@@ -5,7 +5,7 @@ use ruff_python_ast::PySourceType;
55use ruff_python_formatter:: format_module_source;
66use ruff_python_trivia:: textwrap:: { dedent, indent} ;
77use ruff_source_file:: { Line , UniversalNewlines } ;
8- use ruff_text_size:: { TextRange , TextSize } ;
8+ use ruff_text_size:: { TextLen , TextRange , TextSize } ;
99use ruff_workspace:: FormatterSettings ;
1010
1111#[ derive( Debug , PartialEq , Eq ) ]
@@ -54,7 +54,7 @@ pub fn format_code_blocks(
5454 let mut state = MarkdownState :: On ;
5555 let mut changed = false ;
5656 let mut formatted = String :: with_capacity ( source. len ( ) ) ;
57- let mut last_match = TextSize :: new ( 0 ) ;
57+ let mut last_match = TextSize :: ZERO ;
5858
5959 let mut lines = source. universal_newlines ( ) . peekable ( ) ;
6060 while let Some ( line) = lines. next ( ) {
@@ -80,46 +80,52 @@ pub fn format_code_blocks(
8080 continue ;
8181 } ;
8282
83+ if closing_fence != opening_fence {
84+ continue ;
85+ }
86+
8387 // Found the matching end of the code block
84- if closing_fence == opening_fence {
85- let language = language . to_ascii_lowercase ( ) ;
86- if state == MarkdownState :: On
87- && matches ! (
88- language . as_str ( ) ,
89- "python" | "py" | "python3" | "py3" | "pyi"
90- )
91- {
92- // Maybe python, try formatting it
93- let end = code_line . start ( ) ;
94- let unformatted_code = dedent ( & source [ TextRange :: new ( start , end ) ] ) ;
95-
96- let py_source_type = match settings . extension . get_extension ( & language ) {
97- None => PySourceType :: from_extension ( & language ) ,
98- Some ( language) => PySourceType :: from ( language ) ,
99- } ;
88+ if state != MarkdownState :: On {
89+ break ;
90+ }
91+
92+ // Maybe python, try formatting it
93+ let language = language . to_ascii_lowercase ( ) ;
94+ let py_source_type = match settings . extension . get_extension ( & language ) {
95+ None => PySourceType :: from_extension ( & language ) ,
96+ Some ( language ) => PySourceType :: from ( language ) ,
97+ } ;
98+
99+ let end = code_line . start ( ) ;
100+ let unformatted_code = dedent ( & source [ TextRange :: new ( start , end ) ] ) ;
101+
102+ let formatted_code = match language. as_str ( ) {
103+ "python" | "py" | "python3" | "py3" | "pyi" => {
100104 let options =
101105 settings. to_format_options ( py_source_type, & unformatted_code, path) ;
102-
103106 // Using `Printed::into_code` requires adding `ruff_formatter` as a direct
104107 // dependency, and I suspect that Rust can optimize the closure away regardless.
105108 #[ expect( clippy:: redundant_closure_for_method_calls) ]
106- let formatted_code = format_module_source ( & unformatted_code, options)
107- . map ( |formatted| formatted. into_code ( ) ) ;
108-
109- // Formatting produced changes
110- if let Ok ( formatted_code) = formatted_code
111- && ( formatted_code. len ( ) != unformatted_code. len ( )
112- || formatted_code != * unformatted_code)
113- {
114- formatted. push_str ( & source[ TextRange :: new ( last_match, start) ] ) ;
115- let formatted_code = indent ( & formatted_code, code_indent) ;
116- formatted. push_str ( & formatted_code) ;
117- last_match = end;
118- changed = true ;
119- }
109+ format_module_source ( & unformatted_code, options)
110+ . map ( |formatted| formatted. into_code ( ) )
111+ . ok ( )
120112 }
121- break ;
113+ "pycon" => format_pycon_block ( & unformatted_code, path, settings) ,
114+ _ => None ,
115+ } ;
116+
117+ // Formatting produced changes
118+ if let Some ( formatted_code) = formatted_code
119+ && ( formatted_code. len ( ) != unformatted_code. len ( )
120+ || formatted_code != * unformatted_code)
121+ {
122+ formatted. push_str ( & source[ TextRange :: new ( last_match, start) ] ) ;
123+ let formatted_code = indent ( & formatted_code, code_indent) ;
124+ formatted. push_str ( & formatted_code) ;
125+ last_match = end;
126+ changed = true ;
122127 }
128+ break ;
123129 }
124130 }
125131 }
@@ -132,6 +138,73 @@ pub fn format_code_blocks(
132138 }
133139}
134140
141+ fn format_pycon_block (
142+ source : & str ,
143+ path : Option < & Path > ,
144+ settings : & FormatterSettings ,
145+ ) -> Option < String > {
146+ static FIRST_LINE : & str = ">>> " ;
147+ static CONTINUATION : & str = "... " ;
148+ static CONTINUATION_BLANK : & str = "..." ;
149+
150+ let offset = FIRST_LINE . text_len ( ) ;
151+ let mut changed = false ;
152+ let mut result = String :: with_capacity ( source. len ( ) ) ;
153+ let mut unformatted = String :: with_capacity ( source. len ( ) ) ;
154+ let mut last_match = TextSize :: new ( 0 ) ;
155+ let mut lines = source. universal_newlines ( ) . peekable ( ) ;
156+
157+ while let Some ( line) = lines. next ( ) {
158+ unformatted. clear ( ) ;
159+ if line. starts_with ( FIRST_LINE ) {
160+ let start = line. start ( ) ;
161+ let mut end = line. full_end ( ) ;
162+ unformatted. push_str ( & source[ TextRange :: new ( line. start ( ) + offset, line. full_end ( ) ) ] ) ;
163+ while let Some ( next_line) = lines. next_if ( |line| line. starts_with ( CONTINUATION_BLANK ) ) {
164+ end = next_line. full_end ( ) ;
165+ let start = if next_line. trim_end ( ) == CONTINUATION_BLANK {
166+ next_line. end ( )
167+ } else {
168+ next_line. start ( ) + offset
169+ } ;
170+ unformatted. push_str ( & source[ TextRange :: new ( start, end) ] ) ;
171+ }
172+ let options = settings. to_format_options ( PySourceType :: Python , & unformatted, path) ;
173+ // Using `Printed::into_code` requires adding `ruff_formatter` as a direct
174+ // dependency, and I suspect that Rust can optimize the closure away regardless.
175+ #[ expect( clippy:: redundant_closure_for_method_calls) ]
176+ let Ok ( formatted) =
177+ format_module_source ( & unformatted, options) . map ( |formatted| formatted. into_code ( ) )
178+ else {
179+ continue ;
180+ } ;
181+
182+ if formatted. len ( ) != unformatted. len ( ) || formatted != unformatted {
183+ result. push_str ( & source[ TextRange :: new ( last_match, start) ] ) ;
184+ for ( idx, line) in formatted. universal_newlines ( ) . enumerate ( ) {
185+ result. push_str ( if idx == 0 {
186+ FIRST_LINE
187+ } else if line. is_empty ( ) {
188+ CONTINUATION_BLANK
189+ } else {
190+ CONTINUATION
191+ } ) ;
192+ result. push_str ( & formatted[ line. full_range ( ) ] ) ;
193+ }
194+ last_match = end;
195+ changed = true ;
196+ }
197+ }
198+ }
199+
200+ if changed {
201+ result. push_str ( & source[ last_match. to_usize ( ) ..] ) ;
202+ Some ( result)
203+ } else {
204+ None
205+ }
206+ }
207+
135208#[ cfg( test) ]
136209mod tests {
137210 use insta:: assert_snapshot;
@@ -431,4 +504,39 @@ def bar(): ...
431504 ~~~
432505 "# ) ;
433506 }
507+
508+ #[ test]
509+ fn format_code_blocks_python_console ( ) {
510+ let code = r#"
511+ ```pycon
512+ >>> print( 'hello there' )
513+ hello there
514+ >>> def foo(): pass
515+ >>> def bar():
516+ ... print( 'thing1', "thing2", )
517+ ...
518+ ... bar()
519+ ...
520+ thing1 thing2
521+ ```
522+ "# ;
523+ assert_snapshot ! ( format_code_blocks( code, None , & FormatterSettings :: default ( ) ) , @r#"
524+
525+ ```pycon
526+ >>> print("hello there")
527+ hello there
528+ >>> def foo():
529+ ... pass
530+ >>> def bar():
531+ ... print(
532+ ... "thing1",
533+ ... "thing2",
534+ ... )
535+ ...
536+ ...
537+ ... bar()
538+ thing1 thing2
539+ ```
540+ "# ) ;
541+ }
434542}
0 commit comments