diff --git a/crates/ruff_server/src/edit.rs b/crates/ruff_server/src/edit.rs index 0de5979339..9af598eaeb 100644 --- a/crates/ruff_server/src/edit.rs +++ b/crates/ruff_server/src/edit.rs @@ -2,11 +2,13 @@ mod document; mod range; +mod replacement; pub use document::Document; pub(crate) use document::DocumentVersion; use lsp_types::PositionEncodingKind; pub(crate) use range::{RangeExt, ToRangeExt}; +pub(crate) use replacement::Replacement; /// A convenient enumeration for supported text encodings. Can be converted to [`lsp_types::PositionEncodingKind`]. // Please maintain the order from least to greatest priority for the derived `Ord` impl. diff --git a/crates/ruff_server/src/edit/replacement.rs b/crates/ruff_server/src/edit/replacement.rs new file mode 100644 index 0000000000..24a58ec3f1 --- /dev/null +++ b/crates/ruff_server/src/edit/replacement.rs @@ -0,0 +1,98 @@ +use ruff_text_size::{TextLen, TextRange, TextSize}; + +pub(crate) struct Replacement { + pub(crate) source_range: TextRange, + pub(crate) modified_range: TextRange, +} + +impl Replacement { + /// Creates a [`Replacement`] that describes the `source_range` of `source` to replace + /// with `modified` sliced by `modified_range`. + pub(crate) fn between( + source: &str, + source_line_starts: &[TextSize], + modified: &str, + modified_line_starts: &[TextSize], + ) -> Self { + let mut source_start = TextSize::default(); + let mut replaced_start = TextSize::default(); + let mut source_end = source.text_len(); + let mut replaced_end = modified.text_len(); + let mut line_iter = source_line_starts + .iter() + .copied() + .zip(modified_line_starts.iter().copied()); + for (source_line_start, modified_line_start) in line_iter.by_ref() { + if source_line_start != modified_line_start + || source[TextRange::new(source_start, source_line_start)] + != modified[TextRange::new(replaced_start, modified_line_start)] + { + break; + } + source_start = source_line_start; + replaced_start = modified_line_start; + } + + let mut line_iter = line_iter.rev(); + + for (old_line_start, new_line_start) in line_iter.by_ref() { + if old_line_start <= source_start + || new_line_start <= replaced_start + || source[TextRange::new(old_line_start, source_end)] + != modified[TextRange::new(new_line_start, replaced_end)] + { + break; + } + source_end = old_line_start; + replaced_end = new_line_start; + } + + Replacement { + source_range: TextRange::new(source_start, source_end), + modified_range: TextRange::new(replaced_start, replaced_end), + } + } +} + +#[cfg(test)] +mod tests { + use ruff_source_file::LineIndex; + + use super::Replacement; + + #[test] + fn find_replacement_range_works() { + let original = r#" + aaaa + bbbb + cccc + dddd + eeee + "#; + let original_index = LineIndex::from_source_text(original); + let new = r#" + bb + cccc + dd + "#; + let new_index = LineIndex::from_source_text(new); + let expected = r#" + bb + cccc + dd + "#; + let replacement = Replacement::between( + original, + original_index.line_starts(), + new, + new_index.line_starts(), + ); + let mut test = original.to_string(); + test.replace_range( + replacement.source_range.start().to_usize()..replacement.source_range.end().to_usize(), + &new[replacement.modified_range], + ); + + assert_eq!(expected, &test); + } +} diff --git a/crates/ruff_server/src/fix.rs b/crates/ruff_server/src/fix.rs new file mode 100644 index 0000000000..fa5607f972 --- /dev/null +++ b/crates/ruff_server/src/fix.rs @@ -0,0 +1,79 @@ +use ruff_linter::{ + linter::{FixerResult, LinterResult}, + settings::{flags, types::UnsafeFixes, LinterSettings}, + source_kind::SourceKind, +}; +use ruff_python_ast::PySourceType; +use ruff_source_file::LineIndex; +use std::{borrow::Cow, path::Path}; + +use crate::{ + edit::{Replacement, ToRangeExt}, + PositionEncoding, +}; + +pub(crate) fn fix_all( + document: &crate::edit::Document, + linter_settings: &LinterSettings, + encoding: PositionEncoding, +) -> crate::Result> { + let source = document.contents(); + + let source_type = PySourceType::default(); + + // TODO(jane): Support Jupyter Notebooks + let source_kind = SourceKind::Python(source.to_string()); + + // 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 + // edit. + // If we simply generated the diagnostics with `check_path` and then applied fixes individually, + // there's a possibility they could overlap or introduce new problems that need to be fixed, + // which is inconsistent with how `ruff check --fix` works. + let FixerResult { + transformed, + result: LinterResult { error, .. }, + .. + } = ruff_linter::linter::lint_fix( + Path::new(""), + None, + flags::Noqa::Enabled, + UnsafeFixes::Disabled, + linter_settings, + &source_kind, + source_type, + )?; + + if let Some(error) = error { + // abort early if a parsing error occurred + return Err(anyhow::anyhow!( + "A parsing error occurred during `fix_all`: {error}" + )); + } + + // fast path: if `transformed` is still borrowed, no changes were made and we can return early + if let Cow::Borrowed(_) = transformed { + return Ok(vec![]); + } + + let modified = transformed.source_code(); + + let modified_index = LineIndex::from_source_text(modified); + + let source_index = document.index(); + + let Replacement { + source_range, + modified_range, + } = Replacement::between( + source, + source_index.line_starts(), + modified, + modified_index.line_starts(), + ); + + Ok(vec![lsp_types::TextEdit { + range: source_range.to_range(source, source_index, encoding), + new_text: modified[modified_range].to_owned(), + }]) +} diff --git a/crates/ruff_server/src/lib.rs b/crates/ruff_server/src/lib.rs index b4d50d7523..4814436678 100644 --- a/crates/ruff_server/src/lib.rs +++ b/crates/ruff_server/src/lib.rs @@ -1,9 +1,11 @@ //! ## The Ruff Language Server pub use edit::{Document, PositionEncoding}; +use lsp_types::CodeActionKind; pub use server::Server; mod edit; +mod fix; mod format; mod lint; mod server; @@ -12,6 +14,10 @@ mod session; pub(crate) const SERVER_NAME: &str = "ruff"; pub(crate) const DIAGNOSTIC_NAME: &str = "Ruff"; +pub(crate) const SOURCE_FIX_ALL_RUFF: CodeActionKind = CodeActionKind::new("source.fixAll.ruff"); +pub(crate) const SOURCE_ORGANIZE_IMPORTS_RUFF: CodeActionKind = + CodeActionKind::new("source.organizeImports.ruff"); + /// A common result type used in most cases where a /// result type is needed. pub(crate) type Result = anyhow::Result; diff --git a/crates/ruff_server/src/lint.rs b/crates/ruff_server/src/lint.rs index 347e836c0b..ba8bef1e26 100644 --- a/crates/ruff_server/src/lint.rs +++ b/crates/ruff_server/src/lint.rs @@ -16,14 +16,26 @@ use ruff_python_index::Indexer; use ruff_python_parser::lexer::LexResult; use ruff_python_parser::AsMode; use ruff_source_file::Locator; +use ruff_text_size::Ranged; use serde::{Deserialize, Serialize}; use crate::{edit::ToRangeExt, PositionEncoding, DIAGNOSTIC_NAME}; -#[derive(Serialize, Deserialize)] -pub(crate) struct DiagnosticFix { +/// This is serialized on the diagnostic `data` field. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub(crate) struct AssociatedDiagnosticData { pub(crate) kind: DiagnosticKind, pub(crate) fix: Fix, + pub(crate) code: String, +} + +/// Describes a fix for `fixed_diagnostic` that applies `document_edits` to the source. +#[derive(Clone, Debug)] +pub(crate) struct DiagnosticFix { + pub(crate) fixed_diagnostic: lsp_types::Diagnostic, + pub(crate) title: String, + pub(crate) code: String, + pub(crate) document_edits: Vec, } pub(crate) fn check( @@ -78,6 +90,56 @@ pub(crate) fn check( .collect() } +pub(crate) fn fixes_for_diagnostics<'d>( + document: &'d crate::edit::Document, + url: &'d lsp_types::Url, + encoding: PositionEncoding, + version: crate::edit::DocumentVersion, + diagnostics: Vec, +) -> crate::Result> { + diagnostics + .into_iter() + .map(move |mut diagnostic| { + let Some(data) = diagnostic.data.take() else { + return Ok(None); + }; + let fixed_diagnostic = diagnostic; + let associated_data: crate::lint::AssociatedDiagnosticData = + serde_json::from_value(data).map_err(|err| { + anyhow::anyhow!("failed to deserialize diagnostic data: {err}") + })?; + let edits = associated_data + .fix + .edits() + .iter() + .map(|edit| lsp_types::TextEdit { + range: edit + .range() + .to_range(document.contents(), document.index(), encoding), + new_text: edit.content().unwrap_or_default().to_string(), + }); + + let document_edits = vec![lsp_types::TextDocumentEdit { + text_document: lsp_types::OptionalVersionedTextDocumentIdentifier::new( + url.clone(), + version, + ), + edits: edits.map(lsp_types::OneOf::Left).collect(), + }]; + Ok(Some(DiagnosticFix { + fixed_diagnostic, + code: associated_data.code, + title: associated_data + .kind + .suggestion + .unwrap_or(associated_data.kind.name), + document_edits, + })) + }) + .filter_map(crate::Result::transpose) + .collect() +} + fn to_lsp_diagnostic( diagnostic: Diagnostic, document: &crate::edit::Document, @@ -92,9 +154,10 @@ fn to_lsp_diagnostic( let data = fix.and_then(|fix| { fix.applies(Applicability::Unsafe) .then(|| { - serde_json::to_value(&DiagnosticFix { + serde_json::to_value(&AssociatedDiagnosticData { kind: kind.clone(), fix, + code: rule.noqa_code().to_string(), }) .ok() }) diff --git a/crates/ruff_server/src/server.rs b/crates/ruff_server/src/server.rs index b2a51a2a15..53bafa06a4 100644 --- a/crates/ruff_server/src/server.rs +++ b/crates/ruff_server/src/server.rs @@ -72,10 +72,10 @@ impl Server { Ok(Self { conn, - client_capabilities, threads, worker_threads, - session: Session::new(&server_capabilities, &workspaces)?, + session: Session::new(&client_capabilities, &server_capabilities, &workspaces)?, + client_capabilities, }) } @@ -192,14 +192,15 @@ impl Server { position_encoding: Some(position_encoding.into()), code_action_provider: Some(types::CodeActionProviderCapability::Options( CodeActionOptions { - code_action_kinds: Some(vec![ - CodeActionKind::QUICKFIX, - CodeActionKind::SOURCE_ORGANIZE_IMPORTS, - ]), + code_action_kinds: Some( + SupportedCodeAction::all() + .flat_map(|action| action.kinds().into_iter()) + .collect(), + ), work_done_progress_options: WorkDoneProgressOptions { work_done_progress: Some(true), }, - resolve_provider: Some(false), + resolve_provider: Some(true), }, )), workspace: Some(types::WorkspaceServerCapabilities { @@ -235,3 +236,56 @@ impl Server { } } } + +/// The code actions we support. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SupportedCodeAction { + /// Maps to the `quickfix` code action kind. Quick fix code actions are shown under + /// their respective diagnostics. Quick fixes are only created where the fix applicability is + /// at least [`ruff_diagnostics::Applicability::Unsafe`]. + QuickFix, + /// Maps to the `source.fixAll` and `source.fixAll.ruff` code action kinds. + /// This is a source action that applies all safe fixes to the currently open document. + SourceFixAll, + /// Maps to `source.organizeImports` and `source.organizeImports.ruff` code action kinds. + /// This is a source action that applies import sorting fixes to the currently open document. + #[allow(dead_code)] // TODO: remove + SourceOrganizeImports, +} + +impl SupportedCodeAction { + /// Returns the possible LSP code action kind(s) that map to this code action. + fn kinds(self) -> Vec { + match self { + Self::QuickFix => vec![CodeActionKind::QUICKFIX], + Self::SourceFixAll => vec![CodeActionKind::SOURCE_FIX_ALL, crate::SOURCE_FIX_ALL_RUFF], + Self::SourceOrganizeImports => vec![ + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + crate::SOURCE_ORGANIZE_IMPORTS_RUFF, + ], + } + } + + /// Returns all code actions kinds that the server currently supports. + fn all() -> impl Iterator { + [ + Self::QuickFix, + Self::SourceFixAll, + // Self::SourceOrganizeImports, + ] + .into_iter() + } +} + +impl TryFrom for SupportedCodeAction { + type Error = (); + + fn try_from(kind: CodeActionKind) -> std::result::Result { + for supported_kind in Self::all() { + if supported_kind.kinds().contains(&kind) { + return Ok(supported_kind); + } + } + Err(()) + } +} diff --git a/crates/ruff_server/src/server/api.rs b/crates/ruff_server/src/server/api.rs index 21f9a26a42..90cf543a29 100644 --- a/crates/ruff_server/src/server/api.rs +++ b/crates/ruff_server/src/server/api.rs @@ -16,8 +16,8 @@ use super::{client::Responder, schedule::BackgroundSchedule, Result}; /// given the parameter type used by the implementer. macro_rules! define_document_url { ($params:ident: &$p:ty) => { - fn document_url($params: &$p) -> &lsp_types::Url { - &$params.text_document.uri + fn document_url($params: &$p) -> std::borrow::Cow { + std::borrow::Cow::Borrowed(&$params.text_document.uri) } }; } @@ -28,10 +28,13 @@ pub(super) fn request<'a>(req: server::Request) -> Task<'a> { let id = req.id.clone(); match req.method.as_str() { - request::CodeAction::METHOD => background_request_task::( + request::CodeActions::METHOD => background_request_task::( req, BackgroundSchedule::LatencySensitive, ), + request::CodeActionResolve::METHOD => { + background_request_task::(req, BackgroundSchedule::Worker) + } request::DocumentDiagnostic::METHOD => { background_request_task::( req, @@ -102,7 +105,7 @@ fn background_request_task<'a, R: traits::BackgroundDocumentRequestHandler>( let (id, params) = cast_request::(req)?; Ok(Task::background(schedule, move |session: &Session| { // TODO(jane): we should log an error if we can't take a snapshot. - let Some(snapshot) = session.take_snapshot(R::document_url(¶ms)) else { + let Some(snapshot) = session.take_snapshot(&R::document_url(¶ms)) else { return Box::new(|_, _| {}); }; Box::new(move |notifier, responder| { @@ -131,7 +134,7 @@ fn background_notification_thread<'a, N: traits::BackgroundDocumentNotificationH let (id, params) = cast_notification::(req)?; Ok(Task::background(schedule, move |session: &Session| { // TODO(jane): we should log an error if we can't take a snapshot. - let Some(snapshot) = session.take_snapshot(N::document_url(¶ms)) else { + let Some(snapshot) = session.take_snapshot(&N::document_url(¶ms)) else { return Box::new(|_, _| {}); }; Box::new(move |notifier, _| { diff --git a/crates/ruff_server/src/server/api/requests.rs b/crates/ruff_server/src/server/api/requests.rs index d29a60a660..3883c009bd 100644 --- a/crates/ruff_server/src/server/api/requests.rs +++ b/crates/ruff_server/src/server/api/requests.rs @@ -1,4 +1,5 @@ mod code_action; +mod code_action_resolve; mod diagnostic; mod format; mod format_range; @@ -7,7 +8,8 @@ use super::{ define_document_url, traits::{BackgroundDocumentRequestHandler, RequestHandler}, }; -pub(super) use code_action::CodeAction; +pub(super) use code_action::CodeActions; +pub(super) use code_action_resolve::CodeActionResolve; pub(super) use diagnostic::DocumentDiagnostic; pub(super) use format::Format; pub(super) use format_range::FormatRange; diff --git a/crates/ruff_server/src/server/api/requests/code_action.rs b/crates/ruff_server/src/server/api/requests/code_action.rs index 235b651078..a16eab75af 100644 --- a/crates/ruff_server/src/server/api/requests/code_action.rs +++ b/crates/ruff_server/src/server/api/requests/code_action.rs @@ -1,81 +1,131 @@ -use crate::edit::ToRangeExt; +use crate::lint::fixes_for_diagnostics; use crate::server::api::LSPResult; +use crate::server::SupportedCodeAction; use crate::server::{client::Notifier, Result}; use crate::session::DocumentSnapshot; +use crate::DIAGNOSTIC_NAME; +use lsp_server::ErrorCode; use lsp_types::{self as types, request as req}; -use ruff_text_size::Ranged; +use rustc_hash::FxHashSet; +use types::{CodeActionKind, CodeActionOrCommand}; -pub(crate) struct CodeAction; +use super::code_action_resolve::resolve_edit_for_fix_all; -impl super::RequestHandler for CodeAction { +pub(crate) struct CodeActions; + +impl super::RequestHandler for CodeActions { type RequestType = req::CodeActionRequest; } -impl super::BackgroundDocumentRequestHandler for CodeAction { +impl super::BackgroundDocumentRequestHandler for CodeActions { super::define_document_url!(params: &types::CodeActionParams); fn run_with_snapshot( snapshot: DocumentSnapshot, _notifier: Notifier, params: types::CodeActionParams, ) -> Result> { - let document = snapshot.document(); - let url = snapshot.url(); - let encoding = snapshot.encoding(); - let version = document.version(); - let actions: Result> = params - .context - .diagnostics - .into_iter() - .map(|diagnostic| { - let Some(data) = diagnostic.data else { - return Ok(None); - }; - let diagnostic_fix: crate::lint::DiagnosticFix = serde_json::from_value(data) - .map_err(|err| anyhow::anyhow!("failed to deserialize diagnostic data: {err}")) - .with_failure_code(lsp_server::ErrorCode::ParseError)?; - let edits = diagnostic_fix - .fix - .edits() - .iter() - .map(|edit| types::TextEdit { - range: edit.range().to_range( - document.contents(), - document.index(), - encoding, - ), - new_text: edit.content().unwrap_or_default().to_string(), - }); + let mut response: types::CodeActionResponse = types::CodeActionResponse::default(); - let changes = vec![types::TextDocumentEdit { - text_document: types::OptionalVersionedTextDocumentIdentifier::new( - url.clone(), - version, - ), - edits: edits.map(types::OneOf::Left).collect(), - }]; + let supported_code_actions = supported_code_actions(params.context.only); - let title = diagnostic_fix - .kind - .suggestion - .unwrap_or(diagnostic_fix.kind.name); - Ok(Some(types::CodeAction { - title, - kind: Some(types::CodeActionKind::QUICKFIX), - edit: Some(types::WorkspaceEdit { - document_changes: Some(types::DocumentChanges::Edits(changes)), - ..Default::default() - }), - ..Default::default() - })) - }) - .collect(); + if supported_code_actions.contains(&SupportedCodeAction::QuickFix) { + response.extend( + quick_fix(&snapshot, params.context.diagnostics) + .with_failure_code(ErrorCode::InternalError)?, + ); + } - Ok(Some( - actions? - .into_iter() - .flatten() - .map(types::CodeActionOrCommand::CodeAction) - .collect(), - )) + if supported_code_actions.contains(&SupportedCodeAction::SourceFixAll) { + response.push(fix_all(&snapshot).with_failure_code(ErrorCode::InternalError)?); + } + + if supported_code_actions.contains(&SupportedCodeAction::SourceOrganizeImports) { + todo!("Implement the `source.organizeImports` code action"); + } + + Ok(Some(response)) } } + +fn quick_fix( + snapshot: &DocumentSnapshot, + diagnostics: Vec, +) -> crate::Result + '_> { + let document = snapshot.document(); + + let fixes = fixes_for_diagnostics( + document, + snapshot.url(), + snapshot.encoding(), + document.version(), + diagnostics, + )?; + + Ok(fixes.into_iter().map(|fix| { + types::CodeActionOrCommand::CodeAction(types::CodeAction { + title: format!("{DIAGNOSTIC_NAME} ({}): {}", fix.code, fix.title), + kind: Some(types::CodeActionKind::QUICKFIX), + edit: Some(types::WorkspaceEdit { + document_changes: Some(types::DocumentChanges::Edits(fix.document_edits.clone())), + ..Default::default() + }), + diagnostics: Some(vec![fix.fixed_diagnostic.clone()]), + data: Some(serde_json::to_value(snapshot.url()).expect("document url to serialize")), + ..Default::default() + }) + })) +} + +fn fix_all(snapshot: &DocumentSnapshot) -> crate::Result { + let document = snapshot.document(); + + let (edit, data) = if snapshot + .resolved_client_capabilities() + .code_action_deferred_edit_resolution + { + // The editor will request the edit in a `CodeActionsResolve` request + ( + None, + Some(serde_json::to_value(snapshot.url()).expect("document url to serialize")), + ) + } else { + ( + Some(resolve_edit_for_fix_all( + document, + snapshot.url(), + &snapshot.configuration().linter, + snapshot.encoding(), + )?), + None, + ) + }; + let action = types::CodeAction { + title: format!("{DIAGNOSTIC_NAME}: Fix all auto-fixable problems"), + kind: Some(types::CodeActionKind::SOURCE_FIX_ALL), + edit, + data, + ..Default::default() + }; + Ok(types::CodeActionOrCommand::CodeAction(action)) +} + +/// If `action_filter` is `None`, this returns [`SupportedCodeActionKind::all()`]. Otherwise, +/// the list is filtered. +fn supported_code_actions( + action_filter: Option>, +) -> FxHashSet { + let Some(action_filter) = action_filter else { + return SupportedCodeAction::all().collect(); + }; + + SupportedCodeAction::all() + .filter(move |action| { + action_filter.iter().any(|filter| { + action + .kinds() + .iter() + .any(|kind| kind.as_str().starts_with(filter.as_str())) + }) + }) + .collect() +} diff --git a/crates/ruff_server/src/server/api/requests/code_action_resolve.rs b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs new file mode 100644 index 0000000000..ffb05c6210 --- /dev/null +++ b/crates/ruff_server/src/server/api/requests/code_action_resolve.rs @@ -0,0 +1,82 @@ +use std::borrow::Cow; + +use crate::server::api::LSPResult; +use crate::server::SupportedCodeAction; +use crate::server::{client::Notifier, Result}; +use crate::session::DocumentSnapshot; +use crate::PositionEncoding; +use lsp_server::ErrorCode; +use lsp_types::{self as types, request as req}; +use ruff_linter::settings::LinterSettings; + +pub(crate) struct CodeActionResolve; + +impl super::RequestHandler for CodeActionResolve { + type RequestType = req::CodeActionResolveRequest; +} + +impl super::BackgroundDocumentRequestHandler for CodeActionResolve { + fn document_url(params: &types::CodeAction) -> Cow { + let uri: lsp_types::Url = serde_json::from_value(params.data.clone().unwrap_or_default()) + .expect("code actions should have a URI in their data fields"); + std::borrow::Cow::Owned(uri) + } + fn run_with_snapshot( + snapshot: DocumentSnapshot, + _notifier: Notifier, + mut action: types::CodeAction, + ) -> Result { + let document = snapshot.document(); + + let action_kind: SupportedCodeAction = action + .kind + .clone() + .ok_or(anyhow::anyhow!("No kind was given for code action")) + .with_failure_code(ErrorCode::InvalidParams)? + .try_into() + .map_err(|()| anyhow::anyhow!("Code action was of an invalid kind")) + .with_failure_code(ErrorCode::InvalidParams)?; + + action.edit = match action_kind { + SupportedCodeAction::SourceFixAll => Some( + resolve_edit_for_fix_all( + document, + snapshot.url(), + &snapshot.configuration().linter, + snapshot.encoding(), + ) + .with_failure_code(ErrorCode::InternalError)?, + ), + SupportedCodeAction::SourceOrganizeImports => { + todo!("Support `source.organizeImports`") + } + SupportedCodeAction::QuickFix => { + return Err(anyhow::anyhow!( + "Got a code action that should not need additional resolution: {action_kind:?}" + )) + .with_failure_code(ErrorCode::InvalidParams) + } + }; + + Ok(action) + } +} + +pub(super) fn resolve_edit_for_fix_all( + document: &crate::edit::Document, + url: &types::Url, + linter_settings: &LinterSettings, + encoding: PositionEncoding, +) -> crate::Result { + Ok(types::WorkspaceEdit { + changes: Some( + [( + url.clone(), + crate::fix::fix_all(document, linter_settings, encoding)?, + )] + .into_iter() + .collect(), + ), + ..Default::default() + }) +} diff --git a/crates/ruff_server/src/server/api/requests/format.rs b/crates/ruff_server/src/server/api/requests/format.rs index 384539ad09..566e360905 100644 --- a/crates/ruff_server/src/server/api/requests/format.rs +++ b/crates/ruff_server/src/server/api/requests/format.rs @@ -1,10 +1,9 @@ -use crate::edit::ToRangeExt; +use crate::edit::{Replacement, ToRangeExt}; use crate::server::api::LSPResult; use crate::server::{client::Notifier, Result}; use crate::session::DocumentSnapshot; use lsp_types::{self as types, request as req}; use ruff_source_file::LineIndex; -use ruff_text_size::{TextLen, TextRange, TextSize}; use types::TextEdit; pub(crate) struct Format; @@ -33,8 +32,8 @@ impl super::BackgroundDocumentRequestHandler for Format { let unformatted_index = doc.index(); let Replacement { - source_range: replace_range, - formatted_range: replacement_text_range, + source_range, + modified_range: formatted_range, } = Replacement::between( source, unformatted_index.line_starts(), @@ -43,105 +42,8 @@ impl super::BackgroundDocumentRequestHandler for Format { ); Ok(Some(vec![TextEdit { - range: replace_range.to_range(source, unformatted_index, snapshot.encoding()), - new_text: formatted[replacement_text_range].to_owned(), + range: source_range.to_range(source, unformatted_index, snapshot.encoding()), + new_text: formatted[formatted_range].to_owned(), }])) } } - -struct Replacement { - source_range: TextRange, - formatted_range: TextRange, -} - -impl Replacement { - /// Creates a [`Replacement`] that describes the `replace_range` of `old_text` to replace - /// with `new_text` sliced by `replacement_text_range`. - fn between( - source: &str, - source_line_starts: &[TextSize], - formatted: &str, - formatted_line_starts: &[TextSize], - ) -> Self { - let mut source_start = TextSize::default(); - let mut formatted_start = TextSize::default(); - let mut source_end = source.text_len(); - let mut formatted_end = formatted.text_len(); - let mut line_iter = source_line_starts - .iter() - .copied() - .zip(formatted_line_starts.iter().copied()); - for (source_line_start, formatted_line_start) in line_iter.by_ref() { - if source_line_start != formatted_line_start - || source[TextRange::new(source_start, source_line_start)] - != formatted[TextRange::new(formatted_start, formatted_line_start)] - { - break; - } - source_start = source_line_start; - formatted_start = formatted_line_start; - } - - let mut line_iter = line_iter.rev(); - - for (old_line_start, new_line_start) in line_iter.by_ref() { - if old_line_start <= source_start - || new_line_start <= formatted_start - || source[TextRange::new(old_line_start, source_end)] - != formatted[TextRange::new(new_line_start, formatted_end)] - { - break; - } - source_end = old_line_start; - formatted_end = new_line_start; - } - - Replacement { - source_range: TextRange::new(source_start, source_end), - formatted_range: TextRange::new(formatted_start, formatted_end), - } - } -} - -#[cfg(test)] -mod tests { - use ruff_source_file::LineIndex; - - use crate::server::api::requests::format::Replacement; - - #[test] - fn find_replacement_range_works() { - let original = r#" - aaaa - bbbb - cccc - dddd - eeee - "#; - let original_index = LineIndex::from_source_text(original); - let new = r#" - bb - cccc - dd - "#; - let new_index = LineIndex::from_source_text(new); - let expected = r#" - bb - cccc - dd - "#; - let replacement = Replacement::between( - original, - original_index.line_starts(), - new, - new_index.line_starts(), - ); - let mut test = original.to_string(); - test.replace_range( - replacement.source_range.start().to_usize()..replacement.source_range.end().to_usize(), - &new[replacement.formatted_range], - ); - - assert_eq!(expected, &test); - } -} diff --git a/crates/ruff_server/src/server/api/traits.rs b/crates/ruff_server/src/server/api/traits.rs index 54639546dc..7a980ebfca 100644 --- a/crates/ruff_server/src/server/api/traits.rs +++ b/crates/ruff_server/src/server/api/traits.rs @@ -31,7 +31,7 @@ pub(super) trait BackgroundDocumentRequestHandler: RequestHandler { /// implementation. fn document_url( params: &<::RequestType as Request>::Params, - ) -> &lsp_types::Url; + ) -> std::borrow::Cow; fn run_with_snapshot( snapshot: DocumentSnapshot, @@ -66,7 +66,7 @@ pub(super) trait BackgroundDocumentNotificationHandler: NotificationHandler { /// implementation. fn document_url( params: &<::NotificationType as LSPNotification>::Params, - ) -> &lsp_types::Url; + ) -> std::borrow::Cow; fn run_with_snapshot( snapshot: DocumentSnapshot, diff --git a/crates/ruff_server/src/session.rs b/crates/ruff_server/src/session.rs index aa039b2836..22f1ed50ce 100644 --- a/crates/ruff_server/src/session.rs +++ b/crates/ruff_server/src/session.rs @@ -1,34 +1,36 @@ //! Data model, state management, and configuration resolution. -mod types; +mod settings; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::{ops::Deref, sync::Arc}; use anyhow::anyhow; -use lsp_types::{ServerCapabilities, Url}; +use lsp_types::{ClientCapabilities, ServerCapabilities, Url}; use ruff_workspace::resolver::{ConfigurationTransformer, Relativity}; use rustc_hash::FxHashMap; use crate::edit::{Document, DocumentVersion}; use crate::PositionEncoding; +use self::settings::ResolvedClientCapabilities; + /// The global state for the LSP pub(crate) struct Session { /// Workspace folders in the current session, which contain the state of all open files. workspaces: Workspaces, /// The global position encoding, negotiated during LSP initialization. position_encoding: PositionEncoding, - /// Extension-specific settings, set by the client, that apply to all workspace folders. - #[allow(dead_code)] - lsp_settings: types::ExtensionSettings, + /// Tracks what LSP features the client supports and doesn't support. + resolved_client_capabilities: Arc, } /// An immutable snapshot of `Session` that references /// a specific document. pub(crate) struct DocumentSnapshot { configuration: Arc, + resolved_client_capabilities: Arc, document_ref: DocumentRef, position_encoding: PositionEncoding, url: Url, @@ -70,6 +72,7 @@ pub(crate) struct DocumentRef { impl Session { pub(crate) fn new( + client_capabilities: &ClientCapabilities, server_capabilities: &ServerCapabilities, workspaces: &[Url], ) -> crate::Result { @@ -79,7 +82,9 @@ impl Session { .as_ref() .and_then(|encoding| encoding.try_into().ok()) .unwrap_or_default(), - lsp_settings: types::ExtensionSettings, + resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new( + client_capabilities, + )), workspaces: Workspaces::new(workspaces)?, }) } @@ -87,6 +92,7 @@ impl Session { pub(crate) fn take_snapshot(&self, url: &Url) -> Option { Some(DocumentSnapshot { configuration: self.workspaces.configuration(url)?.clone(), + resolved_client_capabilities: self.resolved_client_capabilities.clone(), document_ref: self.workspaces.snapshot(url)?, position_encoding: self.position_encoding, url: url.clone(), @@ -196,6 +202,10 @@ impl DocumentSnapshot { &self.configuration } + pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities { + &self.resolved_client_capabilities + } + pub(crate) fn document(&self) -> &DocumentRef { &self.document_ref } diff --git a/crates/ruff_server/src/session/settings.rs b/crates/ruff_server/src/session/settings.rs new file mode 100644 index 0000000000..f415aa7541 --- /dev/null +++ b/crates/ruff_server/src/session/settings.rs @@ -0,0 +1,25 @@ +use lsp_types::ClientCapabilities; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ResolvedClientCapabilities { + pub(crate) code_action_deferred_edit_resolution: bool, +} + +impl ResolvedClientCapabilities { + pub(super) fn new(client_capabilities: &ClientCapabilities) -> Self { + let code_action_settings = client_capabilities + .text_document + .as_ref() + .and_then(|doc_settings| doc_settings.code_action.as_ref()); + let code_action_data_support = code_action_settings + .and_then(|code_action_settings| code_action_settings.data_support) + .unwrap_or_default(); + let code_action_edit_resolution = code_action_settings + .and_then(|code_action_settings| code_action_settings.resolve_support.as_ref()) + .is_some_and(|resolve_support| resolve_support.properties.contains(&"edit".into())); + Self { + code_action_deferred_edit_resolution: code_action_data_support + && code_action_edit_resolution, + } + } +} diff --git a/crates/ruff_server/src/session/types.rs b/crates/ruff_server/src/session/types.rs deleted file mode 100644 index 1ed23ae69d..0000000000 --- a/crates/ruff_server/src/session/types.rs +++ /dev/null @@ -1,3 +0,0 @@ -#[allow(dead_code)] // TODO(jane): get this wired up after the pre-release -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) struct ExtensionSettings;