Skip to content

Commit cc988ac

Browse files
authored
Support formatting pycon markdown code blocks (#23112)
Fix #23078
1 parent bb62fc7 commit cc988ac

1 file changed

Lines changed: 142 additions & 34 deletions

File tree

  • crates/ruff_markdown/src

crates/ruff_markdown/src/lib.rs

Lines changed: 142 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use ruff_python_ast::PySourceType;
55
use ruff_python_formatter::format_module_source;
66
use ruff_python_trivia::textwrap::{dedent, indent};
77
use ruff_source_file::{Line, UniversalNewlines};
8-
use ruff_text_size::{TextRange, TextSize};
8+
use ruff_text_size::{TextLen, TextRange, TextSize};
99
use 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)]
136209
mod 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

Comments
 (0)