From 8e871e1c30492a8133d6ea5434c61bd0f7a4d2e3 Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Tue, 3 Feb 2026 15:01:24 -0800 Subject: [PATCH 1/4] Prototype of markdown formatting in LSP - Use `SourceType` in formatting related functions - Defer to `ruff_markdown` when formatting `SourceType::Markdown` - A bunch of `todos` around how to handle errors/etc Tested against a local build of `ruff-vscode` extension modified to declare support for markdown files, pointed at a local build of `ruff` Issue #22640 --- Cargo.lock | 1 + crates/ruff_server/Cargo.toml | 1 + crates/ruff_server/src/edit/text_document.rs | 2 + crates/ruff_server/src/fix.rs | 5 +- crates/ruff_server/src/format.rs | 173 ++++++++++++------- crates/ruff_server/src/lint.rs | 5 +- crates/ruff_server/src/resolve.rs | 5 + crates/ruff_server/src/session/index.rs | 8 +- 8 files changed, 133 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dcf953a4f0724..c1dd9a9c52e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3509,6 +3509,7 @@ dependencies = [ "ruff_diagnostics", "ruff_formatter", "ruff_linter", + "ruff_markdown", "ruff_notebook", "ruff_python_ast", "ruff_python_codegen", diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index 06cc0c6815660..ac23cf6416991 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -17,6 +17,7 @@ ruff_db = { workspace = true } ruff_diagnostics = { workspace = true } ruff_formatter = { workspace = true } ruff_linter = { workspace = true } +ruff_markdown = { workspace = true } ruff_notebook = { workspace = true } ruff_python_ast = { workspace = true } ruff_python_codegen = { workspace = true } diff --git a/crates/ruff_server/src/edit/text_document.rs b/crates/ruff_server/src/edit/text_document.rs index e5d00ff0cf176..b78f1c031c368 100644 --- a/crates/ruff_server/src/edit/text_document.rs +++ b/crates/ruff_server/src/edit/text_document.rs @@ -27,6 +27,7 @@ pub struct TextDocument { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum LanguageId { Python, + Markdown, Other, } @@ -34,6 +35,7 @@ impl From<&str> for LanguageId { fn from(language_id: &str) -> Self { match language_id { "python" => Self::Python, + "markdown" => Self::Markdown, _ => Self::Other, } } diff --git a/crates/ruff_server/src/fix.rs b/crates/ruff_server/src/fix.rs index 0c910f67553d9..71f99ff9228f1 100644 --- a/crates/ruff_server/src/fix.rs +++ b/crates/ruff_server/src/fix.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use ruff_python_ast::SourceType; use rustc_hash::FxHashMap; use crate::{ @@ -53,7 +54,9 @@ pub(crate) fn fix_all( None }; - let source_type = query.source_type(); + let SourceType::Python(source_type) = query.source_type() else { + return Ok(Fixes::default()); // todo? + }; // We need to iteratively apply all safe fixes onto a single file and then // create a diff between the modified file and the original source to use as a single workspace diff --git a/crates/ruff_server/src/format.rs b/crates/ruff_server/src/format.rs index 38455514a9935..5be246053c464 100644 --- a/crates/ruff_server/src/format.rs +++ b/crates/ruff_server/src/format.rs @@ -2,10 +2,11 @@ use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; -use anyhow::Context; +use anyhow::{Context, Error}; use ruff_formatter::{FormatOptions, PrintedRange}; -use ruff_python_ast::PySourceType; +use ruff_markdown::{MarkdownResult, format_code_blocks}; +use ruff_python_ast::{PySourceType, SourceType}; use ruff_python_formatter::{FormatModuleError, PyFormatOptions, format_module_source}; use ruff_source_file::LineIndex; use ruff_text_size::TextRange; @@ -30,7 +31,7 @@ pub(crate) enum FormatBackend { pub(crate) fn format( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, path: &Path, backend: FormatBackend, @@ -44,47 +45,75 @@ pub(crate) fn format( /// Format using the built-in Ruff formatter. fn format_internal( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, path: &Path, ) -> crate::Result> { - let format_options = - formatter_settings.to_format_options(source_type, document.contents(), Some(path)); - match format_module_source(document.contents(), format_options) { - Ok(formatted) => { - let formatted = formatted.into_code(); - if formatted == document.contents() { - Ok(None) - } else { - Ok(Some(formatted)) + match source_type { + SourceType::Python(py_source_type) => { + let format_options = formatter_settings.to_format_options( + py_source_type, + document.contents(), + Some(path), + ); + match format_module_source(document.contents(), format_options) { + Ok(formatted) => { + let formatted = formatted.into_code(); + if formatted == document.contents() { + Ok(None) + } else { + Ok(Some(formatted)) + } + } + // Special case - syntax/parse errors are handled here instead of + // being propagated as visible server errors. + Err(FormatModuleError::ParseError(error)) => { + tracing::warn!("Unable to format document: {error}"); + Ok(None) + } + Err(err) => Err(err.into()), } } - // Special case - syntax/parse errors are handled here instead of - // being propagated as visible server errors. - Err(FormatModuleError::ParseError(error)) => { - tracing::warn!("Unable to format document: {error}"); - Ok(None) + SourceType::Markdown => { + if !formatter_settings.preview.is_enabled() { + return Ok(None); // todo + } + + match format_code_blocks(document.contents(), Some(path), formatter_settings) { + MarkdownResult::Formatted(formatted) => Ok(Some(formatted)), + MarkdownResult::Unchanged => Ok(None), + } } - Err(err) => Err(err.into()), + SourceType::Toml(_) => Ok(None), // todo } } /// Format using an external uv command. fn format_external( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, path: &Path, ) -> crate::Result> { - let format_options = - formatter_settings.to_format_options(source_type, document.contents(), Some(path)); - let uv_command = UvFormatCommand::from(format_options); - uv_command.format_document(document.contents(), path) + match source_type { + SourceType::Python(py_source_type) => { + let format_options = formatter_settings.to_format_options( + py_source_type, + document.contents(), + Some(path), + ); + let uv_command = UvFormatCommand::from(format_options); + uv_command.format_document(document.contents(), path) + } + SourceType::Markdown | SourceType::Toml(_) => { + Ok(None) // todo + } + } } pub(crate) fn format_range( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, @@ -103,48 +132,68 @@ pub(crate) fn format_range( /// Format range using the built-in Ruff formatter fn format_range_internal( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, ) -> crate::Result> { - let format_options = - formatter_settings.to_format_options(source_type, document.contents(), Some(path)); - - match ruff_python_formatter::format_range(document.contents(), range, format_options) { - Ok(formatted) => { - if formatted.as_code() == document.contents() { - Ok(None) - } else { - Ok(Some(formatted)) + match source_type { + SourceType::Python(py_source_type) => { + let format_options = formatter_settings.to_format_options( + py_source_type, + document.contents(), + Some(path), + ); + + match ruff_python_formatter::format_range(document.contents(), range, format_options) { + Ok(formatted) => { + if formatted.as_code() == document.contents() { + Ok(None) + } else { + Ok(Some(formatted)) + } + } + // Special case - syntax/parse errors are handled here instead of + // being propagated as visible server errors. + Err(FormatModuleError::ParseError(error)) => { + tracing::warn!("Unable to format document range: {error}"); + Ok(None) + } + Err(err) => Err(err.into()), } } - // Special case - syntax/parse errors are handled here instead of - // being propagated as visible server errors. - Err(FormatModuleError::ParseError(error)) => { - tracing::warn!("Unable to format document range: {error}"); - Ok(None) + SourceType::Markdown | SourceType::Toml(_) => { + Ok(None) // todo } - Err(err) => Err(err.into()), } } /// Format range using an external command, i.e., `uv`. fn format_range_external( document: &TextDocument, - source_type: PySourceType, + source_type: SourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, ) -> crate::Result> { - let format_options = - formatter_settings.to_format_options(source_type, document.contents(), Some(path)); - let uv_command = UvFormatCommand::from(format_options); - - // Format the range using uv and convert the result to `PrintedRange` - match uv_command.format_range(document.contents(), range, path, document.index())? { - Some(formatted) => Ok(Some(PrintedRange::new(formatted, range))), - None => Ok(None), + match source_type { + SourceType::Python(py_source_type) => { + let format_options = formatter_settings.to_format_options( + py_source_type, + document.contents(), + Some(path), + ); + let uv_command = UvFormatCommand::from(format_options); + + // Format the range using uv and convert the result to `PrintedRange` + match uv_command.format_range(document.contents(), range, path, document.index())? { + Some(formatted) => Ok(Some(PrintedRange::new(formatted, range))), + None => Ok(None), + } + } + SourceType::Markdown | SourceType::Toml(_) => { + Ok(None) // todo + } } } @@ -327,7 +376,7 @@ mod tests { use insta::assert_snapshot; use ruff_linter::settings::types::{CompiledPerFileTargetVersionList, PerFileTargetVersion}; - use ruff_python_ast::{PySourceType, PythonVersion}; + use ruff_python_ast::{PySourceType, PythonVersion, SourceType}; use ruff_text_size::{TextRange, TextSize}; use ruff_workspace::FormatterSettings; @@ -349,7 +398,7 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a .unwrap(); let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings { unresolved_target_version: PythonVersion::PY38, per_file_target_version, @@ -373,7 +422,7 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a // same as above but without the per_file_target_version override let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings { unresolved_target_version: PythonVersion::PY38, ..Default::default() @@ -420,7 +469,7 @@ sys.exit( .unwrap(); let result = format_range( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings { unresolved_target_version: PythonVersion::PY38, per_file_target_version, @@ -445,7 +494,7 @@ sys.exit( // same as above but without the per_file_target_version override let result = format_range( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings { unresolved_target_version: PythonVersion::PY38, ..Default::default() @@ -488,7 +537,7 @@ def world( ): let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings::default(), Path::new("test.py"), FormatBackend::Uv, @@ -529,7 +578,7 @@ def another_function(x,y,z): let result = format_range( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings::default(), range, Path::new("test.py"), @@ -571,7 +620,7 @@ def hello(very_long_parameter_name_1, very_long_parameter_name_2, very_long_para let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &formatter_settings, Path::new("test.py"), FormatBackend::Uv, @@ -618,7 +667,7 @@ def hello(): let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &formatter_settings, Path::new("test.py"), FormatBackend::Uv, @@ -650,7 +699,7 @@ def broken(: // uv should return None for syntax errors (as indicated by the TODO comment) let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &FormatterSettings::default(), Path::new("test.py"), FormatBackend::Uv, @@ -684,7 +733,7 @@ line''' let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &formatter_settings, Path::new("test.py"), FormatBackend::Uv, @@ -726,7 +775,7 @@ bar = [1, 2, 3,] let result = format( &document, - PySourceType::Python, + SourceType::Python(PySourceType::Python), &formatter_settings, Path::new("test.py"), FormatBackend::Uv, diff --git a/crates/ruff_server/src/lint.rs b/crates/ruff_server/src/lint.rs index 356f802a90973..2a3cb301a5f2b 100644 --- a/crates/ruff_server/src/lint.rs +++ b/crates/ruff_server/src/lint.rs @@ -1,5 +1,6 @@ //! Access to the Ruff linting API for the LSP +use ruff_python_ast::SourceType; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; @@ -95,7 +96,9 @@ pub(crate) fn check( None }; - let source_type = query.source_type(); + let SourceType::Python(source_type) = query.source_type() else { + return DiagnosticsMap::default(); // todo? + }; let target_version = settings.linter.resolve_target_version(&document_path); diff --git a/crates/ruff_server/src/resolve.rs b/crates/ruff_server/src/resolve.rs index 905512342f21e..6c836c14044c1 100644 --- a/crates/ruff_server/src/resolve.rs +++ b/crates/ruff_server/src/resolve.rs @@ -73,6 +73,11 @@ fn is_document_excluded( } else if let Some(LanguageId::Python) = language_id { tracing::debug!("Included path via Python language ID: {}", path.display()); false + } else if let Some(LanguageId::Markdown) = language_id + && let Some(_) = formatter_settings + { + tracing::debug!("Included path via Markdown language ID: {}", path.display()); + false } else { tracing::debug!( "Ignored path as it's not in the inclusion set: {}", diff --git a/crates/ruff_server/src/session/index.rs b/crates/ruff_server/src/session/index.rs index 3fcf7d1db02a3..381421fc98b2f 100644 --- a/crates/ruff_server/src/session/index.rs +++ b/crates/ruff_server/src/session/index.rs @@ -583,10 +583,12 @@ impl DocumentQuery { } /// Get the source type of the document associated with this query. - pub(crate) fn source_type(&self) -> ruff_python_ast::PySourceType { + pub(crate) fn source_type(&self) -> ruff_python_ast::SourceType { match self { - Self::Text { .. } => ruff_python_ast::PySourceType::from(self.virtual_file_path()), - Self::Notebook { .. } => ruff_python_ast::PySourceType::Ipynb, + Self::Text { .. } => ruff_python_ast::SourceType::from(self.virtual_file_path()), + Self::Notebook { .. } => { + ruff_python_ast::SourceType::Python(ruff_python_ast::PySourceType::Ipynb) + } } } From bfc9056176d7467a8f78da1f53119a1fba155668 Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Thu, 5 Feb 2026 13:01:11 -0800 Subject: [PATCH 2/4] Resolve todos and clippy --- crates/ruff_server/src/format.rs | 119 +++++++++++++++---------------- 1 file changed, 58 insertions(+), 61 deletions(-) diff --git a/crates/ruff_server/src/format.rs b/crates/ruff_server/src/format.rs index 5be246053c464..e8e8efbf5ca57 100644 --- a/crates/ruff_server/src/format.rs +++ b/crates/ruff_server/src/format.rs @@ -2,7 +2,7 @@ use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; -use anyhow::{Context, Error}; +use anyhow::Context; use ruff_formatter::{FormatOptions, PrintedRange}; use ruff_markdown::{MarkdownResult, format_code_blocks}; @@ -76,7 +76,8 @@ fn format_internal( } SourceType::Markdown => { if !formatter_settings.preview.is_enabled() { - return Ok(None); // todo + tracing::warn!("Markdown formatting is experimental, enable preview mode."); + return Ok(None); } match format_code_blocks(document.contents(), Some(path), formatter_settings) { @@ -84,7 +85,10 @@ fn format_internal( MarkdownResult::Unchanged => Ok(None), } } - SourceType::Toml(_) => Ok(None), // todo + SourceType::Toml(_) => { + tracing::warn!("Formatting TOML files not supported"); + Ok(None) + } } } @@ -95,20 +99,22 @@ fn format_external( formatter_settings: &FormatterSettings, path: &Path, ) -> crate::Result> { - match source_type { + let format_options = match source_type { SourceType::Python(py_source_type) => { - let format_options = formatter_settings.to_format_options( - py_source_type, - document.contents(), - Some(path), - ); - let uv_command = UvFormatCommand::from(format_options); - uv_command.format_document(document.contents(), path) + formatter_settings.to_format_options(py_source_type, document.contents(), Some(path)) } - SourceType::Markdown | SourceType::Toml(_) => { - Ok(None) // todo + SourceType::Markdown => formatter_settings.to_format_options( + PySourceType::Python, + document.contents(), + Some(path), + ), + SourceType::Toml(_) => { + tracing::warn!("Formatting TOML files not supported"); + return Ok(None); } - } + }; + let uv_command = UvFormatCommand::from(format_options); + uv_command.format_document(document.contents(), path) } pub(crate) fn format_range( @@ -119,12 +125,23 @@ pub(crate) fn format_range( path: &Path, backend: FormatBackend, ) -> crate::Result> { + let py_source_type = match source_type { + SourceType::Python(py_source_type) => py_source_type, + SourceType::Markdown => { + tracing::warn!("Range formatting for Markdown files not supported"); + return Ok(None); + } + SourceType::Toml(_) => { + tracing::warn!("Formatting TOML files not supported"); + return Ok(None); + } + }; match backend { FormatBackend::Uv => { - format_range_external(document, source_type, formatter_settings, range, path) + format_range_external(document, py_source_type, formatter_settings, range, path) } FormatBackend::Internal => { - format_range_internal(document, source_type, formatter_settings, range, path) + format_range_internal(document, py_source_type, formatter_settings, range, path) } } } @@ -132,68 +149,48 @@ pub(crate) fn format_range( /// Format range using the built-in Ruff formatter fn format_range_internal( document: &TextDocument, - source_type: SourceType, + source_type: PySourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, ) -> crate::Result> { - match source_type { - SourceType::Python(py_source_type) => { - let format_options = formatter_settings.to_format_options( - py_source_type, - document.contents(), - Some(path), - ); - - match ruff_python_formatter::format_range(document.contents(), range, format_options) { - Ok(formatted) => { - if formatted.as_code() == document.contents() { - Ok(None) - } else { - Ok(Some(formatted)) - } - } - // Special case - syntax/parse errors are handled here instead of - // being propagated as visible server errors. - Err(FormatModuleError::ParseError(error)) => { - tracing::warn!("Unable to format document range: {error}"); - Ok(None) - } - Err(err) => Err(err.into()), + let format_options = + formatter_settings.to_format_options(source_type, document.contents(), Some(path)); + + match ruff_python_formatter::format_range(document.contents(), range, format_options) { + Ok(formatted) => { + if formatted.as_code() == document.contents() { + Ok(None) + } else { + Ok(Some(formatted)) } } - SourceType::Markdown | SourceType::Toml(_) => { - Ok(None) // todo + // Special case - syntax/parse errors are handled here instead of + // being propagated as visible server errors. + Err(FormatModuleError::ParseError(error)) => { + tracing::warn!("Unable to format document range: {error}"); + Ok(None) } + Err(err) => Err(err.into()), } } /// Format range using an external command, i.e., `uv`. fn format_range_external( document: &TextDocument, - source_type: SourceType, + source_type: PySourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, ) -> crate::Result> { - match source_type { - SourceType::Python(py_source_type) => { - let format_options = formatter_settings.to_format_options( - py_source_type, - document.contents(), - Some(path), - ); - let uv_command = UvFormatCommand::from(format_options); - - // Format the range using uv and convert the result to `PrintedRange` - match uv_command.format_range(document.contents(), range, path, document.index())? { - Some(formatted) => Ok(Some(PrintedRange::new(formatted, range))), - None => Ok(None), - } - } - SourceType::Markdown | SourceType::Toml(_) => { - Ok(None) // todo - } + let format_options = + formatter_settings.to_format_options(source_type, document.contents(), Some(path)); + let uv_command = UvFormatCommand::from(format_options); + + // Format the range using uv and convert the result to `PrintedRange` + match uv_command.format_range(document.contents(), range, path, document.index())? { + Some(formatted) => Ok(Some(PrintedRange::new(formatted, range))), + None => Ok(None), } } From 96a237ee73e6b515fd0738041c204bc48bc21d61 Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Thu, 5 Feb 2026 13:46:06 -0800 Subject: [PATCH 3/4] nits --- crates/ruff_server/src/fix.rs | 2 +- crates/ruff_server/src/resolve.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_server/src/fix.rs b/crates/ruff_server/src/fix.rs index 71f99ff9228f1..ab6a527f97287 100644 --- a/crates/ruff_server/src/fix.rs +++ b/crates/ruff_server/src/fix.rs @@ -55,7 +55,7 @@ pub(crate) fn fix_all( }; let SourceType::Python(source_type) = query.source_type() else { - return Ok(Fixes::default()); // todo? + return Ok(Fixes::default()); }; // We need to iteratively apply all safe fixes onto a single file and then diff --git a/crates/ruff_server/src/resolve.rs b/crates/ruff_server/src/resolve.rs index 6c836c14044c1..9b0193e250f46 100644 --- a/crates/ruff_server/src/resolve.rs +++ b/crates/ruff_server/src/resolve.rs @@ -74,7 +74,7 @@ fn is_document_excluded( tracing::debug!("Included path via Python language ID: {}", path.display()); false } else if let Some(LanguageId::Markdown) = language_id - && let Some(_) = formatter_settings + && formatter_settings.is_some() { tracing::debug!("Included path via Markdown language ID: {}", path.display()); false From cdbe55310721011b8f72dfbe768a6833a9e943b0 Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Fri, 6 Feb 2026 09:17:49 -0800 Subject: [PATCH 4/4] drop todo --- crates/ruff_server/src/lint.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_server/src/lint.rs b/crates/ruff_server/src/lint.rs index 2a3cb301a5f2b..9446b10fd8fd7 100644 --- a/crates/ruff_server/src/lint.rs +++ b/crates/ruff_server/src/lint.rs @@ -97,7 +97,7 @@ pub(crate) fn check( }; let SourceType::Python(source_type) = query.source_type() else { - return DiagnosticsMap::default(); // todo? + return DiagnosticsMap::default(); }; let target_version = settings.linter.resolve_target_version(&document_path);