From ce14f4dea57abe79a2bd7f712750bfa6cb6c1108 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Wed, 31 Jan 2024 11:13:37 +0100 Subject: [PATCH] Range formatting API (#9635) --- crates/ruff_formatter/src/builders.rs | 10 +- crates/ruff_formatter/src/lib.rs | 86 +- crates/ruff_formatter/src/printer/mod.rs | 24 +- crates/ruff_python_ast/src/node.rs | 3 +- .../ruff_python_ast/src/visitor/preorder.rs | 2 +- .../test/fixtures/ruff/.editorconfig | 4 + .../ruff/range_formatting/ancestory.py | 20 + .../ruff/range_formatting/clause_header.py | 44 ++ .../range_formatting/comment_only_range.py | 6 + .../ruff/range_formatting/decorators.py | 23 + .../docstring_code_examples.options.json | 13 + .../docstring_code_examples.py | 103 +++ .../ruff/range_formatting/empty_file.py | 1 + .../ruff/range_formatting/empty_range.py | 2 + .../ruff/range_formatting/fmt_on_off.py | 24 + .../ruff/range_formatting/indent.options.json | 12 + .../fixtures/ruff/range_formatting/indent.py | 63 ++ .../ruff/range_formatting/leading_comments.py | 34 + .../leading_trailing_comments.py | 16 + .../fixtures/ruff/range_formatting/module.py | 10 + .../ruff/range_formatting/range_narrowing.py | 8 + .../ruff/range_formatting/regressions.py | 69 ++ .../ruff/range_formatting/same_line_body.py | 19 + .../ruff/range_formatting/stub.options.json | 6 + .../fixtures/ruff/range_formatting/stub.pyi | 16 + .../range_formatting/trailing_comments.py | 30 + .../range_formatting/whitespace_only_range.py | 6 + .../src/comments/format.rs | 12 +- .../ruff_python_formatter/src/comments/mod.rs | 29 +- .../src/comments/visitor.rs | 10 +- crates/ruff_python_formatter/src/lib.rs | 33 +- crates/ruff_python_formatter/src/options.rs | 6 + crates/ruff_python_formatter/src/range.rs | 740 ++++++++++++++++++ .../src/statement/clause.rs | 23 +- .../src/statement/stmt_if.rs | 12 +- .../src/statement/suite.rs | 10 +- crates/ruff_python_formatter/src/verbatim.rs | 36 +- .../ruff_python_formatter/tests/fixtures.rs | 174 +++- ...atibility@cases__line_ranges_basic.py.snap | 319 -------- ...@cases__line_ranges_diff_edge_case.py.snap | 20 +- ...ibility@cases__line_ranges_fmt_off.py.snap | 111 --- ...ses__line_ranges_fmt_off_decorator.py.snap | 10 +- ...cases__line_ranges_fmt_off_overlap.py.snap | 93 --- ...ity@cases__line_ranges_indentation.py.snap | 69 -- ...lity@cases__line_ranges_two_passes.py.snap | 74 -- ...format@range_formatting__ancestory.py.snap | 53 ++ ...at@range_formatting__clause_header.py.snap | 101 +++ ...nge_formatting__comment_only_range.py.snap | 26 + ...ormat@range_formatting__decorators.py.snap | 59 ++ ...ormatting__docstring_code_examples.py.snap | 391 +++++++++ ...ormat@range_formatting__empty_file.py.snap | 14 + ...rmat@range_formatting__empty_range.py.snap | 18 + ...ormat@range_formatting__fmt_on_off.py.snap | 62 ++ .../format@range_formatting__indent.py.snap | 310 ++++++++ ...range_formatting__leading_comments.py.snap | 82 ++ ...matting__leading_trailing_comments.py.snap | 46 ++ .../format@range_formatting__module.py.snap | 34 + ...@range_formatting__range_narrowing.py.snap | 30 + ...rmat@range_formatting__regressions.py.snap | 139 ++++ ...t@range_formatting__same_line_body.py.snap | 61 ++ .../format@range_formatting__stub.pyi.snap | 61 ++ ...ange_formatting__trailing_comments.py.snap | 74 ++ ..._formatting__whitespace_only_range.py.snap | 25 + crates/ruff_source_file/src/line_index.rs | 10 +- crates/ruff_wasm/src/lib.rs | 4 +- 65 files changed, 3273 insertions(+), 762 deletions(-) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/ancestory.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/clause_header.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/comment_only_range.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/decorators.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_file.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_range.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/fmt_on_off.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_comments.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_trailing_comments.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/module.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/range_narrowing.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/regressions.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/same_line_body.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.pyi create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/trailing_comments.py create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/whitespace_only_range.py create mode 100644 crates/ruff_python_formatter/src/range.rs delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_basic.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_overlap.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_indentation.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_two_passes.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__ancestory.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__clause_header.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__comment_only_range.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__decorators.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__docstring_code_examples.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_file.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_range.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__fmt_on_off.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_comments.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_trailing_comments.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__module.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__range_narrowing.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__regressions.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__same_line_body.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__stub.pyi.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__trailing_comments.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@range_formatting__whitespace_only_range.py.snap diff --git a/crates/ruff_formatter/src/builders.rs b/crates/ruff_formatter/src/builders.rs index fdf87e6327..658af7408e 100644 --- a/crates/ruff_formatter/src/builders.rs +++ b/crates/ruff_formatter/src/builders.rs @@ -328,6 +328,12 @@ pub struct SourcePosition(TextSize); impl Format for SourcePosition { fn fmt(&self, f: &mut Formatter) -> 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) -> 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 { diff --git a/crates/ruff_formatter/src/lib.rs b/crates/ruff_formatter/src/lib.rs index d443ec1737..927a598c4e 100644 --- a/crates/ruff_formatter/src/lib.rs +++ b/crates/ruff_formatter/src/lib.rs @@ -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 { 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 = None; + let mut end_marker: Option = 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 diff --git a/crates/ruff_formatter/src/printer/mod.rs b/crates/ruff_formatter/src/printer/mod.rs index 69504bacf8..5725569e88 100644 --- a/crates/ruff_formatter/src/printer/mod.rs +++ b/crates/ruff_formatter/src/printer/mod.rs @@ -60,7 +60,10 @@ impl<'a> Printer<'a> { document: &'a Document, indent: u16, ) -> PrintResult { - 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,7 +146,13 @@ impl<'a> Printer<'a> { FormatElement::SourcePosition(position) => { self.state.source_position = *position; - self.push_marker(); + // 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 => { @@ -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); } } diff --git a/crates/ruff_python_ast/src/node.rs b/crates/ruff_python_ast/src/node.rs index a2b534aba4..364eda993b 100644 --- a/crates/ruff_python_ast/src/node.rs +++ b/crates/ruff_python_ast/src/node.rs @@ -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), diff --git a/crates/ruff_python_ast/src/visitor/preorder.rs b/crates/ruff_python_ast/src/visitor/preorder.rs index d560cb5fb0..394d8cbf17 100644 --- a/crates/ruff_python_ast/src/visitor/preorder.rs +++ b/crates/ruff_python_ast/src/visitor/preorder.rs @@ -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) } } diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/.editorconfig b/crates/ruff_python_formatter/resources/test/fixtures/ruff/.editorconfig index ddc5dc593f..9d774cc7f6 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/.editorconfig +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/.editorconfig @@ -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 \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/ancestory.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/ancestory.py new file mode 100644 index 0000000000..d4aeca61b8 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/ancestory.py @@ -0,0 +1,20 @@ +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") diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/clause_header.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/clause_header.py new file mode 100644 index 0000000000..02a50a824f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/clause_header.py @@ -0,0 +1,44 @@ +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" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/comment_only_range.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/comment_only_range.py new file mode 100644 index 0000000000..bbea5d9d0a --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/comment_only_range.py @@ -0,0 +1,6 @@ +def test (): + # Some leading comment + # that spans multiple lines + + print("Do not format this" ) + diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/decorators.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/decorators.py new file mode 100644 index 0000000000..68e3343d89 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/decorators.py @@ -0,0 +1,23 @@ +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" ) + diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.options.json new file mode 100644 index 0000000000..09af351926 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.options.json @@ -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 + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.py new file mode 100644 index 0000000000..4bb8e1f60b --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/docstring_code_examples.py @@ -0,0 +1,103 @@ +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) + diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_file.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_file.py new file mode 100644 index 0000000000..4a78b58aca --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_file.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_range.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_range.py new file mode 100644 index 0000000000..173a02a12b --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/empty_range.py @@ -0,0 +1,2 @@ +def test(): + print( "test" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/fmt_on_off.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/fmt_on_off.py new file mode 100644 index 0000000000..7a37eb5a8a --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/fmt_on_off.py @@ -0,0 +1,24 @@ +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 + diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.options.json new file mode 100644 index 0000000000..fe928b3ec9 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.options.json @@ -0,0 +1,12 @@ +[ + { + "indent_style": "space" + }, + { + "indent_style": "tab" + }, + { + "indent_style": "space", + "indent_width": 2 + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py new file mode 100644 index 0000000000..1fb1522aa0 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/indent.py @@ -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" ) + 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" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_comments.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_comments.py new file mode 100644 index 0000000000..1d5bcfe085 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_comments.py @@ -0,0 +1,34 @@ +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" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_trailing_comments.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_trailing_comments.py new file mode 100644 index 0000000000..77d6642c36 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/leading_trailing_comments.py @@ -0,0 +1,16 @@ +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" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/module.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/module.py new file mode 100644 index 0000000000..75b4ad48fd --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/module.py @@ -0,0 +1,10 @@ +print("Before range start" ) + + +if a + b : + print("formatted" ) + +print("still in range" ) + + +print("After range end" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/range_narrowing.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/range_narrowing.py new file mode 100644 index 0000000000..55b3097b33 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/range_narrowing.py @@ -0,0 +1,8 @@ +def test (): + if True: + print( "format") + elif False: + print ( "and this") + print("not this" ) + + print("nor this" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/regressions.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/regressions.py new file mode 100644 index 0000000000..96f21de80d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/regressions.py @@ -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 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 diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/same_line_body.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/same_line_body.py new file mode 100644 index 0000000000..6e0adcd6a2 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/same_line_body.py @@ -0,0 +1,19 @@ +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") diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.options.json new file mode 100644 index 0000000000..94ab017cf8 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.options.json @@ -0,0 +1,6 @@ +[ + { + "preview": "enabled", + "source_type": "Stub" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.pyi b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.pyi new file mode 100644 index 0000000000..e3cbf7dbac --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/stub.pyi @@ -0,0 +1,16 @@ +# 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: + ... diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/trailing_comments.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/trailing_comments.py new file mode 100644 index 0000000000..749e1e4703 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/trailing_comments.py @@ -0,0 +1,30 @@ +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" ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/whitespace_only_range.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/whitespace_only_range.py new file mode 100644 index 0000000000..b01cbc8686 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/range_formatting/whitespace_only_range.py @@ -0,0 +1,6 @@ +def test(): + pass + + + +def test_formatted(): pass diff --git a/crates/ruff_python_formatter/src/comments/format.rs b/crates/ruff_python_formatter/src/comments/format.rs index e1690ef5c2..97e8496bb6 100644 --- a/crates/ruff_python_formatter/src/comments/format.rs +++ b/crates/ruff_python_formatter/src/comments/format.rs @@ -432,7 +432,17 @@ impl Format> for FormatNormalizedComment<'_> { fn fmt(&self, f: &mut Formatter) -> 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) => { diff --git a/crates/ruff_python_formatter/src/comments/mod.rs b/crates/ruff_python_formatter/src/comments/mod.rs index 80b412ee4d..92c52ff09c 100644 --- a/crates/ruff_python_formatter/src/comments/mod.rs +++ b/crates/ruff_python_formatter/src/comments/mod.rs @@ -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,20 +346,28 @@ impl<'a> Comments<'a> { /// Extracts the comments from the AST. pub(crate) fn from_ast( - root: &'a Mod, + root: impl Into>, source_code: SourceCode<'a>, comment_ranges: &'a CommentRanges, ) -> Self { - let map = if comment_ranges.is_empty() { - CommentsMap::new() - } else { - let mut builder = - CommentsMapBuilder::new(Locator::new(source_code.as_str()), comment_ranges); - CommentsVisitor::new(source_code, comment_ranges, &mut builder).visit(root); - builder.finish() - }; + 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 { + let mut builder = + CommentsMapBuilder::new(Locator::new(source_code.as_str()), comment_ranges); + CommentsVisitor::new(source_code, comment_ranges, &mut builder).visit(root); + 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. diff --git a/crates/ruff_python_formatter/src/comments/visitor.rs b/crates/ruff_python_formatter/src/comments/visitor.rs index b9788be8d2..f98aaabb16 100644 --- a/crates/ruff_python_formatter/src/comments/visitor.rs +++ b/crates/ruff_python_formatter/src/comments/visitor.rs @@ -24,7 +24,7 @@ pub(crate) fn collect_comments<'a>( comment_ranges: &'a CommentRanges, ) -> Vec> { 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 diff --git a/crates/ruff_python_formatter/src/lib.rs b/crates/ruff_python_formatter/src/lib.rs index ea8db20abb..7e1b4c3024 100644 --- a/crates/ruff_python_formatter/src/lib.rs +++ b/crates/ruff_python_formatter/src/lib.rs @@ -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::::from(printed.source_range()), + printed.as_code(), + ); + + assert_eq!( + formatted, + r#"def test2(a): + print("body") + "# + ); + } + #[test] fn string_processing() { use crate::prelude::*; diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index e4022a5889..ecabc30d63 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -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 { diff --git a/crates/ruff_python_formatter/src/range.rs b/crates/ruff_python_formatter/src/range.rs new file mode 100644 index 0000000000..c969c3d2c7 --- /dev/null +++ b/crates/ruff_python_formatter/src/range.rs @@ -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 { + // 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(&mut self, items: I) + where + I: IntoIterator, + 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) -> Option { + 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(a, b, c): + // 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 + // 1 + 2 + // """A Multiline string + // that uses the same indentation as the formatted code will. This should not be dedented.""" + // + // print("Done") + // ``` + // 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 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> for FormatEnclosingNode<'_> { + fn fmt(&self, f: &mut Formatter>) -> 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 { + 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)) +} diff --git a/crates/ruff_python_formatter/src/statement/clause.rs b/crates/ruff_python_formatter/src/statement/clause.rs index 3f9b3de62c..7592c94f1c 100644 --- a/crates/ruff_python_formatter/src/statement/clause.rs +++ b/crates/ruff_python_formatter/src/statement/clause.rs @@ -357,8 +357,20 @@ impl<'ast> Format> 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 { 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 { diff --git a/crates/ruff_python_formatter/src/statement/stmt_if.rs b/crates/ruff_python_formatter/src/statement/stmt_if.rs index 5c6fabea10..3384a245c3 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_if.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_if.rs @@ -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())) ] ) } diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index efcf6eff3d..54e28a618e 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -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> for DocstringStmt<'_> { diff --git a/crates/ruff_python_formatter/src/verbatim.rs b/crates/ruff_python_formatter/src/verbatim.rs index 5c29b0039d..2eb4967f57 100644 --- a/crates/ruff_python_formatter/src/verbatim.rs +++ b/crates/ruff_python_formatter/src/verbatim.rs @@ -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> 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!( diff --git a/crates/ruff_python_formatter/tests/fixtures.rs b/crates/ruff_python_formatter/tests/fixtures.rs index f85407148e..a72e505e7a 100644 --- a/crates/ruff_python_formatter/tests/fixtures.rs +++ b/crates/ruff_python_formatter/tests/fixtures.rs @@ -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,12 +32,65 @@ fn black_compatibility() { PyFormatOptions::from_extension(input_path) }; - let printed = format_module_source(&content, options.clone()).unwrap_or_else(|err| { - panic!( - "Formatting of {} to succeed but encountered error {err}", - input_path.display() - ) - }); + 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::() + .expect("Expected a valid line number"); + let upper = upper + .parse::() + .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::::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}", + input_path.display() + ) + }); + + let formatted_code = printed.into_code(); + + ensure_stability_when_formatting_twice(&formatted_code, &options, input_path); + + formatted_code + }; let extension = input_path .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("") { + let mut content = source.to_string(); + let without_markers = content + .replace("", "") + .replace("", ""); + + while let Some(range_start_marker) = content.find("") { + // Remove the start marker + content.replace_range( + range_start_marker..range_start_marker + "".len(), + "", + ); + + let range_end_marker = content[range_start_marker..] + .find("") + .expect("Matching marker for to exist") + + range_start_marker; + + content.replace_range(range_end_marker..range_end_marker + "".len(), ""); + + // Replace all other markers to get a valid Python input + let format_input = content + .replace("", "") + .replace("", ""); + + 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::::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, diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_basic.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_basic.py.snap deleted file mode 100644 index 54479133ba..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_basic.py.snap +++ /dev/null @@ -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" ) -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_diff_edge_case.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_diff_edge_case.py.snap index 332b54ba9c..bdfd173c80 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_diff_edge_case.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_diff_edge_case.py.snap @@ -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 diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off.py.snap deleted file mode 100644 index 201f97f8f8..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off.py.snap +++ /dev/null @@ -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"}) -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_decorator.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_decorator.py.snap index f52235b48b..a6e07024ed 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_decorator.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_decorator.py.snap @@ -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 diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_overlap.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_overlap.py.snap deleted file mode 100644 index 9150aaa8a2..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_fmt_off_overlap.py.snap +++ /dev/null @@ -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"}) -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_indentation.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_indentation.py.snap deleted file mode 100644 index 49f472b8c6..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_indentation.py.snap +++ /dev/null @@ -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") -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_two_passes.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_two_passes.py.snap deleted file mode 100644 index 5a8eb36ddc..0000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__line_ranges_two_passes.py.snap +++ /dev/null @@ -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.") -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__ancestory.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__ancestory.py.snap new file mode 100644 index 0000000000..d309ad6311 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__ancestory.py.snap @@ -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(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") +``` + +## 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") +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__clause_header.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__clause_header.py.snap new file mode 100644 index 0000000000..5a5f35cb61 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__clause_header.py.snap @@ -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(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" ) +``` + +## 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" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__comment_only_range.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__comment_only_range.py.snap new file mode 100644 index 0000000000..d69ceb6c42 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__comment_only_range.py.snap @@ -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 (): + # Some leading comment + # that spans multiple lines + + print("Do not format this" ) + +``` + +## Output +```python +def test (): + # Some leading comment + # that spans multiple lines + + print("Do not format this" ) + +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__decorators.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__decorators.py.snap new file mode 100644 index 0000000000..4b39451a05 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__decorators.py.snap @@ -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" ) + + @ 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" ) + +``` + +## 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" ) + +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__docstring_code_examples.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__docstring_code_examples.py.snap new file mode 100644 index 0000000000..13e46604e5 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__docstring_code_examples.py.snap @@ -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 (): + """ + 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) + +``` + +## 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) + +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_file.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_file.py.snap new file mode 100644 index 0000000000..ec7310c609 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_file.py.snap @@ -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 +``` + +## Output +```python +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_range.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_range.py.snap new file mode 100644 index 0000000000..4f4a1acd1a --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__empty_range.py.snap @@ -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(): + print( "test" ) +``` + +## Output +```python +def test(): + print( "test" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__fmt_on_off.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__fmt_on_off.py.snap new file mode 100644 index 0000000000..5ddf8a65cb --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__fmt_on_off.py.snap @@ -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 + 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 + +``` + +## 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 + +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap new file mode 100644 index 0000000000..bf1c74c0c7 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__indent.py.snap @@ -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" ) + 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" ) +``` + +## 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") +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_comments.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_comments.py.snap new file mode 100644 index 0000000000..9297aa57fd --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_comments.py.snap @@ -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" ) + # 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" ) +``` + +## 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" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_trailing_comments.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_trailing_comments.py.snap new file mode 100644 index 0000000000..37fe8c3145 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__leading_trailing_comments.py.snap @@ -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 + 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" ) +``` + +## 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" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__module.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__module.py.snap new file mode 100644 index 0000000000..652bc2a605 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__module.py.snap @@ -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" ) + + +if a + b : + print("formatted" ) + +print("still in range" ) + + +print("After range end" ) +``` + +## Output +```python +print("Before range start" ) + + +if a + b: + print("formatted") + +print("still in range") + + +print("After range end" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__range_narrowing.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__range_narrowing.py.snap new file mode 100644 index 0000000000..55ee3b708d --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__range_narrowing.py.snap @@ -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 (): + if True: + print( "format") + elif False: + print ( "and this") + 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" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__regressions.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__regressions.py.snap new file mode 100644 index 0000000000..c104acef5a --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__regressions.py.snap @@ -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 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 +``` + +## 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 +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__same_line_body.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__same_line_body.py.snap new file mode 100644 index 0000000000..b637a77a23 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__same_line_body.py.snap @@ -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(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") +``` + +## 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", + ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__stub.pyi.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__stub.pyi.snap new file mode 100644 index 0000000000..9af5612858 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__stub.pyi.snap @@ -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: + ... + +class Test2: pass + +class Test3: ... + +class Test4: + # leading comment + ... + # trailing comment + + +class Test4: + ... +``` + +## 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: ... +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__trailing_comments.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__trailing_comments.py.snap new file mode 100644 index 0000000000..9e6a775af7 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__trailing_comments.py.snap @@ -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" ) + + 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" ) +``` + +## 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" ) +``` + + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__whitespace_only_range.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__whitespace_only_range.py.snap new file mode 100644 index 0000000000..4e22fa6805 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@range_formatting__whitespace_only_range.py.snap @@ -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 + + + +def test_formatted(): pass +``` + +## Output +```python +def test(): + pass + + +def test_formatted(): pass +``` + + + diff --git a/crates/ruff_source_file/src/line_index.rs b/crates/ruff_source_file/src/line_index.rs index 279e68dfb8..735bf1f7f6 100644 --- a/crates/ruff_source_file/src/line_index.rs +++ b/crates/ruff_source_file/src/line_index.rs @@ -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(option: Option) -> T { } } +impl FromStr for OneIndexed { + type Err = ParseIntError; + fn from_str(s: &str) -> Result { + Ok(OneIndexed(NonZeroUsize::from_str(s)?)) + } +} + #[cfg(test)] mod tests { use ruff_text_size::TextSize; diff --git a/crates/ruff_wasm/src/lib.rs b/crates/ruff_wasm/src/lib.rs index f83ed36b79..de168f63a2 100644 --- a/crates/ruff_wasm/src/lib.rs +++ b/crates/ruff_wasm/src/lib.rs @@ -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,