Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ruff_markdown/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ authors = { workspace = true }
license = { workspace = true }

[dependencies]
ruff_formatter = { workspace = true }
Comment thread
amyreese marked this conversation as resolved.
Outdated
ruff_python_ast = { workspace = true }
ruff_python_formatter = { workspace = true }
ruff_python_trivia = { workspace = true }
Expand Down
165 changes: 129 additions & 36 deletions crates/ruff_markdown/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use ruff_python_ast::PySourceType;
use ruff_python_formatter::format_module_source;
use ruff_python_trivia::textwrap::{dedent, indent};
use ruff_source_file::{Line, UniversalNewlines};
use ruff_text_size::{TextRange, TextSize};
use ruff_text_size::{TextLen, TextRange, TextSize};
use ruff_workspace::FormatterSettings;

#[derive(Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -80,46 +80,49 @@ pub fn format_code_blocks(
continue;
};

if closing_fence != opening_fence {
continue;
}

// Found the matching end of the code block
if closing_fence == opening_fence {
let language = language.to_ascii_lowercase();
if state == MarkdownState::On
&& matches!(
language.as_str(),
"python" | "py" | "python3" | "py3" | "pyi"
)
{
// Maybe python, try formatting it
let end = code_line.start();
let unformatted_code = dedent(&source[TextRange::new(start, end)]);

let py_source_type = match settings.extension.get_extension(&language) {
None => PySourceType::from_extension(&language),
Some(language) => PySourceType::from(language),
};
if state != MarkdownState::On {
break;
}

// Maybe python, try formatting it
let language = language.to_ascii_lowercase();
let py_source_type = match settings.extension.get_extension(&language) {
None => PySourceType::from_extension(&language),
Some(language) => PySourceType::from(language),
};

let end = code_line.start();
let unformatted_code = dedent(&source[TextRange::new(start, end)]);

let formatted_code = match language.as_str() {
"python" | "py" | "python3" | "py3" | "pyi" => {
let options =
settings.to_format_options(py_source_type, &unformatted_code, path);

// Using `Printed::into_code` requires adding `ruff_formatter` as a direct
// dependency, and I suspect that Rust can optimize the closure away regardless.
#[expect(clippy::redundant_closure_for_method_calls)]
let formatted_code = format_module_source(&unformatted_code, options)
.map(|formatted| formatted.into_code());

// Formatting produced changes
if let Ok(formatted_code) = formatted_code
&& (formatted_code.len() != unformatted_code.len()
|| formatted_code != *unformatted_code)
{
formatted.push_str(&source[TextRange::new(last_match, start)]);
let formatted_code = indent(&formatted_code, code_indent);
formatted.push_str(&formatted_code);
last_match = end;
changed = true;
}
format_module_source(&unformatted_code, options)
.map(ruff_formatter::Printed::into_code)
.ok()
}
break;
"pycon" => format_pycon_block(&unformatted_code, path, settings),
_ => None,
};

// Formatting produced changes
if let Some(formatted_code) = formatted_code
&& (formatted_code.len() != unformatted_code.len()
|| formatted_code != *unformatted_code)
{
formatted.push_str(&source[TextRange::new(last_match, start)]);
let formatted_code = indent(&formatted_code, code_indent);
formatted.push_str(&formatted_code);
last_match = end;
changed = true;
}
break;
}
}
}
Expand All @@ -132,6 +135,63 @@ pub fn format_code_blocks(
}
}

fn format_pycon_block(
source: &str,
path: Option<&Path>,
settings: &FormatterSettings,
) -> Option<String> {
static FIRST_LINE: &str = ">>> ";
static CONTINUATION: &str = "... ";

let offset = FIRST_LINE.text_len();
let mut changed = false;
let mut result = String::with_capacity(source.len());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might actually want to skip this with_capacity call since String::new() won't allocate at all in the case that nothing changes and we're able to return None.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I do the same thing in format_code_blocks above? Is there a way to "initialize with full capacity only once needed"?

@ntBre ntBre Feb 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably not a big deal either way, but yeah I guess you could do the same in format_code_blocks. I don't think there's a nice way to initialize only if needed. There's reserve, but that's not quite the same.

I think it's fine to leave this as-is.

let mut unformatted = String::with_capacity(source.len());
let mut last_match = TextSize::new(0);
Comment thread
amyreese marked this conversation as resolved.
let mut lines = source.universal_newlines().peekable();

while let Some(line) = lines.next() {
unformatted.clear();
if line.starts_with(FIRST_LINE) {
let start = line.start();
let mut end = line.full_end();
unformatted.push_str(&source[TextRange::new(line.start() + offset, line.full_end())]);
while let Some(next_line) = lines.peek() {
if next_line.starts_with(CONTINUATION) {
end = next_line.full_end();
unformatted.push_str(&source[TextRange::new(next_line.start() + offset, end)]);
lines.next();
} else {
break;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
while let Some(next_line) = lines.peek() {
if next_line.starts_with(CONTINUATION) {
end = next_line.full_end();
unformatted.push_str(&source[TextRange::new(next_line.start() + offset, end)]);
lines.next();
} else {
break;
}
}
while let Some(next_line) = lines.next_if(|line| line.starts_with(CONTINUATION)) {
end = next_line.full_end();
unformatted.push_str(&source[TextRange::new(next_line.start() + offset, end)]);
}

I think you can simplify this slightly with the next_if helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I like this as much once I had to add logic to deal with empty continuation lines (ie, no space after the "...") 🤔

What do you think? 257b0ae

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That commit looks fine to me! What don't you like about it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly that it didn't feel "simpler" at this point, but ¯_(ツ)_/¯

let options = settings.to_format_options(PySourceType::Python, &unformatted, path);
let Ok(formatted) =
format_module_source(&unformatted, options).map(ruff_formatter::Printed::into_code)
else {
continue;
};

if formatted.len() != unformatted.len() || formatted != unformatted {
result.push_str(&source[TextRange::new(last_match, start)]);
for (idx, line) in formatted.universal_newlines().enumerate() {
result.push_str(if idx == 0 { FIRST_LINE } else { CONTINUATION });
result.push_str(&formatted[TextRange::new(line.start(), line.full_end())]);
Comment thread
amyreese marked this conversation as resolved.
Outdated
}
last_match = end;
changed = true;
}
}
}

if changed {
result.push_str(&source[last_match.to_usize()..]);
Some(result)
} else {
None
}
}

#[cfg(test)]
mod tests {
use insta::assert_snapshot;
Expand Down Expand Up @@ -431,4 +491,37 @@ def bar(): ...
~~~
"#);
}

#[test]
fn format_code_blocks_python_console() {
let code = r#"
```pycon
>>> print( 'hello there' )
hello there
>>> def foo(): pass
>>> def bar():
... print( 'thing1', "thing2", )
... bar()
thing1 thing2
```
"#;
assert_snapshot!(format_code_blocks(code, None, &FormatterSettings::default()), @r#"

```pycon
>>> print("hello there")
hello there
>>> def foo():
... pass
>>> def bar():
... print(
... "thing1",
... "thing2",
... )
...
...
... bar()
Comment thread
amyreese marked this conversation as resolved.
thing1 thing2
```
"#);
}
}