Range formatting API (#9635)

This commit is contained in:
Micha Reiser 2024-01-31 11:13:37 +01:00 committed by GitHub
parent 6bb126415d
commit ce14f4dea5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3273 additions and 762 deletions

View file

@ -328,6 +328,12 @@ pub struct SourcePosition(TextSize);
impl<Context> Format<Context> for SourcePosition {
fn fmt(&self, f: &mut Formatter<Context>) -> FormatResult<()> {
if let Some(FormatElement::SourcePosition(last_position)) = f.buffer.elements().last() {
if *last_position == self.0 {
return Ok(());
}
}
f.write_element(FormatElement::SourcePosition(self.0));
Ok(())
@ -353,8 +359,8 @@ where
Context: FormatContext,
{
fn fmt(&self, f: &mut Formatter<Context>) -> FormatResult<()> {
if let Some(source_position) = self.position {
f.write_element(FormatElement::SourcePosition(source_position));
if let Some(position) = self.position {
source_position(position).fmt(f)?;
}
f.write_element(FormatElement::Text {

View file

@ -53,7 +53,7 @@ pub use crate::diagnostics::{ActualStart, FormatError, InvalidDocumentError, Pri
pub use format_element::{normalize_newlines, FormatElement, LINE_TERMINATORS};
pub use group_id::GroupId;
use ruff_macros::CacheKey;
use ruff_text_size::{TextRange, TextSize};
use ruff_text_size::{TextLen, TextRange, TextSize};
#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash, CacheKey)]
#[cfg_attr(
@ -432,6 +432,90 @@ impl Printed {
pub fn take_verbatim_ranges(&mut self) -> Vec<TextRange> {
std::mem::take(&mut self.verbatim_ranges)
}
/// Slices the formatted code to the sub-slices that covers the passed `source_range`.
///
/// The implementation uses the source map generated during formatting to find the closest range
/// in the formatted document that covers `source_range` or more. The returned slice
/// matches the `source_range` exactly (except indent, see below) if the formatter emits [`FormatElement::SourcePosition`] for
/// the range's offsets.
///
/// Returns the entire document if the source map is empty.
#[must_use]
pub fn slice_range(self, source_range: TextRange) -> PrintedRange {
let mut start_marker: Option<SourceMarker> = None;
let mut end_marker: Option<SourceMarker> = None;
// Note: The printer can generate multiple source map entries for the same source position.
// For example if you have:
// * `source_position(276)`
// * `token("def")`
// * `token("foo")`
// * `source_position(284)`
// The printer uses the source position 276 for both the tokens `def` and `foo` because that's the only position it knows of.
//
// Warning: Source markers are often emitted sorted by their source position but it's not guaranteed.
// They are only guaranteed to be sorted in increasing order by their destination position.
for marker in self.sourcemap {
// Take the closest start marker, but skip over start_markers that have the same start.
if marker.source <= source_range.start()
&& !start_marker.is_some_and(|existing| existing.source >= marker.source)
{
start_marker = Some(marker);
}
if marker.source >= source_range.end()
&& !end_marker.is_some_and(|existing| existing.source <= marker.source)
{
end_marker = Some(marker);
}
}
let start = start_marker.map(|marker| marker.dest).unwrap_or_default();
let end = end_marker.map_or_else(|| self.code.text_len(), |marker| marker.dest);
let code_range = TextRange::new(start, end);
PrintedRange {
code: self.code[code_range].to_string(),
source_range,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PrintedRange {
code: String,
source_range: TextRange,
}
impl PrintedRange {
pub fn new(code: String, source_range: TextRange) -> Self {
Self { code, source_range }
}
pub fn empty() -> Self {
Self {
code: String::new(),
source_range: TextRange::default(),
}
}
/// The formatted code.
pub fn as_code(&self) -> &str {
&self.code
}
/// The range the formatted code corresponds to in the source document.
pub fn source_range(&self) -> TextRange {
self.source_range
}
#[must_use]
pub fn with_code(self, code: String) -> Self {
Self { code, ..self }
}
}
/// Public return type of the formatter

View file

@ -60,7 +60,10 @@ impl<'a> Printer<'a> {
document: &'a Document,
indent: u16,
) -> PrintResult<Printed> {
let mut stack = PrintCallStack::new(PrintElementArgs::new(Indention::Level(indent)));
let indentation = Indention::Level(indent);
self.state.pending_indent = indentation;
let mut stack = PrintCallStack::new(PrintElementArgs::new(indentation));
let mut queue: PrintQueue<'a> = PrintQueue::new(document.as_ref());
loop {
@ -143,8 +146,14 @@ impl<'a> Printer<'a> {
FormatElement::SourcePosition(position) => {
self.state.source_position = *position;
// The printer defers printing indents until the next text
// is printed. Pushing the marker now would mean that the
// mapped range includes the indent range, which we don't want.
// Only add a marker if we're not in an indented context, e.g. at the end of the file.
if self.state.pending_indent.is_empty() {
self.push_marker();
}
}
FormatElement::LineSuffixBoundary => {
const HARD_BREAK: &FormatElement = &FormatElement::Line(LineMode::Hard);
@ -511,11 +520,12 @@ impl<'a> Printer<'a> {
dest: self.state.buffer.text_len(),
};
if let Some(last) = self.state.source_markers.last() {
if last != &marker {
self.state.source_markers.push(marker);
}
} else {
if self
.state
.source_markers
.last()
.map_or(true, |last| last != &marker)
{
self.state.source_markers.push(marker);
}
}

View file

@ -5978,9 +5978,10 @@ impl<'a> AnyNodeRef<'a> {
)
}
pub fn visit_preorder<'b, V>(&'b self, visitor: &mut V)
pub fn visit_preorder<'b, V>(self, visitor: &mut V)
where
V: PreorderVisitor<'b> + ?Sized,
'a: 'b,
{
match self {
AnyNodeRef::ModModule(node) => node.visit_preorder(visitor),

View file

@ -239,7 +239,7 @@ pub enum TraversalSignal {
}
impl TraversalSignal {
const fn is_traverse(self) -> bool {
pub const fn is_traverse(self) -> bool {
matches!(self, TraversalSignal::Traverse)
}
}

View file

@ -1,3 +1,7 @@
[mixed_space_and_tab.py]
generated_code = true
ij_formatter_enabled = false
["range_formatting/*.py"]
generated_code = true
ij_formatter_enabled = false

View file

@ -0,0 +1,20 @@
def test ():
if True:
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print(" Do not format this")
def test_empty_lines ():
if True:
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print(" Do not format this")

View file

@ -0,0 +1,44 @@
def test(<RANGE_START>a, b, c: str<RANGE_END>, d):
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
class <RANGE_START> Test(OtherClass<RANGE_END>)\
: # comment
# Should not get formatted
def __init__( self):
print("hello")
print( "dont' format this")
def test2(<RANGE_START>a, b, c: str, d):<RANGE_END>
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
def test3(<RANGE_START>a, b, c: str, d):<RANGE_END> # fmt: skip
print ( "Don't format the body when only making changes to the clause header")
def test4(<RANGE_START> a):
print("Format this" )
if True:
print( "and this")<RANGE_END>
print("Not this" )
<RANGE_START>if a + b : # trailing clause header comment<RANGE_END>
print("Not formatted" )
<RANGE_START>if b + c :<RANGE_END> # trailing clause header comment
print("Not formatted" )

View file

@ -0,0 +1,6 @@
def test ():
<RANGE_START># Some leading comment
# that spans multiple lines
<RANGE_END>
print("Do not format this" )

View file

@ -0,0 +1,23 @@
def test():
print("before" )
@<RANGE_START> decorator( aa)
<RANGE_END>def func ():
print("Do not format this" )
<RANGE_START>@ decorator( a)
def test( a):<RANGE_END>
print( "body")
print("after" )
<RANGE_START>@ decorator( a)<RANGE_END>
def test( a):
print( "body")
print("after" )

View file

@ -0,0 +1,13 @@
[
{
"docstring_code": "enabled",
"indent_style": "space",
"indent_width": 4
},
{
"docstring_code": "enabled",
"docstring_code_line_width": 88,
"indent_style": "space",
"indent_width": 4
}
]

View file

@ -0,0 +1,103 @@
def doctest_simple ():
<RANGE_START>"""
Do cool stuff.
>>> cool_stuff( 1 )
2
"""
pass<RANGE_END>
def doctest_only ():
<RANGE_START>"""
Do cool stuff.
>>> def cool_stuff( x ):
... print( f"hi {x}" );
hi 2
"""<RANGE_END>
pass
def in_doctest ():
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
"""
pass
def suppressed_doctest ():
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
""" # fmt: skip
pass
def fmt_off_doctest ():
# fmt: off
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
"""
# fmt: on
pass
if True:
def doctest_long_lines():
<RANGE_START>
'''
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard)
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
'''
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
<RANGE_END>
if True:
def doctest_long_lines():
<RANGE_START>'''
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard)
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
'''<RANGE_END>
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)

View file

@ -0,0 +1 @@
<RANGE_START><RANGE_END>

View file

@ -0,0 +1,2 @@
def test():
<RANGE_START><RANGE_END>print( "test" )

View file

@ -0,0 +1,24 @@
class MyClass:
# Range that falls entirely in a suppressed range
# fmt: off<RANGE_START>
def method( self ):
print ( "str" )
<RANGE_END># fmt: on
# This should net get formatted because it isn't in a formatting range.
def not_in_formatting_range ( self): ...
# Range that starts in a suppressed range and ends in a formatting range
# fmt: off<RANGE_START>
def other( self):
print ( "str" )
# fmt: on
def formatted ( self):
pass
<RANGE_END>
def outside_formatting_range (self): pass

View file

@ -0,0 +1,12 @@
[
{
"indent_style": "space"
},
{
"indent_style": "tab"
},
{
"indent_style": "space",
"indent_width": 2
}
]

View file

@ -0,0 +1,63 @@
# Formats the entire function with tab or 4 space indentation
# because the statement indentations don't match the preferred indentation.
def test ():
print("before" )
<RANGE_START>1 + 2
if True:
pass
print("Done" )<RANGE_END>
print("formatted" )
print("not formatted" )
def test2 ():
print("before" )
<RANGE_START>1 + 2
(
3 + 2
)
print("Done" )<RANGE_END>
print("formatted" )
print("not formatted" )
def test3 ():
print("before" )
<RANGE_START>1 + 2
"""A Multiline string
that starts at the beginning of the line and we need to preserve the leading spaces"""
"""A Multiline string
that has some indentation on the second line and we need to preserve the leading spaces"""
print("Done" )<RANGE_END>
def test4 ():
print("before" )
<RANGE_START>1 + 2
"""A Multiline string
that uses the same indentation as the formatted code will. This should not be dedented."""
print("Done" )<RANGE_END>
def test5 ():
print("before" )
if True:
print("Format to fix indentation" )
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )
pass
def test6 ():
<RANGE_START>
print("Format" )
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )

View file

@ -0,0 +1,34 @@
def test ():
print( "hello" )
<RANGE_START># leading comment
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )
print( "Hy" )
def test2 ():
print( "hello" )
# Leading comments don't get formatted. That's why Ruff won't fixup
# the indentation here. That's something we might want to explore in the future
# leading comment 1<RANGE_START>
# leading comment 2
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )
def test3 ():
<RANGE_START>print( "hello" )
# leading comment 1
# leading comment 2
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )

View file

@ -0,0 +1,16 @@
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
<RANGE_START>print( "format this")<RANGE_END> # trailing end of line comment
# here's some trailing comment as well
print("Do not format this" )
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
<RANGE_START>print( "format this")
# here's some trailing comment as well
<RANGE_END>
print("Do not format this 2" )

View file

@ -0,0 +1,10 @@
print("Before range start" )
<RANGE_START>
if a + b :
print("formatted" )
print("still in range" )
<RANGE_END>
print("After range end" )

View file

@ -0,0 +1,8 @@
def test ():
<RANGE_START>if True:
print( "format")
elif False:
print ( "and this")<RANGE_END>
print("not this" )
print("nor this" )

View file

@ -0,0 +1,69 @@
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not <RANGE_START>None:
return event_cls<RANGE_END>
else:
raise ValueError(f"unknown event name '{event_name}'")
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not None:
<RANGE_START>
<RANGE_END>
return event_cls
else:
raise ValueError(f"unknown event name '{event_name}'")
# The user starts adding items to a list and then hits save.
# Ruff should trim the empty lines
a = [
1,
2,
3,<RANGE_START>
<RANGE_END>
]
print("Don't format this" )
# The user removed an argument from a call. Ruff should reformat the entire call
call(
a,
<RANGE_START>
<RANGE_END>b,
c,
d
)
print("Don't format this" )
#-----------------------------------------------------------------------------
# The user adds a new comment at the end:
<RANGE_START># <RANGE_END>
#-----------------------------------------------------------------------------
print("Don't format this" )
def convert_str(value: str) -> str: # Trailing comment
"""Return a string as-is."""
<RANGE_START>
return value # Trailing comment
<RANGE_END>
def test ():
pass

View file

@ -0,0 +1,19 @@
def test(<RANGE_START>a ): <RANGE_END>print("body" )
def test2( a): <RANGE_START>print("body" )<RANGE_END>
def test3( a): <RANGE_START>print("body" )
print("more" )<RANGE_END>
print("after" )
# The if header and the print statement together are longer than 100 characters.
# The print statement should either be wrapped to fit at the end of the if statement, or be converted to a
# suite body
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd: <RANGE_START>print("aaaa long body, should wrap or be intented" )<RANGE_END>
# This print statement is too-long even when intented. It should be wrapped
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd: <RANGE_START>print("aaaa long body, should wrap or be intented", "more content to make it exceed the 88 chars limit")<RANGE_END>

View file

@ -0,0 +1,6 @@
[
{
"preview": "enabled",
"source_type": "Stub"
}
]

View file

@ -0,0 +1,16 @@
# Don't collapse the ellipsis if only formatting the ellipsis line.
class Test:
<RANGE_START>...<RANGE_END>
class Test2: <RANGE_START>pass<RANGE_END>
class Test3: <RANGE_START>...<RANGE_END>
class Test4:
# leading comment
<RANGE_START>...<RANGE_END>
# trailing comment
class Test4:
<RANGE_START> ...<RANGE_END>

View file

@ -0,0 +1,30 @@
def test1 ():
print("hello" )
<RANGE_START>1 + 2<RANGE_END> # trailing comment
print ("world" )
def test2 ():
print("hello" )
# FIXME: For some reason the trailing comment here gets not formatted
# but is correctly formatted above
<RANGE_START>1 + 2 # trailing comment<RANGE_END>
print ("world" )
def test3 ():
print("hellO" )
<RANGE_START>1 + 2 # trailing comment
# trailing section comment
<RANGE_END>
def test3 ():
print("hellO" )
<RANGE_START>1 + 2 # trailing comment
print("more" ) # trailing comment 2
# trailing section comment
<RANGE_END>
print( "world" )

View file

@ -0,0 +1,6 @@
def test():
pass <RANGE_START>
<RANGE_END>
def test_formatted(): pass

View file

@ -432,7 +432,17 @@ impl Format<PyFormatContext<'_>> for FormatNormalizedComment<'_> {
fn fmt(&self, f: &mut Formatter<PyFormatContext>) -> FormatResult<()> {
match self.comment {
Cow::Borrowed(borrowed) => {
source_text_slice(TextRange::at(self.range.start(), borrowed.text_len())).fmt(f)
source_text_slice(TextRange::at(self.range.start(), borrowed.text_len())).fmt(f)?;
// Write the end position if the borrowed comment is shorter than the original comment
// So that we still can map back the end of a comment to the formatted code.
if f.options().source_map_generation().is_enabled()
&& self.range.len() != borrowed.text_len()
{
source_position(self.range.end()).fmt(f)?;
}
Ok(())
}
Cow::Owned(ref owned) => {

View file

@ -98,7 +98,6 @@ pub(crate) use format::{
use ruff_formatter::{SourceCode, SourceCodeSlice};
use ruff_python_ast::visitor::preorder::{PreorderVisitor, TraversalSignal};
use ruff_python_ast::AnyNodeRef;
use ruff_python_ast::Mod;
use ruff_python_trivia::{CommentRanges, PythonWhitespace};
use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextRange};
@ -347,10 +346,15 @@ impl<'a> Comments<'a> {
/// Extracts the comments from the AST.
pub(crate) fn from_ast(
root: &'a Mod,
root: impl Into<AnyNodeRef<'a>>,
source_code: SourceCode<'a>,
comment_ranges: &'a CommentRanges,
) -> Self {
fn collect_comments<'a>(
root: AnyNodeRef<'a>,
source_code: SourceCode<'a>,
comment_ranges: &'a CommentRanges,
) -> Comments<'a> {
let map = if comment_ranges.is_empty() {
CommentsMap::new()
} else {
@ -360,7 +364,10 @@ impl<'a> Comments<'a> {
builder.finish()
};
Self::new(map, comment_ranges)
Comments::new(map, comment_ranges)
}
collect_comments(root.into(), source_code, comment_ranges)
}
/// Returns `true` if the given `node` has any comments.

View file

@ -24,7 +24,7 @@ pub(crate) fn collect_comments<'a>(
comment_ranges: &'a CommentRanges,
) -> Vec<DecoratedComment<'a>> {
let mut collector = CommentsVecBuilder::default();
CommentsVisitor::new(source_code, comment_ranges, &mut collector).visit(root);
CommentsVisitor::new(source_code, comment_ranges, &mut collector).visit(AnyNodeRef::from(root));
collector.comments
}
@ -52,8 +52,12 @@ impl<'a, 'builder> CommentsVisitor<'a, 'builder> {
}
}
pub(super) fn visit(mut self, root: &'a Mod) {
self.visit_mod(root);
pub(super) fn visit(mut self, root: AnyNodeRef<'a>) {
if self.enter_node(root).is_traverse() {
root.visit_preorder(&mut self);
}
self.leave_node(root);
}
// Try to skip the subtree if

View file

@ -1,6 +1,7 @@
use thiserror::Error;
use tracing::Level;
pub use range::format_range;
use ruff_formatter::prelude::*;
use ruff_formatter::{format, FormatError, Formatted, PrintError, Printed, SourceCode};
use ruff_python_ast::AstNode;
@ -33,6 +34,7 @@ pub(crate) mod other;
pub(crate) mod pattern;
mod prelude;
mod preview;
mod range;
mod shared_traits;
pub(crate) mod statement;
pub(crate) mod string;
@ -170,8 +172,9 @@ mod tests {
use ruff_python_ast::PySourceType;
use ruff_python_index::tokens_and_ranges;
use ruff_python_parser::{parse_tokens, AsMode};
use ruff_text_size::{TextRange, TextSize};
use crate::{format_module_ast, format_module_source, PyFormatOptions};
use crate::{format_module_ast, format_module_source, format_range, PyFormatOptions};
/// Very basic test intentionally kept very similar to the CLI
#[test]
@ -242,6 +245,34 @@ def main() -> None:
);
}
/// Use this test to quickly debug some formatting issue.
#[ignore]
#[test]
fn range_formatting_quick_test() {
let source = r#"def test2( a): print("body" )
"#;
let start = TextSize::new(20);
let end = TextSize::new(35);
let source_type = PySourceType::Python;
let options = PyFormatOptions::from_source_type(source_type);
let printed = format_range(source, TextRange::new(start, end), options).unwrap();
let mut formatted = source.to_string();
formatted.replace_range(
std::ops::Range::<usize>::from(printed.source_range()),
printed.as_code(),
);
assert_eq!(
formatted,
r#"def test2(a):
print("body")
"#
);
}
#[test]
fn string_processing() {
use crate::prelude::*;

View file

@ -203,6 +203,12 @@ impl PyFormatOptions {
self.preview = preview;
self
}
#[must_use]
pub fn with_source_map_generation(mut self, source_map: SourceMapGeneration) -> Self {
self.source_map_generation = source_map;
self
}
}
impl FormatOptions for PyFormatOptions {

View file

@ -0,0 +1,740 @@
use tracing::Level;
use ruff_formatter::printer::SourceMapGeneration;
use ruff_formatter::{
format, FormatContext, FormatError, FormatOptions, IndentStyle, PrintedRange, SourceCode,
};
use ruff_python_ast::visitor::preorder::{walk_body, PreorderVisitor, TraversalSignal};
use ruff_python_ast::{AnyNode, AnyNodeRef, Stmt, StmtMatch, StmtTry};
use ruff_python_index::tokens_and_ranges;
use ruff_python_parser::{parse_tokens, AsMode, ParseError, ParseErrorType};
use ruff_python_trivia::{indentation_at_offset, BackwardsTokenizer, SimpleToken, SimpleTokenKind};
use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use crate::comments::Comments;
use crate::context::{IndentLevel, NodeLevel};
use crate::prelude::*;
use crate::statement::suite::DocstringStmt;
use crate::verbatim::{ends_suppression, starts_suppression};
use crate::{format_module_source, FormatModuleError, PyFormatOptions};
/// Formats the given `range` in source rather than the entire file.
///
/// The returned formatted range guarantees to cover at least `range` (excluding whitespace), but the range might be larger.
/// Some cases in which the returned range is larger than `range` are:
/// * The logical lines in `range` use a indentation different from the configured [`IndentStyle`] and [`IndentWidth`].
/// * `range` is smaller than a logical lines and the formatter needs to format the entire logical line.
/// * `range` falls on a single line body.
///
/// The formatting of logical lines using range formatting should produce the same result as when formatting the entire document (for the same lines and options).
///
/// ## Implementation
///
/// This is an optimisation problem. The goal is to find the minimal range that fully covers `range`, is still formattable,
/// and produces the same result as when formatting the entire document.
///
/// The implementation performs the following steps:
/// 1. Find the deepest node that fully encloses `range`. The node with the minimum covering range.
/// 2. Try to narrow the range found in step one by searching its children and find node and comment start and end offsets that are closer to `range`'s start and end.
/// 3. Format the node from step 1 and use the source map information generated by the formatter to map the narrowed range in the source document to the range in the formatted output.
/// 4. Take the formatted code and return it.
///
/// # Error
/// Returns a range error if `range` lies outside of the source file.
///
/// # Panics
/// If `range` doesn't point to a valid char boundaries.
///
/// [`IndentWidth`]: `ruff_formatter::IndentWidth`
#[tracing::instrument(name = "format_range", level = Level::TRACE, skip_all)]
pub fn format_range(
source: &str,
range: TextRange,
options: PyFormatOptions,
) -> Result<PrintedRange, FormatModuleError> {
// 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()),
}));
}
// Formatting an empty string always yields an empty string. Return directly.
if range.is_empty() {
return Ok(PrintedRange::empty());
}
if range == TextRange::up_to(source.text_len()) {
let formatted = format_module_source(source, options)?;
return Ok(PrintedRange::new(formatted.into_code(), range));
}
let (tokens, comment_ranges) =
tokens_and_ranges(source, options.source_type()).map_err(|err| ParseError {
offset: err.location,
error: ParseErrorType::Lexical(err.error),
})?;
assert_valid_char_boundaries(range, source);
let module = parse_tokens(tokens, source, options.source_type().as_mode())?;
let root = AnyNode::from(module);
let source_code = SourceCode::new(source);
let comments = Comments::from_ast(root.as_ref(), source_code, &comment_ranges);
let mut context = PyFormatContext::new(
options.with_source_map_generation(SourceMapGeneration::Enabled),
source,
comments,
);
let (enclosing_node, base_indent) = match find_enclosing_node(range, root.as_ref(), &context) {
EnclosingNode::Node { node, indent_level } => (node, indent_level),
EnclosingNode::Suppressed => {
// The entire range falls into a suppressed range. There's nothing to format.
return Ok(PrintedRange::empty());
}
};
let narrowed_range = narrow_range(range, enclosing_node, &context);
assert_valid_char_boundaries(narrowed_range, source);
// Correctly initialize the node level for the blank line rules.
if !enclosing_node.is_mod_module() {
context.set_node_level(NodeLevel::CompoundStatement);
context.set_indent_level(
// Plus 1 because `IndentLevel=0` equals the module level.
IndentLevel::new(base_indent.saturating_add(1)),
);
}
let formatted = format!(
context,
[FormatEnclosingNode {
root: enclosing_node
}]
)?;
let printed = formatted.print_with_indent(base_indent)?;
Ok(printed.slice_range(narrowed_range))
}
/// Finds the node with the minimum covering range of `range`.
///
/// It traverses the tree and returns the deepest node that fully encloses `range`.
///
/// ## Eligible nodes
/// The search is restricted to nodes that mark the start of a logical line to ensure
/// formatting a range results in the same formatting for that logical line as when formatting the entire document.
/// This property can't be guaranteed when supporting sub-expression formatting because
/// a) Adding parentheses around enclosing expressions can toggle an expression from non-splittable to splittable,
/// b) formatting a sub-expression has fewer split points than formatting the entire expressions.
///
/// ### Possible docstrings
/// Strings that are suspected to be docstrings are excluded from the search to format the enclosing suite instead
/// so that the formatter's docstring detection in [`FormatSuite`] correctly detects and formats the docstrings.
///
/// ### Compound statements with a simple statement body
/// Don't include simple-statement bodies of compound statements `if True: pass` because the formatter
/// must run [`FormatClauseBody`] to determine if the body should be collapsed or not.
///
/// ### Incorrectly indented code
/// Code that uses indentations that don't match the configured [`IndentStyle`] and [`IndentWidth`] are excluded from the search,
/// because formatting such nodes on their own can lead to indentation mismatch with its sibling nodes.
///
/// ## Suppression comments
/// The search ends when `range` falls into a suppressed range because there's nothing to format. It also avoids that the
/// formatter formats the statement because it doesn't see the suppression comment of the enclosing node.
///
/// The implementation doesn't handle `fmt: ignore` suppression comments because the statement's formatting logic
/// correctly detects the suppression comment and returns the statement text as is.
fn find_enclosing_node<'ast>(
range: TextRange,
root: AnyNodeRef<'ast>,
context: &PyFormatContext<'ast>,
) -> EnclosingNode<'ast> {
let mut visitor = FindEnclosingNode::new(range, context);
if visitor.enter_node(root).is_traverse() {
root.visit_preorder(&mut visitor);
}
visitor.leave_node(root);
visitor.closest
}
struct FindEnclosingNode<'a, 'ast> {
range: TextRange,
context: &'a PyFormatContext<'ast>,
/// The, to this point, deepest node that fully encloses `range`.
closest: EnclosingNode<'ast>,
/// Tracks if the current statement is suppressed
suppressed: Suppressed,
}
impl<'a, 'ast> FindEnclosingNode<'a, 'ast> {
fn new(range: TextRange, context: &'a PyFormatContext<'ast>) -> Self {
Self {
range,
context,
suppressed: Suppressed::No,
closest: EnclosingNode::Suppressed,
}
}
}
impl<'ast> PreorderVisitor<'ast> for FindEnclosingNode<'_, 'ast> {
fn enter_node(&mut self, node: AnyNodeRef<'ast>) -> TraversalSignal {
if !(is_logical_line(node) || node.is_mod_module()) {
return TraversalSignal::Skip;
}
// Handle `fmt: off` suppression comments for statements.
if node.is_statement() {
let leading_comments = self.context.comments().leading(node);
self.suppressed = Suppressed::from(match self.suppressed {
Suppressed::No => starts_suppression(leading_comments, self.context.source()),
Suppressed::Yes => !ends_suppression(leading_comments, self.context.source()),
});
}
if !node.range().contains_range(self.range) {
return TraversalSignal::Skip;
}
if self.suppressed.is_yes() && node.is_statement() {
self.closest = EnclosingNode::Suppressed;
return TraversalSignal::Skip;
}
// Don't pick potential docstrings as the closest enclosing node because `suite.rs` than fails to identify them as
// docstrings and docstring formatting won't kick in.
// Format the enclosing node instead and slice the formatted docstring from the result.
let is_maybe_docstring = node
.as_stmt_expr()
.is_some_and(|stmt| DocstringStmt::is_docstring_statement(stmt));
if is_maybe_docstring {
return TraversalSignal::Skip;
}
// Only computing the count here is sufficient because each enclosing node ensures that it has the necessary indent
// or we don't traverse otherwise.
let Some(indent_level) =
indent_level(node.start(), self.context.source(), self.context.options())
else {
// Non standard indent or a simple-statement body of a compound statement, format the enclosing node
return TraversalSignal::Skip;
};
self.closest = EnclosingNode::Node { node, indent_level };
TraversalSignal::Traverse
}
fn leave_node(&mut self, node: AnyNodeRef<'ast>) {
if node.is_statement() {
let trailing_comments = self.context.comments().trailing(node);
// Update the suppressed state for the next statement.
self.suppressed = Suppressed::from(match self.suppressed {
Suppressed::No => starts_suppression(trailing_comments, self.context.source()),
Suppressed::Yes => !ends_suppression(trailing_comments, self.context.source()),
});
}
}
fn visit_body(&mut self, body: &'ast [Stmt]) {
// We only visit statements that aren't suppressed that's why we don't need to track the suppression
// state in a stack. Assert that this assumption is safe.
debug_assert!(self.suppressed.is_no());
walk_body(self, body);
self.suppressed = Suppressed::No;
}
}
#[derive(Debug, Copy, Clone)]
enum EnclosingNode<'a> {
/// The entire range falls into a suppressed `fmt: off` range.
Suppressed,
/// The node outside of a suppression range that fully encloses the searched range.
Node {
node: AnyNodeRef<'a>,
indent_level: u16,
},
}
/// Narrows the formatting `range` to a smaller sub-range than the enclosing node's range.
///
/// The range is narrowed by searching the enclosing node's children and:
/// * Find the closest node or comment start or end offset to `range.start`
/// * Find the closest node or comment start or end offset, or the clause header's `:` end offset to `range.end`
///
/// The search is restricted to positions where the formatter emits source map entries because it guarantees
/// that we know the exact range in the formatted range and not just an approximation that could include other tokens.
///
/// ## Clause Headers
/// For clause headers like `if`, `while`, `match`, `case` etc. consider the `:` end position for narrowing `range.end`
/// to support formatting the clause header without its body.
///
/// ## Compound statements with simple statement bodies
/// Similar to [`find_enclosing_node`], exclude the compound statement's body if it is a simple statement (not a suite) from the search to format the entire clause header
/// with the body. This ensures that the formatter runs [`FormatClauseBody`] that determines if the body should be indented.s
///
/// ## Non-standard indentation
/// Node's that use an indentation that doesn't match the configured [`IndentStyle`] and [`IndentWidth`] are excluded from the search.
/// This is because the formatter always uses the configured [`IndentStyle`] and [`IndentWidth`], resulting in the
/// formatted nodes using a different indentation than the unformatted sibling nodes. This would be tolerable
/// in non whitespace sensitive languages like JavaScript but results in lexical errors in Python.
///
/// ## Implementation
/// It would probably be possible to merge this visitor with [`FindEnclosingNode`] but they are separate because
/// it avoids some unnecessary work for nodes that aren't the `enclosing_node` and I found reasoning
/// and debugging the visiting logic easier when they are separate.
///
/// [`IndentStyle`]: ruff_formatter::IndentStyle
/// [`IndentWidth`]: ruff_formatter::IndentWidth
fn narrow_range(
range: TextRange,
enclosing_node: AnyNodeRef,
context: &PyFormatContext,
) -> TextRange {
let locator = context.locator();
let enclosing_indent = indentation_at_offset(enclosing_node.start(), &locator)
.expect("Expected enclosing to never be a same line body statement.");
let mut visitor = NarrowRange {
context,
range,
narrowed_start: enclosing_node.start(),
narrowed_end: enclosing_node.end(),
enclosing_indent,
level: usize::from(!enclosing_node.is_mod_module()),
};
if visitor.enter_node(enclosing_node).is_traverse() {
enclosing_node.visit_preorder(&mut visitor);
}
visitor.leave_node(enclosing_node);
TextRange::new(visitor.narrowed_start, visitor.narrowed_end)
}
struct NarrowRange<'a> {
context: &'a PyFormatContext<'a>,
// The range to format
range: TextRange,
// The narrowed range
narrowed_start: TextSize,
narrowed_end: TextSize,
// Stated tracked by the visitor
enclosing_indent: &'a str,
level: usize,
}
impl PreorderVisitor<'_> for NarrowRange<'_> {
fn enter_node(&mut self, node: AnyNodeRef<'_>) -> TraversalSignal {
if !(is_logical_line(node) || node.is_mod_module()) {
return TraversalSignal::Skip;
}
// Find the start offset of the node that starts the closest to (and before) the start offset of the formatting range.
// We do this by iterating over known positions that emit source map entries and pick the start point that ends closest
// to the searched range's start.
let leading_comments = self.context.comments().leading(node);
self.narrow(leading_comments);
self.narrow([node]);
// Avoid traversing when it's known to not be able to narrow the range further to avoid traversing the entire tree (entire file in the worst case).
// If the node's range is entirely before the searched range, don't traverse because non of its children
// can be closer to `narrow_start` than the node itself (which we already narrowed).
//
// Don't traverse if the current node is passed the narrowed range (it's impossible to refine it further).
if node.end() < self.range.start()
|| (self.narrowed_start > node.start() && self.narrowed_end <= node.end())
{
return TraversalSignal::Skip;
}
// Handle nodes that have indented child-nodes that aren't a `Body` (which is handled by `visit_body`).
// Ideally, this would be handled as part of `visit_stmt` but `visit_stmt` doesn't get called for the `enclosing_node`
// because it's not possible to convert` AnyNodeRef` to `&Stmt` :(
match node {
AnyNodeRef::StmtMatch(StmtMatch {
subject: _,
cases,
range: _,
}) => {
if let Some(saved_state) = self.enter_level(cases.first().map(AnyNodeRef::from)) {
for match_case in cases {
self.visit_match_case(match_case);
}
self.leave_level(saved_state);
}
// Already traversed as part of `enter_node`.
TraversalSignal::Skip
}
AnyNodeRef::StmtTry(StmtTry {
body,
handlers,
orelse,
finalbody,
is_star: _,
range: _,
}) => {
self.visit_body(body);
if let Some(except_handler_saved) =
self.enter_level(handlers.first().map(AnyNodeRef::from))
{
for except_handler in handlers {
self.visit_except_handler(except_handler);
}
self.leave_level(except_handler_saved);
}
self.visit_body(orelse);
self.visit_body(finalbody);
// Already traversed as part of `enter_node`.
TraversalSignal::Skip
}
_ => TraversalSignal::Traverse,
}
}
fn leave_node(&mut self, node: AnyNodeRef<'_>) {
if !(is_logical_line(node) || node.is_mod_module()) {
return;
}
// Find the end offset of the closest node to the end offset of the formatting range.
// We do this by iterating over end positions that we know generate source map entries end pick the end
// that ends closest or after the searched range's end.
let trailing_comments = self.context.comments().trailing(node);
self.narrow(trailing_comments);
}
fn visit_body(&mut self, body: &'_ [Stmt]) {
if let Some(saved_state) = self.enter_level(body.first().map(AnyNodeRef::from)) {
walk_body(self, body);
self.leave_level(saved_state);
}
}
}
impl NarrowRange<'_> {
fn narrow<I, T>(&mut self, items: I)
where
I: IntoIterator<Item = T>,
T: Ranged,
{
for ranged in items {
self.narrow_offset(ranged.start());
self.narrow_offset(ranged.end());
}
}
fn narrow_offset(&mut self, offset: TextSize) {
self.narrow_start(offset);
self.narrow_end(offset);
}
fn narrow_start(&mut self, offset: TextSize) {
if offset <= self.range.start() {
self.narrowed_start = self.narrowed_start.max(offset);
}
}
fn narrow_end(&mut self, offset: TextSize) {
if offset >= self.range.end() {
self.narrowed_end = self.narrowed_end.min(offset);
}
}
fn enter_level(&mut self, first_child: Option<AnyNodeRef>) -> Option<SavedLevel> {
if let Some(first_child) = first_child {
// If this is a clause header and the `range` ends within the clause header, then avoid formatting the body.
// This prevents that we format an entire function definition when the selected range is fully enclosed by the parameters.
// ```python
// 1| def foo(<RANGE_START>a, b, c<RANGE_END>):
// 2| pass
// ```
// We don't want to format the body of the function.
if let Some(SimpleToken {
kind: SimpleTokenKind::Colon,
range: colon_range,
}) = BackwardsTokenizer::up_to(
first_child.start(),
self.context.source(),
self.context.comments().ranges(),
)
.skip_trivia()
.next()
{
self.narrow_offset(colon_range.end());
}
// It is necessary to format all statements if the statement or any of its parents don't use the configured indentation.
// ```python
// 0| def foo():
// 1| if True:
// 2| print("Hello")
// 3| print("More")
// 4| a = 10
// ```
// Here, the `if` statement uses the correct 4 spaces indentation, but the two `print` statements use a 2 spaces indentation.
// The formatter output uses 8 space indentation for the `print` statement which doesn't match the indentation of the statement on line 4 when
// replacing the source with the formatted code. That's why we expand the range in this case to cover the entire if-body range.
//
// I explored the alternative of using `indent(dedent(formatted))` to retain the correct indentation. It works pretty well except that it can change the
// content of multiline strings:
// ```python
// def test ():
// pass
// <RANGE_START>1 + 2
// """A Multiline string
// that uses the same indentation as the formatted code will. This should not be dedented."""
//
// print("Done")<RANGE_END>
// ```
// The challenge here is that the second line of the multiline string uses a 4 space indentation. Using `dedent` would
// dedent the second line to 0 spaces and the `indent` then adds a 2 space indentation to match the indentation in the source.
// This is incorrect because the leading whitespace is the content of the string and not indentation, resulting in changed string content.
if let Some(indentation) =
indentation_at_offset(first_child.start(), &self.context.locator())
{
let relative_indent = indentation.strip_prefix(self.enclosing_indent).unwrap();
let expected_indents = self.level;
// Each level must always add one level of indent. That's why an empty relative indent to the parent node tells us that the enclosing node is the Module.
let has_expected_indentation = match self.context.options().indent_style() {
IndentStyle::Tab => {
relative_indent.len() == expected_indents
&& relative_indent.chars().all(|c| c == '\t')
}
IndentStyle::Space => {
relative_indent.len()
== expected_indents
* self.context.options().indent_width().value() as usize
&& relative_indent.chars().all(|c| c == ' ')
}
};
if !has_expected_indentation {
return None;
}
} else {
// Simple-statement body of a compound statement (not a suite body).
// Don't narrow the range because the formatter must run `FormatClauseBody` to determine if the body should be collapsed or not.
return None;
}
}
let saved_level = self.level;
self.level += 1;
Some(SavedLevel { level: saved_level })
}
#[allow(clippy::needless_pass_by_value)]
fn leave_level(&mut self, saved_state: SavedLevel) {
self.level = saved_state.level;
}
}
const fn is_logical_line(node: AnyNodeRef) -> bool {
// Make sure to update [`FormatEnclosingLine`] when changing this.
node.is_statement()
|| node.is_decorator()
|| node.is_except_handler()
|| node.is_elif_else_clause()
|| node.is_match_case()
}
#[derive(Debug)]
struct SavedLevel {
level: usize,
}
#[derive(Copy, Clone, Default, Debug)]
enum Suppressed {
/// Code is not suppressed
#[default]
No,
/// The node is suppressed by a suppression comment in the same body block.
Yes,
}
impl Suppressed {
const fn is_no(self) -> bool {
matches!(self, Suppressed::No)
}
const fn is_yes(self) -> bool {
matches!(self, Suppressed::Yes)
}
}
impl From<bool> for Suppressed {
fn from(value: bool) -> Self {
if value {
Suppressed::Yes
} else {
Suppressed::No
}
}
}
fn assert_valid_char_boundaries(range: TextRange, source: &str) {
assert!(source.is_char_boundary(usize::from(range.start())));
assert!(source.is_char_boundary(usize::from(range.end())));
}
struct FormatEnclosingNode<'a> {
root: AnyNodeRef<'a>,
}
impl Format<PyFormatContext<'_>> for FormatEnclosingNode<'_> {
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
// Note: It's important that this supports formatting all nodes for which `is_logical_line`
// returns + the root `Mod` nodes.
match self.root {
AnyNodeRef::ModModule(node) => node.format().fmt(f),
AnyNodeRef::ModExpression(node) => node.format().fmt(f),
AnyNodeRef::StmtFunctionDef(node) => node.format().fmt(f),
AnyNodeRef::StmtClassDef(node) => node.format().fmt(f),
AnyNodeRef::StmtReturn(node) => node.format().fmt(f),
AnyNodeRef::StmtDelete(node) => node.format().fmt(f),
AnyNodeRef::StmtTypeAlias(node) => node.format().fmt(f),
AnyNodeRef::StmtAssign(node) => node.format().fmt(f),
AnyNodeRef::StmtAugAssign(node) => node.format().fmt(f),
AnyNodeRef::StmtAnnAssign(node) => node.format().fmt(f),
AnyNodeRef::StmtFor(node) => node.format().fmt(f),
AnyNodeRef::StmtWhile(node) => node.format().fmt(f),
AnyNodeRef::StmtIf(node) => node.format().fmt(f),
AnyNodeRef::StmtWith(node) => node.format().fmt(f),
AnyNodeRef::StmtMatch(node) => node.format().fmt(f),
AnyNodeRef::StmtRaise(node) => node.format().fmt(f),
AnyNodeRef::StmtTry(node) => node.format().fmt(f),
AnyNodeRef::StmtAssert(node) => node.format().fmt(f),
AnyNodeRef::StmtImport(node) => node.format().fmt(f),
AnyNodeRef::StmtImportFrom(node) => node.format().fmt(f),
AnyNodeRef::StmtGlobal(node) => node.format().fmt(f),
AnyNodeRef::StmtNonlocal(node) => node.format().fmt(f),
AnyNodeRef::StmtExpr(node) => node.format().fmt(f),
AnyNodeRef::StmtPass(node) => node.format().fmt(f),
AnyNodeRef::StmtBreak(node) => node.format().fmt(f),
AnyNodeRef::StmtContinue(node) => node.format().fmt(f),
AnyNodeRef::StmtIpyEscapeCommand(node) => node.format().fmt(f),
AnyNodeRef::ExceptHandlerExceptHandler(node) => node.format().fmt(f),
AnyNodeRef::MatchCase(node) => node.format().fmt(f),
AnyNodeRef::Decorator(node) => node.format().fmt(f),
AnyNodeRef::ElifElseClause(node) => node.format().fmt(f),
AnyNodeRef::ExprBoolOp(_)
| AnyNodeRef::ExprNamedExpr(_)
| AnyNodeRef::ExprBinOp(_)
| AnyNodeRef::ExprUnaryOp(_)
| AnyNodeRef::ExprLambda(_)
| AnyNodeRef::ExprIfExp(_)
| AnyNodeRef::ExprDict(_)
| AnyNodeRef::ExprSet(_)
| AnyNodeRef::ExprListComp(_)
| AnyNodeRef::ExprSetComp(_)
| AnyNodeRef::ExprDictComp(_)
| AnyNodeRef::ExprGeneratorExp(_)
| AnyNodeRef::ExprAwait(_)
| AnyNodeRef::ExprYield(_)
| AnyNodeRef::ExprYieldFrom(_)
| AnyNodeRef::ExprCompare(_)
| AnyNodeRef::ExprCall(_)
| AnyNodeRef::FStringExpressionElement(_)
| AnyNodeRef::FStringLiteralElement(_)
| AnyNodeRef::ExprFString(_)
| AnyNodeRef::ExprStringLiteral(_)
| AnyNodeRef::ExprBytesLiteral(_)
| AnyNodeRef::ExprNumberLiteral(_)
| AnyNodeRef::ExprBooleanLiteral(_)
| AnyNodeRef::ExprNoneLiteral(_)
| AnyNodeRef::ExprEllipsisLiteral(_)
| AnyNodeRef::ExprAttribute(_)
| AnyNodeRef::ExprSubscript(_)
| AnyNodeRef::ExprStarred(_)
| AnyNodeRef::ExprName(_)
| AnyNodeRef::ExprList(_)
| AnyNodeRef::ExprTuple(_)
| AnyNodeRef::ExprSlice(_)
| AnyNodeRef::ExprIpyEscapeCommand(_)
| AnyNodeRef::FString(_)
| AnyNodeRef::StringLiteral(_)
| AnyNodeRef::PatternMatchValue(_)
| AnyNodeRef::PatternMatchSingleton(_)
| AnyNodeRef::PatternMatchSequence(_)
| AnyNodeRef::PatternMatchMapping(_)
| AnyNodeRef::PatternMatchClass(_)
| AnyNodeRef::PatternMatchStar(_)
| AnyNodeRef::PatternMatchAs(_)
| AnyNodeRef::PatternMatchOr(_)
| AnyNodeRef::PatternArguments(_)
| AnyNodeRef::PatternKeyword(_)
| AnyNodeRef::Comprehension(_)
| AnyNodeRef::Arguments(_)
| AnyNodeRef::Parameters(_)
| AnyNodeRef::Parameter(_)
| AnyNodeRef::ParameterWithDefault(_)
| AnyNodeRef::Keyword(_)
| AnyNodeRef::Alias(_)
| AnyNodeRef::WithItem(_)
| AnyNodeRef::TypeParams(_)
| AnyNodeRef::TypeParamTypeVar(_)
| AnyNodeRef::TypeParamTypeVarTuple(_)
| AnyNodeRef::TypeParamParamSpec(_)
| AnyNodeRef::BytesLiteral(_) => {
panic!("Range formatting only supports formatting logical lines")
}
}
}
}
/// Computes the level of indentation for `indentation` when using the configured [`IndentStyle`] and [`IndentWidth`].
///
/// Returns `None` if the indentation doesn't conform to the configured [`IndentStyle`] and [`IndentWidth`].
///
/// # Panics
/// If `offset` is outside of `source`.
fn indent_level(offset: TextSize, source: &str, options: &PyFormatOptions) -> Option<u16> {
let locator = Locator::new(source);
let indentation = indentation_at_offset(offset, &locator)?;
let level = match options.indent_style() {
IndentStyle::Tab => {
if indentation.chars().all(|c| c == '\t') {
Some(indentation.len())
} else {
None
}
}
IndentStyle::Space => {
let indent_width = options.indent_width().value() as usize;
if indentation.chars().all(|c| c == ' ') && indentation.len() % indent_width == 0 {
Some(indentation.len() / indent_width)
} else {
None
}
}
};
level.map(|level| u16::try_from(level).unwrap_or(u16::MAX))
}

View file

@ -357,8 +357,20 @@ impl<'ast> Format<PyFormatContext<'ast>> for FormatClauseHeader<'_, 'ast> {
if SuppressionKind::has_skip_comment(self.trailing_colon_comment, f.context().source()) {
write_suppressed_clause_header(self.header, f)?;
} else {
f.write_fmt(Arguments::from(&self.formatter))?;
token(":").fmt(f)?;
// Write a source map entry for the colon for range formatting to support formatting the clause header without
// the clause body. Avoid computing `self.header.range()` otherwise because it's somewhat involved.
let clause_end = if f.options().source_map_generation().is_enabled() {
Some(source_position(
self.header.range(f.context().source())?.end(),
))
} else {
None
};
write!(
f,
[Arguments::from(&self.formatter), token(":"), clause_end]
)?;
}
trailing_comments(self.trailing_colon_comment).fmt(f)
@ -458,7 +470,12 @@ fn find_keyword(
fn colon_range(after_keyword_or_condition: TextSize, source: &str) -> FormatResult<TextRange> {
let mut tokenizer = SimpleTokenizer::starts_at(after_keyword_or_condition, source)
.skip_trivia()
.skip_while(|token| token.kind() == SimpleTokenKind::RParen);
.skip_while(|token| {
matches!(
token.kind(),
SimpleTokenKind::RParen | SimpleTokenKind::Comma
)
});
match tokenizer.next() {
Some(SimpleToken {

View file

@ -1,6 +1,7 @@
use ruff_formatter::{format_args, write};
use ruff_python_ast::AnyNodeRef;
use ruff_python_ast::{ElifElseClause, StmtIf};
use ruff_text_size::Ranged;
use crate::comments::SourceComment;
use crate::expression::maybe_parenthesize_expression;
@ -81,7 +82,12 @@ pub(crate) fn format_elif_else_clause(
clause_header(
ClauseHeader::ElifElse(item),
trailing_colon_comment,
&format_with(|f| {
&format_with(|f: &mut PyFormatter| {
f.options()
.source_map_generation()
.is_enabled()
.then_some(source_position(item.start()))
.fmt(f)?;
if let Some(test) = test {
write!(
f,
@ -98,6 +104,10 @@ pub(crate) fn format_elif_else_clause(
)
.with_leading_comments(leading_comments, last_node),
clause_body(body, trailing_colon_comment),
f.options()
.source_map_generation()
.is_enabled()
.then_some(source_position(item.end()))
]
)
}

View file

@ -2,8 +2,8 @@ use ruff_formatter::{
write, FormatContext, FormatOwnedWithRule, FormatRefWithRule, FormatRuleWithOptions,
};
use ruff_python_ast::helpers::is_compound_statement;
use ruff_python_ast::AnyNodeRef;
use ruff_python_ast::{self as ast, Expr, PySourceType, Stmt, Suite};
use ruff_python_ast::{AnyNodeRef, StmtExpr};
use ruff_python_trivia::{lines_after, lines_after_ignoring_end_of_line_trivia, lines_before};
use ruff_text_size::{Ranged, TextRange};
@ -740,6 +740,14 @@ impl<'a> DocstringStmt<'a> {
_ => None,
}
}
pub(crate) fn is_docstring_statement(stmt: &StmtExpr) -> bool {
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = stmt.value.as_ref() {
!value.is_implicit_concatenated()
} else {
false
}
}
}
impl Format<PyFormatContext<'_>> for DocstringStmt<'_> {

View file

@ -19,6 +19,40 @@ use crate::statement::clause::ClauseHeader;
use crate::statement::suite::SuiteChildStatement;
use crate::statement::trailing_semicolon;
/// Returns `true` if the statements coming after `leading_or_trailing_comments` are suppressed.
///
/// The result is only correct if called for statement comments in a non-suppressed range.
///
/// # Panics
/// If `leading_or_trailing_comments` contain any range that's outside of `source`.
pub(crate) fn starts_suppression(
leading_or_trailing_comments: &[SourceComment],
source: &str,
) -> bool {
let mut iter = CommentRangeIter::outside_suppression(leading_or_trailing_comments, source);
// Move the iter to the last element.
let _ = iter.by_ref().last();
matches!(iter.in_suppression, InSuppression::Yes)
}
/// Returns `true` if the statements coming after `leading_or_trailing_comments` are no longer suppressed.
///
/// The result is only correct if called for statement comments in a suppressed range.
///
/// # Panics
/// If `leading_or_trailing_comments` contain any range that's outside of `source`.
pub(crate) fn ends_suppression(
leading_or_trailing_comments: &[SourceComment],
source: &str,
) -> bool {
let mut iter = CommentRangeIter::in_suppression(leading_or_trailing_comments, source);
// Move the iter to the last element.
let _ = iter.by_ref().last();
!matches!(iter.in_suppression, InSuppression::Yes)
}
/// Disables formatting for all statements between the `first_suppressed` that has a leading `fmt: off` comment
/// and the first trailing or leading `fmt: on` comment. The statements are formatted as they appear in the source code.
///
@ -855,7 +889,7 @@ impl Format<PyFormatContext<'_>> for VerbatimText {
match normalize_newlines(f.context().locator().slice(self.verbatim_range), ['\r']) {
Cow::Borrowed(_) => {
write!(f, [source_text_slice(self.verbatim_range,)])?;
write!(f, [source_text_slice(self.verbatim_range)])?;
}
Cow::Owned(cleaned) => {
write!(

View file

@ -1,5 +1,7 @@
use std::borrow::Cow;
use std::fmt::{Formatter, Write};
use std::io::BufReader;
use std::ops::Range;
use std::path::Path;
use std::{fmt, fs};
@ -8,8 +10,10 @@ use similar::TextDiff;
use crate::normalizer::Normalizer;
use ruff_formatter::FormatOptions;
use ruff_python_ast::comparable::ComparableMod;
use ruff_python_formatter::{format_module_source, PreviewMode, PyFormatOptions};
use ruff_python_formatter::{format_module_source, format_range, PreviewMode, PyFormatOptions};
use ruff_python_parser::{parse, AsMode};
use ruff_source_file::{LineIndex, OneIndexed};
use ruff_text_size::{TextRange, TextSize};
mod normalizer;
@ -28,6 +32,52 @@ fn black_compatibility() {
PyFormatOptions::from_extension(input_path)
};
let first_line = content.lines().next().unwrap_or_default();
let formatted_code = if first_line.starts_with("# flags:")
&& first_line.contains("--line-ranges=")
{
let line_index = LineIndex::from_source_text(&content);
let ranges = first_line
.split_ascii_whitespace()
.filter_map(|chunk| {
let (_, lines) = chunk.split_once("--line-ranges=")?;
let (lower, upper) = lines.split_once('-')?;
let lower = lower
.parse::<OneIndexed>()
.expect("Expected a valid line number");
let upper = upper
.parse::<OneIndexed>()
.expect("Expected a valid line number");
let range_start = line_index.line_start(lower, &content);
let range_end = line_index.line_end(upper, &content);
Some(TextRange::new(range_start, range_end))
})
.rev();
let mut formatted_code = content.clone();
for range in ranges {
let formatted =
format_range(&content, range, options.clone()).unwrap_or_else(|err| {
panic!(
"Range-formatting of {} to succeed but encountered error {err}",
input_path.display()
)
});
let range = formatted.source_range();
formatted_code.replace_range(Range::<usize>::from(range), formatted.as_code());
}
// We can't do stability checks for range formatting because we don't know the updated rangs.
formatted_code
} else {
let printed = format_module_source(&content, options.clone()).unwrap_or_else(|err| {
panic!(
"Formatting of {} to succeed but encountered error {err}",
@ -35,6 +85,13 @@ fn black_compatibility() {
)
});
let formatted_code = printed.into_code();
ensure_stability_when_formatting_twice(&formatted_code, &options, input_path);
formatted_code
};
let extension = input_path
.extension()
.expect("Test file to have py or pyi extension")
@ -43,10 +100,7 @@ fn black_compatibility() {
let expected_output = fs::read_to_string(&expected_path)
.unwrap_or_else(|_| panic!("Expected Black output file '{expected_path:?}' to exist"));
let formatted_code = printed.as_code();
ensure_unchanged_ast(&content, formatted_code, &options, input_path);
ensure_stability_when_formatting_twice(formatted_code, &options, input_path);
ensure_unchanged_ast(&content, &formatted_code, &options, input_path);
if formatted_code == expected_output {
// Black and Ruff formatting matches. Delete any existing snapshot files because the Black output
@ -88,7 +142,7 @@ fn black_compatibility() {
write!(snapshot, "{}", Header::new("Black Differences")).unwrap();
let diff = TextDiff::from_lines(expected_output.as_str(), formatted_code)
let diff = TextDiff::from_lines(expected_output.as_str(), &formatted_code)
.unified_diff()
.header("Black", "Ruff")
.to_string();
@ -124,12 +178,7 @@ fn format() {
let content = fs::read_to_string(input_path).unwrap();
let options = PyFormatOptions::from_extension(input_path);
let printed =
format_module_source(&content, options.clone()).expect("Formatting to succeed");
let formatted_code = printed.as_code();
ensure_unchanged_ast(&content, formatted_code, &options, input_path);
ensure_stability_when_formatting_twice(formatted_code, &options, input_path);
let formatted_code = format_file(&content, &options, input_path);
let mut snapshot = format!("## Input\n{}", CodeFrame::new("python", &content));
@ -142,12 +191,7 @@ fn format() {
writeln!(snapshot, "## Outputs").unwrap();
for (i, options) in options.into_iter().enumerate() {
let printed =
format_module_source(&content, options.clone()).expect("Formatting to succeed");
let formatted_code = printed.as_code();
ensure_unchanged_ast(&content, formatted_code, &options, input_path);
ensure_stability_when_formatting_twice(formatted_code, &options, input_path);
let formatted_code = format_file(&content, &options, input_path);
writeln!(
snapshot,
@ -164,16 +208,7 @@ fn format() {
// We want to capture the differences in the preview style in our fixtures
let options_preview = options.with_preview(PreviewMode::Enabled);
let printed_preview = format_module_source(&content, options_preview.clone())
.expect("Formatting to succeed");
let formatted_preview = printed_preview.as_code();
ensure_unchanged_ast(&content, formatted_preview, &options_preview, input_path);
ensure_stability_when_formatting_twice(
formatted_preview,
&options_preview,
input_path,
);
let formatted_preview = format_file(&content, &options_preview, input_path);
if formatted_code != formatted_preview {
// Having both snapshots makes it hard to see the difference, so we're keeping only
@ -183,7 +218,7 @@ fn format() {
"#### Preview changes\n{}",
CodeFrame::new(
"diff",
TextDiff::from_lines(formatted_code, formatted_preview)
TextDiff::from_lines(&formatted_code, &formatted_preview)
.unified_diff()
.header("Stable", "Preview")
)
@ -194,12 +229,7 @@ fn format() {
} else {
// We want to capture the differences in the preview style in our fixtures
let options_preview = options.with_preview(PreviewMode::Enabled);
let printed_preview = format_module_source(&content, options_preview.clone())
.expect("Formatting to succeed");
let formatted_preview = printed_preview.as_code();
ensure_unchanged_ast(&content, formatted_preview, &options_preview, input_path);
ensure_stability_when_formatting_twice(formatted_preview, &options_preview, input_path);
let formatted_preview = format_file(&content, &options_preview, input_path);
if formatted_code == formatted_preview {
writeln!(
@ -217,7 +247,7 @@ fn format() {
CodeFrame::new("python", &formatted_code),
CodeFrame::new(
"diff",
TextDiff::from_lines(formatted_code, formatted_preview)
TextDiff::from_lines(&formatted_code, &formatted_preview)
.unified_diff()
.header("Stable", "Preview")
)
@ -242,6 +272,66 @@ fn format() {
);
}
fn format_file(source: &str, options: &PyFormatOptions, input_path: &Path) -> String {
let (unformatted, formatted_code) = if source.contains("<RANGE_START>") {
let mut content = source.to_string();
let without_markers = content
.replace("<RANGE_START>", "")
.replace("<RANGE_END>", "");
while let Some(range_start_marker) = content.find("<RANGE_START>") {
// Remove the start marker
content.replace_range(
range_start_marker..range_start_marker + "<RANGE_START>".len(),
"",
);
let range_end_marker = content[range_start_marker..]
.find("<RANGE_END>")
.expect("Matching <RANGE_END> marker for <RANGE_START> to exist")
+ range_start_marker;
content.replace_range(range_end_marker..range_end_marker + "<RANGE_END>".len(), "");
// Replace all other markers to get a valid Python input
let format_input = content
.replace("<RANGE_START>", "")
.replace("<RANGE_END>", "");
let range = TextRange::new(
TextSize::try_from(range_start_marker).unwrap(),
TextSize::try_from(range_end_marker).unwrap(),
);
let formatted =
format_range(&format_input, range, options.clone()).unwrap_or_else(|err| {
panic!(
"Range-formatting of {} to succeed but encountered error {err}",
input_path.display()
)
});
content.replace_range(
Range::<usize>::from(formatted.source_range()),
formatted.as_code(),
);
}
(Cow::Owned(without_markers), content)
} else {
let printed = format_module_source(source, options.clone()).expect("Formatting to succeed");
let formatted_code = printed.into_code();
ensure_stability_when_formatting_twice(&formatted_code, options, input_path);
(Cow::Borrowed(source), formatted_code)
};
ensure_unchanged_ast(&unformatted, &formatted_code, options, input_path);
formatted_code
}
/// Format another time and make sure that there are no changes anymore
fn ensure_stability_when_formatting_twice(
formatted_code: &str,

View file

@ -1,319 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_basic.py
---
## Input
```python
# flags: --line-ranges=5-6
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
# Adding some unformated code covering a wide range of syntaxes.
if True:
# Incorrectly indented prefix comments.
pass
import typing
from typing import (
Any ,
)
class MyClass( object): # Trailing comment with extra leading space.
#NOTE: The following indentation is incorrect:
@decor( 1 * 3 )
def my_func( arg):
pass
try: # Trailing comment with extra leading space.
for i in range(10): # Trailing comment with extra leading space.
while condition:
if something:
then_something( )
elif something_else:
then_something_else( )
except ValueError as e:
unformatted( )
finally:
unformatted( )
async def test_async_unformatted( ): # Trailing comment with extra leading space.
async for i in some_iter( unformatted ): # Trailing comment with extra leading space.
await asyncio.sleep( 1 )
async with some_context( unformatted ):
print( "unformatted" )
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -1,7 +1,18 @@
# flags: --line-ranges=5-6
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
-def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo1(
+ parameter_1,
+ parameter_2,
+ parameter_3,
+ parameter_4,
+ parameter_5,
+ parameter_6,
+ parameter_7,
+):
+ pass
+
+
def foo2(
parameter_1,
parameter_2,
@@ -26,38 +37,52 @@
pass
-def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo4(
+ parameter_1,
+ parameter_2,
+ parameter_3,
+ parameter_4,
+ parameter_5,
+ parameter_6,
+ parameter_7,
+):
+ pass
+
# Adding some unformated code covering a wide range of syntaxes.
if True:
- # Incorrectly indented prefix comments.
- pass
+ # Incorrectly indented prefix comments.
+ pass
-import typing
-from typing import (
- Any ,
- )
-class MyClass( object): # Trailing comment with extra leading space.
- #NOTE: The following indentation is incorrect:
- @decor( 1 * 3 )
- def my_func( arg):
- pass
+import typing
+from typing import (
+ Any,
+)
+
+
+class MyClass(object): # Trailing comment with extra leading space.
+ # NOTE: The following indentation is incorrect:
+ @decor(1 * 3)
+ def my_func(arg):
+ pass
-try: # Trailing comment with extra leading space.
- for i in range(10): # Trailing comment with extra leading space.
- while condition:
- if something:
- then_something( )
- elif something_else:
- then_something_else( )
-except ValueError as e:
- unformatted( )
+
+try: # Trailing comment with extra leading space.
+ for i in range(10): # Trailing comment with extra leading space.
+ while condition:
+ if something:
+ then_something()
+ elif something_else:
+ then_something_else()
+except ValueError as e:
+ unformatted()
finally:
- unformatted( )
+ unformatted()
+
-async def test_async_unformatted( ): # Trailing comment with extra leading space.
- async for i in some_iter( unformatted ): # Trailing comment with extra leading space.
- await asyncio.sleep( 1 )
- async with some_context( unformatted ):
- print( "unformatted" )
+async def test_async_unformatted(): # Trailing comment with extra leading space.
+ async for i in some_iter(unformatted): # Trailing comment with extra leading space.
+ await asyncio.sleep(1)
+ async with some_context(unformatted):
+ print("unformatted")
```
## Ruff Output
```python
# flags: --line-ranges=5-6
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def foo1(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
def foo2(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
def foo3(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
def foo4(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
# Adding some unformated code covering a wide range of syntaxes.
if True:
# Incorrectly indented prefix comments.
pass
import typing
from typing import (
Any,
)
class MyClass(object): # Trailing comment with extra leading space.
# NOTE: The following indentation is incorrect:
@decor(1 * 3)
def my_func(arg):
pass
try: # Trailing comment with extra leading space.
for i in range(10): # Trailing comment with extra leading space.
while condition:
if something:
then_something()
elif something_else:
then_something_else()
except ValueError as e:
unformatted()
finally:
unformatted()
async def test_async_unformatted(): # Trailing comment with extra leading space.
async for i in some_iter(unformatted): # Trailing comment with extra leading space.
await asyncio.sleep(1)
async with some_context(unformatted):
print("unformatted")
```
## Black Output
```python
# flags: --line-ranges=5-6
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo2(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
def foo3(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
# Adding some unformated code covering a wide range of syntaxes.
if True:
# Incorrectly indented prefix comments.
pass
import typing
from typing import (
Any ,
)
class MyClass( object): # Trailing comment with extra leading space.
#NOTE: The following indentation is incorrect:
@decor( 1 * 3 )
def my_func( arg):
pass
try: # Trailing comment with extra leading space.
for i in range(10): # Trailing comment with extra leading space.
while condition:
if something:
then_something( )
elif something_else:
then_something_else( )
except ValueError as e:
unformatted( )
finally:
unformatted( )
async def test_async_unformatted( ): # Trailing comment with extra leading space.
async for i in some_iter( unformatted ): # Trailing comment with extra leading space.
await asyncio.sleep( 1 )
async with some_context( unformatted ):
print( "unformatted" )
```

View file

@ -25,16 +25,14 @@ print ( "format me" )
```diff
--- Black
+++ Ruff
@@ -6,7 +6,8 @@
# This can be fixed in the future if we use a better diffing algorithm, or make Black
# perform formatting in a single pass.
-print ( "format me" )
@@ -9,5 +9,5 @@
print ( "format me" )
print("format me")
print("format me")
print("format me")
print("format me")
+print("format me")
-print("format me")
-print("format me")
+print ( "format me" )
+print ( "format me" )
```
## Ruff Output
@ -48,11 +46,11 @@ print ( "format me" )
# This can be fixed in the future if we use a better diffing algorithm, or make Black
# perform formatting in a single pass.
print ( "format me" )
print("format me")
print("format me")
print("format me")
print("format me")
print("format me")
print ( "format me" )
print ( "format me" )
```
## Black Output

View file

@ -1,111 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_fmt_off.py
---
## Input
```python
# flags: --line-ranges=7-7 --line-ranges=17-23
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# fmt: off
import os
def myfunc( ): # Intentionally unformatted.
pass
# fmt: on
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc( ): # This will be reformatted.
print( {"this will be reformatted"} )
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -9,8 +9,10 @@
# fmt: on
-def myfunc( ): # This will not be reformatted.
- print( {"also won't be reformatted"} )
+def myfunc(): # This will not be reformatted.
+ print({"also won't be reformatted"})
+
+
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
```
## Ruff Output
```python
# flags: --line-ranges=7-7 --line-ranges=17-23
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# fmt: off
import os
def myfunc( ): # Intentionally unformatted.
pass
# fmt: on
def myfunc(): # This will not be reformatted.
print({"also won't be reformatted"})
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc(): # This will be reformatted.
print({"this will be reformatted"})
```
## Black Output
```python
# flags: --line-ranges=7-7 --line-ranges=17-23
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# fmt: off
import os
def myfunc( ): # Intentionally unformatted.
pass
# fmt: on
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc(): # This will be reformatted.
print({"this will be reformatted"})
```

View file

@ -33,12 +33,7 @@ class MyClass:
```diff
--- Black
+++ Ruff
@@ -4,12 +4,11 @@
# Regression test for an edge case involving decorators and fmt: off/on.
class MyClass:
-
# fmt: off
@@ -9,7 +9,7 @@
@decorator ( )
# fmt: on
def method():
@ -47,7 +42,7 @@ class MyClass:
@decor(
a=1,
@@ -18,4 +17,4 @@
@@ -18,4 +18,4 @@
# fmt: on
)
def func():
@ -64,6 +59,7 @@ class MyClass:
# Regression test for an edge case involving decorators and fmt: off/on.
class MyClass:
# fmt: off
@decorator ( )
# fmt: on

View file

@ -1,93 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_fmt_off_overlap.py
---
## Input
```python
# flags: --line-ranges=11-17
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc( ): # This will be reformatted.
print( {"this will be reformatted"} )
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -3,8 +3,10 @@
# flag above as it's formatting specifically these lines.
-def myfunc( ): # This will not be reformatted.
- print( {"also won't be reformatted"} )
+def myfunc(): # This will not be reformatted.
+ print({"also won't be reformatted"})
+
+
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
```
## Ruff Output
```python
# flags: --line-ranges=11-17
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def myfunc(): # This will not be reformatted.
print({"also won't be reformatted"})
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc(): # This will be reformatted.
print({"this will be reformatted"})
```
## Black Output
```python
# flags: --line-ranges=11-17
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: off
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
def myfunc( ): # This will not be reformatted.
print( {"also won't be reformatted"} )
# fmt: on
def myfunc(): # This will be reformatted.
print({"this will be reformatted"})
```

View file

@ -1,69 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_indentation.py
---
## Input
```python
# flags: --line-ranges=5-5
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
if cond1:
print("first")
if cond2:
print("second")
else:
print("else")
if another_cond:
print("will not be changed")
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -9,4 +9,4 @@
print("else")
if another_cond:
- print("will not be changed")
+ print("will not be changed")
```
## Ruff Output
```python
# flags: --line-ranges=5-5
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
if cond1:
print("first")
if cond2:
print("second")
else:
print("else")
if another_cond:
print("will not be changed")
```
## Black Output
```python
# flags: --line-ranges=5-5
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
if cond1:
print("first")
if cond2:
print("second")
else:
print("else")
if another_cond:
print("will not be changed")
```

View file

@ -1,74 +0,0 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/line_ranges_two_passes.py
---
## Input
```python
# flags: --line-ranges=9-11
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# This is a specific case for Black's two-pass formatting behavior in `format_str`.
# The second pass must respect the line ranges before the first pass.
def restrict_to_this_line(arg1,
arg2,
arg3):
print ( "This should not be formatted." )
print ( "Note that in the second pass, the original line range 9-11 will cover these print lines.")
```
## Black Differences
```diff
--- Black
+++ Ruff
@@ -7,5 +7,7 @@
def restrict_to_this_line(arg1, arg2, arg3):
- print ( "This should not be formatted." )
- print ( "Note that in the second pass, the original line range 9-11 will cover these print lines.")
+ print("This should not be formatted.")
+ print(
+ "Note that in the second pass, the original line range 9-11 will cover these print lines."
+ )
```
## Ruff Output
```python
# flags: --line-ranges=9-11
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# This is a specific case for Black's two-pass formatting behavior in `format_str`.
# The second pass must respect the line ranges before the first pass.
def restrict_to_this_line(arg1, arg2, arg3):
print("This should not be formatted.")
print(
"Note that in the second pass, the original line range 9-11 will cover these print lines."
)
```
## Black Output
```python
# flags: --line-ranges=9-11
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
# This is a specific case for Black's two-pass formatting behavior in `format_str`.
# The second pass must respect the line ranges before the first pass.
def restrict_to_this_line(arg1, arg2, arg3):
print ( "This should not be formatted." )
print ( "Note that in the second pass, the original line range 9-11 will cover these print lines.")
```

View file

@ -0,0 +1,53 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/ancestory.py
---
## Input
```python
def test ():
if True:
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print(" Do not format this")
def test_empty_lines ():
if True:
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print(" Do not format this")
```
## Output
```python
def test ():
if True:
print(1 + 2)
else:
print(3 + 4)
print(" Do not format this")
def test_empty_lines ():
if True:
print(1 + 2)
else:
print(3 + 4)
print(" Do not format this")
```

View file

@ -0,0 +1,101 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/clause_header.py
---
## Input
```python
def test(<RANGE_START>a, b, c: str<RANGE_END>, d):
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
class <RANGE_START> Test(OtherClass<RANGE_END>)\
: # comment
# Should not get formatted
def __init__( self):
print("hello")
print( "dont' format this")
def test2(<RANGE_START>a, b, c: str, d):<RANGE_END>
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
def test3(<RANGE_START>a, b, c: str, d):<RANGE_END> # fmt: skip
print ( "Don't format the body when only making changes to the clause header")
def test4(<RANGE_START> a):
print("Format this" )
if True:
print( "and this")<RANGE_END>
print("Not this" )
<RANGE_START>if a + b : # trailing clause header comment<RANGE_END>
print("Not formatted" )
<RANGE_START>if b + c :<RANGE_END> # trailing clause header comment
print("Not formatted" )
```
## Output
```python
def test(a, b, c: str, d):
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
class Test(OtherClass): # comment
# Should not get formatted
def __init__( self):
print("hello")
print( "dont' format this")
def test2(a, b, c: str, d):
print ( "Don't format the body when only making changes to the clause header")
print( "Should not get formatted")
def test3(a, b, c: str, d): # fmt: skip
print ( "Don't format the body when only making changes to the clause header")
def test4(a):
print("Format this")
if True:
print("and this")
print("Not this" )
if a + b: # trailing clause header comment
print("Not formatted" )
if b + c: # trailing clause header comment
print("Not formatted" )
```

View file

@ -0,0 +1,26 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/comment_only_range.py
---
## Input
```python
def test ():
<RANGE_START># Some leading comment
# that spans multiple lines
<RANGE_END>
print("Do not format this" )
```
## Output
```python
def test ():
# Some leading comment
# that spans multiple lines
print("Do not format this" )
```

View file

@ -0,0 +1,59 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/decorators.py
---
## Input
```python
def test():
print("before" )
@<RANGE_START> decorator( aa)
<RANGE_END>def func ():
print("Do not format this" )
<RANGE_START>@ decorator( a)
def test( a):<RANGE_END>
print( "body")
print("after" )
<RANGE_START>@ decorator( a)<RANGE_END>
def test( a):
print( "body")
print("after" )
```
## Output
```python
def test():
print("before" )
@decorator(aa)
def func():
print("Do not format this" )
@decorator(a)
def test(a):
print( "body")
print("after" )
@decorator(a)
def test( a):
print( "body")
print("after" )
```

View file

@ -0,0 +1,391 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.py
---
## Input
```python
def doctest_simple ():
<RANGE_START>"""
Do cool stuff.
>>> cool_stuff( 1 )
2
"""
pass<RANGE_END>
def doctest_only ():
<RANGE_START>"""
Do cool stuff.
>>> def cool_stuff( x ):
... print( f"hi {x}" );
hi 2
"""<RANGE_END>
pass
def in_doctest ():
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
"""
pass
def suppressed_doctest ():
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
""" # fmt: skip
pass
def fmt_off_doctest ():
# fmt: off
"""
Do cool stuff.
<RANGE_START>
>>> cool_stuff( x )
>>> cool_stuff( y )
2<RANGE_END>
"""
# fmt: on
pass
if True:
def doctest_long_lines():
<RANGE_START>
'''
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard)
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
'''
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
<RANGE_END>
if True:
def doctest_long_lines():
<RANGE_START>'''
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard)
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
'''<RANGE_END>
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Enabled
docstring-code-line-width = "dynamic"
preview = Disabled
target_version = Py38
source_type = Python
```
```python
def doctest_simple ():
"""
Do cool stuff.
>>> cool_stuff(1)
2
"""
pass
def doctest_only ():
"""
Do cool stuff.
>>> def cool_stuff(x):
... print(f"hi {x}")
hi 2
"""
pass
def in_doctest ():
"""
Do cool stuff.
>>> cool_stuff(x)
>>> cool_stuff(y)
2
"""
pass
def suppressed_doctest ():
"""
Do cool stuff.
>>> cool_stuff( x )
>>> cool_stuff( y )
2
""" # fmt: skip
pass
def fmt_off_doctest ():
# fmt: off
"""
Do cool stuff.
>>> cool_stuff( x )
>>> cool_stuff( y )
2
"""
# fmt: on
pass
if True:
def doctest_long_lines():
"""
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(
... lion, giraffe, hippo, zeba, lemur, penguin, monkey
... )
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(
... lion,
... giraffe,
... hippo,
... zeba,
... lemur,
... penguin,
... monkey,
... spider,
... bear,
... leopard,
... )
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(
... lion, giraffe, hippo, zebra, lemur, penguin, monkey
... )
"""
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(
lion, giraffe, hippo, zeba, lemur, penguin, monkey
)
if True:
def doctest_long_lines():
"""
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(
... lion, giraffe, hippo, zeba, lemur, penguin, monkey
... )
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(
... lion,
... giraffe,
... hippo,
... zeba,
... lemur,
... penguin,
... monkey,
... spider,
... bear,
... leopard,
... )
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(
... lion, giraffe, hippo, zebra, lemur, penguin, monkey
... )
"""
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
```
### Output 2
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Enabled
docstring-code-line-width = 88
preview = Disabled
target_version = Py38
source_type = Python
```
```python
def doctest_simple ():
"""
Do cool stuff.
>>> cool_stuff(1)
2
"""
pass
def doctest_only ():
"""
Do cool stuff.
>>> def cool_stuff(x):
... print(f"hi {x}")
hi 2
"""
pass
def in_doctest ():
"""
Do cool stuff.
>>> cool_stuff(x)
>>> cool_stuff(y)
2
"""
pass
def suppressed_doctest ():
"""
Do cool stuff.
>>> cool_stuff( x )
>>> cool_stuff( y )
2
""" # fmt: skip
pass
def fmt_off_doctest ():
# fmt: off
"""
Do cool stuff.
>>> cool_stuff( x )
>>> cool_stuff( y )
2
"""
# fmt: on
pass
if True:
def doctest_long_lines():
"""
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(
... lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard
... )
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
"""
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(
lion, giraffe, hippo, zeba, lemur, penguin, monkey
)
if True:
def doctest_long_lines():
"""
This won't get wrapped even though it exceeds our configured
line width because it doesn't exceed the line width within this
docstring. e.g, the `f` in `foo` is treated as the first column.
>>> foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
But this one is long enough to get wrapped.
>>> foo, bar, quux = this_is_a_long_line(
... lion, giraffe, hippo, zeba, lemur, penguin, monkey, spider, bear, leopard
... )
This one doesn't get wrapped with an indent width of 4 even with `dynamic` line width
>>> a = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
This one gets wrapped with `dynamic` line width and an indent width of 4 because it exceeds the width by 1
>>> ab = this_is_a_long_line(lion, giraffe, hippo, zebra, lemur, penguin, monkey)
"""
# This demonstrates a normal line that will get wrapped but won't
# get wrapped in the docstring above because of how the line-width
# setting gets reset at the first column in each code snippet.
foo, bar, quux = this_is_a_long_line(lion, giraffe, hippo, zeba, lemur, penguin, monkey)
```

View file

@ -0,0 +1,14 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_file.py
---
## Input
```python
<RANGE_START><RANGE_END>```
## Output
```python
```

View file

@ -0,0 +1,18 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_range.py
---
## Input
```python
def test():
<RANGE_START><RANGE_END>print( "test" )
```
## Output
```python
def test():
print( "test" )
```

View file

@ -0,0 +1,62 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/fmt_on_off.py
---
## Input
```python
class MyClass:
# Range that falls entirely in a suppressed range
# fmt: off<RANGE_START>
def method( self ):
print ( "str" )
<RANGE_END># fmt: on
# This should net get formatted because it isn't in a formatting range.
def not_in_formatting_range ( self): ...
# Range that starts in a suppressed range and ends in a formatting range
# fmt: off<RANGE_START>
def other( self):
print ( "str" )
# fmt: on
def formatted ( self):
pass
<RANGE_END>
def outside_formatting_range (self): pass
```
## Output
```python
class MyClass:
# Range that falls entirely in a suppressed range
# fmt: off
def method( self ):
print ( "str" )
# fmt: on
# This should net get formatted because it isn't in a formatting range.
def not_in_formatting_range ( self): ...
# Range that starts in a suppressed range and ends in a formatting range
# fmt: off
def other( self):
print ( "str" )
# fmt: on
def formatted(self):
pass
def outside_formatting_range (self): pass
```

View file

@ -0,0 +1,310 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py
---
## Input
```python
# Formats the entire function with tab or 4 space indentation
# because the statement indentations don't match the preferred indentation.
def test ():
print("before" )
<RANGE_START>1 + 2
if True:
pass
print("Done" )<RANGE_END>
print("formatted" )
print("not formatted" )
def test2 ():
print("before" )
<RANGE_START>1 + 2
(
3 + 2
)
print("Done" )<RANGE_END>
print("formatted" )
print("not formatted" )
def test3 ():
print("before" )
<RANGE_START>1 + 2
"""A Multiline string
that starts at the beginning of the line and we need to preserve the leading spaces"""
"""A Multiline string
that has some indentation on the second line and we need to preserve the leading spaces"""
print("Done" )<RANGE_END>
def test4 ():
print("before" )
<RANGE_START>1 + 2
"""A Multiline string
that uses the same indentation as the formatted code will. This should not be dedented."""
print("Done" )<RANGE_END>
def test5 ():
print("before" )
if True:
print("Format to fix indentation" )
print(<RANGE_START>1 + 2)
else:
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )
pass
def test6 ():
<RANGE_START>
print("Format" )
print(3 + 4)<RANGE_END>
print("Format to fix indentation" )
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Disabled
target_version = Py38
source_type = Python
```
```python
# Formats the entire function with tab or 4 space indentation
# because the statement indentations don't match the preferred indentation.
def test ():
print("before")
1 + 2
if True:
pass
print("Done")
print("formatted")
print("not formatted" )
def test2 ():
print("before")
1 + 2
(3 + 2)
print("Done")
print("formatted")
print("not formatted" )
def test3 ():
print("before")
1 + 2
"""A Multiline string
that starts at the beginning of the line and we need to preserve the leading spaces"""
"""A Multiline string
that has some indentation on the second line and we need to preserve the leading spaces"""
print("Done")
def test4 ():
print("before")
1 + 2
"""A Multiline string
that uses the same indentation as the formatted code will. This should not be dedented."""
print("Done")
def test5 ():
print("before")
if True:
print("Format to fix indentation")
print(1 + 2)
else:
print(3 + 4)
print("Format to fix indentation")
pass
def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation" )
```
### Output 2
```
indent-style = tab
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Disabled
target_version = Py38
source_type = Python
```
```python
# Formats the entire function with tab or 4 space indentation
# because the statement indentations don't match the preferred indentation.
def test ():
print("before")
1 + 2
if True:
pass
print("Done")
print("formatted")
print("not formatted" )
def test2 ():
print("before")
1 + 2
(3 + 2)
print("Done")
print("formatted")
print("not formatted" )
def test3 ():
print("before")
1 + 2
"""A Multiline string
that starts at the beginning of the line and we need to preserve the leading spaces"""
"""A Multiline string
that has some indentation on the second line and we need to preserve the leading spaces"""
print("Done")
def test4 ():
print("before")
1 + 2
"""A Multiline string
that uses the same indentation as the formatted code will. This should not be dedented."""
print("Done")
def test5 ():
print("before")
if True:
print("Format to fix indentation")
print(1 + 2)
else:
print(3 + 4)
print("Format to fix indentation")
pass
def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
```
### Output 3
```
indent-style = space
line-width = 88
indent-width = 2
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Disabled
target_version = Py38
source_type = Python
```
```python
# Formats the entire function with tab or 4 space indentation
# because the statement indentations don't match the preferred indentation.
def test ():
print("before" )
1 + 2
if True:
pass
print("Done")
print("formatted" )
print("not formatted" )
def test2 ():
print("before" )
1 + 2
(3 + 2)
print("Done")
print("formatted" )
print("not formatted" )
def test3 ():
print("before" )
1 + 2
"""A Multiline string
that starts at the beginning of the line and we need to preserve the leading spaces"""
"""A Multiline string
that has some indentation on the second line and we need to preserve the leading spaces"""
print("Done")
def test4 ():
print("before" )
1 + 2
"""A Multiline string
that uses the same indentation as the formatted code will. This should not be dedented."""
print("Done")
def test5 ():
print("before" )
if True:
print("Format to fix indentation")
print(1 + 2)
else:
print(3 + 4)
print("Format to fix indentation")
pass
def test6 ():
print("Format")
print(3 + 4)
print("Format to fix indentation")
```

View file

@ -0,0 +1,82 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_comments.py
---
## Input
```python
def test ():
print( "hello" )
<RANGE_START># leading comment
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )
print( "Hy" )
def test2 ():
print( "hello" )
# Leading comments don't get formatted. That's why Ruff won't fixup
# the indentation here. That's something we might want to explore in the future
# leading comment 1<RANGE_START>
# leading comment 2
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )
def test3 ():
<RANGE_START>print( "hello" )
# leading comment 1
# leading comment 2
1 + 2
print( "world" )<RANGE_END>
print( "unformatted" )
```
## Output
```python
def test ():
print( "hello" )
# leading comment
1 + 2
print("world")
print( "unformatted" )
print( "Hy" )
def test2 ():
print( "hello" )
# Leading comments don't get formatted. That's why Ruff won't fixup
# the indentation here. That's something we might want to explore in the future
# leading comment 1
# leading comment 2
1 + 2
print("world")
print( "unformatted" )
def test3 ():
print("hello")
# leading comment 1
# leading comment 2
1 + 2
print("world")
print( "unformatted" )
```

View file

@ -0,0 +1,46 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_trailing_comments.py
---
## Input
```python
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
<RANGE_START>print( "format this")<RANGE_END> # trailing end of line comment
# here's some trailing comment as well
print("Do not format this" )
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
<RANGE_START>print( "format this")
# here's some trailing comment as well
<RANGE_END>
print("Do not format this 2" )
```
## Output
```python
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
print("format this") # trailing end of line comment
# here's some trailing comment as well
print("Do not format this" )
def test ():
# Leading comments before the statements that should be formatted
# Don't duplicate the comments
print("format this")
# here's some trailing comment as well
print("Do not format this 2" )
```

View file

@ -0,0 +1,34 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/module.py
---
## Input
```python
print("Before range start" )
<RANGE_START>
if a + b :
print("formatted" )
print("still in range" )
<RANGE_END>
print("After range end" )
```
## Output
```python
print("Before range start" )
if a + b:
print("formatted")
print("still in range")
print("After range end" )
```

View file

@ -0,0 +1,30 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/range_narrowing.py
---
## Input
```python
def test ():
<RANGE_START>if True:
print( "format")
elif False:
print ( "and this")<RANGE_END>
print("not this" )
print("nor this" )
```
## Output
```python
def test ():
if True:
print("format")
elif False:
print("and this")
print("not this" )
print("nor this" )
```

View file

@ -0,0 +1,139 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/regressions.py
---
## Input
```python
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not <RANGE_START>None:
return event_cls<RANGE_END>
else:
raise ValueError(f"unknown event name '{event_name}'")
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not None:
<RANGE_START>
<RANGE_END>
return event_cls
else:
raise ValueError(f"unknown event name '{event_name}'")
# The user starts adding items to a list and then hits save.
# Ruff should trim the empty lines
a = [
1,
2,
3,<RANGE_START>
<RANGE_END>
]
print("Don't format this" )
# The user removed an argument from a call. Ruff should reformat the entire call
call(
a,
<RANGE_START>
<RANGE_END>b,
c,
d
)
print("Don't format this" )
#-----------------------------------------------------------------------------
# The user adds a new comment at the end:
<RANGE_START># <RANGE_END>
#-----------------------------------------------------------------------------
print("Don't format this" )
def convert_str(value: str) -> str: # Trailing comment
"""Return a string as-is."""
<RANGE_START>
return value # Trailing comment
<RANGE_END>
def test ():
pass
```
## Output
```python
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not None:
return event_cls
else:
raise ValueError(f"unknown event name '{event_name}'")
class Event:
event_name: ClassVar[str]
@staticmethod
def cls_for(event_name: str) -> type[Event]:
event_cls = _CONCRETE_EVENT_CLASSES.get(event_name)
if event_cls is not None:
return event_cls
else:
raise ValueError(f"unknown event name '{event_name}'")
# The user starts adding items to a list and then hits save.
# Ruff should trim the empty lines
a = [
1,
2,
3,
]
print("Don't format this" )
# The user removed an argument from a call. Ruff should reformat the entire call
call(a, b, c, d)
print("Don't format this" )
#-----------------------------------------------------------------------------
# The user adds a new comment at the end:
#
#-----------------------------------------------------------------------------
print("Don't format this" )
def convert_str(value: str) -> str: # Trailing comment
"""Return a string as-is."""
return value # Trailing comment
def test ():
pass
```

View file

@ -0,0 +1,61 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/same_line_body.py
---
## Input
```python
def test(<RANGE_START>a ): <RANGE_END>print("body" )
def test2( a): <RANGE_START>print("body" )<RANGE_END>
def test3( a): <RANGE_START>print("body" )
print("more" )<RANGE_END>
print("after" )
# The if header and the print statement together are longer than 100 characters.
# The print statement should either be wrapped to fit at the end of the if statement, or be converted to a
# suite body
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd: <RANGE_START>print("aaaa long body, should wrap or be intented" )<RANGE_END>
# This print statement is too-long even when intented. It should be wrapped
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd: <RANGE_START>print("aaaa long body, should wrap or be intented", "more content to make it exceed the 88 chars limit")<RANGE_END>
```
## Output
```python
def test(a):
print("body")
def test2( a):
print("body")
def test3( a):
print("body")
print("more")
print("after" )
# The if header and the print statement together are longer than 100 characters.
# The print statement should either be wrapped to fit at the end of the if statement, or be converted to a
# suite body
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd:
print("aaaa long body, should wrap or be intented")
# This print statement is too-long even when intented. It should be wrapped
if aaaaaaaaaaaa + bbbbbbbbbbbbbb + cccccccccccccccccc + ddd:
print(
"aaaa long body, should wrap or be intented",
"more content to make it exceed the 88 chars limit",
)
```

View file

@ -0,0 +1,61 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.pyi
---
## Input
```python
# Don't collapse the ellipsis if only formatting the ellipsis line.
class Test:
<RANGE_START>...<RANGE_END>
class Test2: <RANGE_START>pass<RANGE_END>
class Test3: <RANGE_START>...<RANGE_END>
class Test4:
# leading comment
<RANGE_START>...<RANGE_END>
# trailing comment
class Test4:
<RANGE_START> ...<RANGE_END>
```
## Outputs
### Output 1
```
indent-style = space
line-width = 88
indent-width = 4
quote-style = Double
line-ending = LineFeed
magic-trailing-comma = Respect
docstring-code = Disabled
docstring-code-line-width = "dynamic"
preview = Enabled
target_version = Py38
source_type = Stub
```
```python
# Don't collapse the ellipsis if only formatting the ellipsis line.
class Test:
...
class Test2:
pass
class Test3: ...
class Test4:
# leading comment
...
# trailing comment
class Test4: ...
```

View file

@ -0,0 +1,74 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/trailing_comments.py
---
## Input
```python
def test1 ():
print("hello" )
<RANGE_START>1 + 2<RANGE_END> # trailing comment
print ("world" )
def test2 ():
print("hello" )
# FIXME: For some reason the trailing comment here gets not formatted
# but is correctly formatted above
<RANGE_START>1 + 2 # trailing comment<RANGE_END>
print ("world" )
def test3 ():
print("hellO" )
<RANGE_START>1 + 2 # trailing comment
# trailing section comment
<RANGE_END>
def test3 ():
print("hellO" )
<RANGE_START>1 + 2 # trailing comment
print("more" ) # trailing comment 2
# trailing section comment
<RANGE_END>
print( "world" )
```
## Output
```python
def test1 ():
print("hello" )
1 + 2 # trailing comment
print ("world" )
def test2 ():
print("hello" )
# FIXME: For some reason the trailing comment here gets not formatted
# but is correctly formatted above
1 + 2 # trailing comment
print ("world" )
def test3 ():
print("hellO" )
1 + 2 # trailing comment
# trailing section comment
def test3 ():
print("hellO" )
1 + 2 # trailing comment
print("more") # trailing comment 2
# trailing section comment
print( "world" )
```

View file

@ -0,0 +1,25 @@
---
source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/whitespace_only_range.py
---
## Input
```python
def test():
pass <RANGE_START>
<RANGE_END>
def test_formatted(): pass
```
## Output
```python
def test():
pass
def test_formatted(): pass
```

View file

@ -1,7 +1,8 @@
use std::fmt;
use std::fmt::{Debug, Formatter};
use std::num::NonZeroUsize;
use std::num::{NonZeroUsize, ParseIntError};
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use ruff_text_size::{TextLen, TextRange, TextSize};
@ -325,6 +326,13 @@ const fn unwrap<T: Copy>(option: Option<T>) -> T {
}
}
impl FromStr for OneIndexed {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(OneIndexed(NonZeroUsize::from_str(s)?))
}
}
#[cfg(test)]
mod tests {
use ruff_text_size::TextSize;

View file

@ -4,6 +4,7 @@ use js_sys::Error;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use ruff_formatter::printer::SourceMapGeneration;
use ruff_formatter::{FormatResult, Formatted, IndentStyle};
use ruff_linter::directives;
use ruff_linter::line_width::{IndentWidth, LineLength};
@ -292,7 +293,8 @@ impl<'a> ParsedModule<'a> {
// TODO(konstin): Add an options for py/pyi to the UI (2/2)
let options = settings
.formatter
.to_format_options(PySourceType::default(), self.source_code);
.to_format_options(PySourceType::default(), self.source_code)
.with_source_map_generation(SourceMapGeneration::Enabled);
format_module_ast(
&self.module,