Skip to main content

mcpls_core/bridge/translator/
edits.rs

1//! Rename, format-document, and code-actions handlers.
2
3use std::path::PathBuf;
4
5use lsp_types::{
6    DocumentFormattingParams, FormattingOptions, PartialResultParams,
7    RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams,
8    WorkDoneProgressParams,
9};
10use tokio::task::JoinSet;
11
12use super::Translator;
13use super::diagnostics::diagnostic_to_mcp;
14use super::dto::{
15    CodeAction, CodeActionsResult, CommandDescription, DocumentChanges, DroppedEdits,
16    FormatDocumentResult, Position, RenameResult, TextEdit, WorkspaceEditDescription,
17};
18use super::encoding_ctx::EncodingCtx;
19use super::routing::{Capability, IndexingGate, MAX_POSITION_VALUE, MAX_RANGE_LINES};
20use crate::bridge::uri_in_workspace_roots;
21use crate::config::{ServerId, ToolKind};
22use crate::error::{Error, Result};
23use crate::lsp::LspClient;
24
25/// Convert LSP range to MCP range (0-based to 1-based).
26/// Validate parameters for `handle_code_actions`.
27fn validate_code_action_params(
28    start: Position,
29    end: Position,
30    kind_filter: Option<&str>,
31) -> Result<()> {
32    const VALID_ACTION_KINDS: &[&str] = &[
33        "quickfix",
34        "refactor",
35        "refactor.extract",
36        "refactor.inline",
37        "refactor.rewrite",
38        "source",
39        "source.organizeImports",
40    ];
41
42    let Position {
43        line: start_line,
44        character: start_character,
45    } = start;
46    let Position {
47        line: end_line,
48        character: end_character,
49    } = end;
50
51    if let Some(kind) = kind_filter
52        && !VALID_ACTION_KINDS
53            .iter()
54            .any(|k| k.eq_ignore_ascii_case(kind))
55    {
56        return Err(Error::InvalidToolParams(format!(
57            "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
58        )));
59    }
60
61    if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
62        return Err(Error::InvalidToolParams(
63            "Line and character positions must be >= 1".to_string(),
64        ));
65    }
66
67    if start_line > MAX_POSITION_VALUE
68        || start_character > MAX_POSITION_VALUE
69        || end_line > MAX_POSITION_VALUE
70        || end_character > MAX_POSITION_VALUE
71    {
72        return Err(Error::InvalidToolParams(format!(
73            "Position values must be <= {MAX_POSITION_VALUE}"
74        )));
75    }
76
77    if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
78        return Err(Error::InvalidToolParams(format!(
79            "Range size must be <= {MAX_RANGE_LINES} lines"
80        )));
81    }
82
83    if start_line > end_line || (start_line == end_line && start_character > end_character) {
84        return Err(Error::InvalidToolParams(
85            "Start position must be before or equal to end position".to_string(),
86        ));
87    }
88
89    Ok(())
90}
91
92/// Maximum length, in bytes, of a `rename_symbol` `new_name` parameter.
93///
94/// `new_name` is forwarded to the routed LSP server as-is with no inherent
95/// bound of its own -- unlike `workspace_symbol_search`'s `query` (see
96/// `validate_query_length`), it previously relied entirely on outer
97/// transport limits (#309). No real identifier approaches this length in any
98/// language mcpls targets.
99pub(super) const MAX_NEW_NAME_LENGTH: usize = 1_000;
100
101/// Validate parameters for `handle_rename`.
102fn validate_rename_params(new_name: &str) -> Result<()> {
103    if new_name.len() > MAX_NEW_NAME_LENGTH {
104        return Err(Error::InvalidToolParams(format!(
105            "new_name too long: {} bytes (max {MAX_NEW_NAME_LENGTH})",
106            new_name.len()
107        )));
108    }
109    Ok(())
110}
111
112/// Convert a raw LSP `WorkspaceEdit` into MCP `DocumentChanges`.
113///
114/// Prefers the legacy `changes` map (`HashMap<Uri, Vec<TextEdit>>`) and falls
115/// back to `documentChanges` (the array form some servers, e.g.
116/// rust-analyzer, use instead) only when `changes` is `None` or an empty
117/// map. The choice of source is made once, up front, from the *raw* map's
118/// presence and emptiness -- decided before any per-entry filtering -- never
119/// from whether filtering happened to leave zero surviving entries. This
120/// matters because `changes` and `documentChanges` each get their own
121/// [`DroppedEdits`] tally: branching on the post-filter result instead would
122/// reintroduce #475 M1, where a `changes` map that exists but whose entries
123/// are all filtered out falls through to `documentChanges` and either
124/// double-counts or loses the `changes`-side drops. The fallback order is
125/// safe because mcpls's advertised client capabilities (`lsp/lifecycle.rs`)
126/// do not set `workspace.workspaceEdit.documentChanges`, so per LSP 3.17 a
127/// spec-compliant server must always populate `changes`; the
128/// `documentChanges`-only fallback exists solely for common non-compliant
129/// servers (e.g. rust-analyzer), and the branch where both are present is
130/// effectively dead in practice -- do not "fix" this to prefer
131/// `documentChanges` first without first advertising that capability. An
132/// entry outside `workspace_roots` is dropped rather than rewritten into the
133/// response -- see [`crate::bridge::uri_in_workspace_roots`]. `edit_kind`
134/// names the caller for the dropped-entry log line (e.g. `"rename edit"`,
135/// `"code-action edit"`).
136async fn convert_workspace_edit(
137    edit: lsp_types::WorkspaceEdit,
138    ctx: &EncodingCtx,
139    workspace_roots: &[PathBuf],
140    edit_kind: &str,
141) -> (Vec<DocumentChanges>, DroppedEdits) {
142    let mut result_changes = Vec::new();
143    let mut dropped = DroppedEdits::default();
144
145    if let Some(changes_map) = edit.changes.filter(|m| !m.is_empty()) {
146        for (uri, edits) in changes_map {
147            if !uri_in_workspace_roots(&uri, workspace_roots) {
148                tracing::warn!(uri = uri.as_ref(), "dropping out-of-workspace {edit_kind}");
149                dropped.out_of_workspace += 1;
150                continue;
151            }
152            let mut text_edits = Vec::with_capacity(edits.len());
153            for e in edits {
154                text_edits.push(TextEdit {
155                    range: ctx.normalize_range(&uri, e.range).await,
156                    new_text: e.new_text,
157                });
158            }
159            result_changes.push(DocumentChanges {
160                uri: uri.to_string(),
161                edits: text_edits,
162            });
163        }
164    } else if let Some(document_changes) = edit.document_changes {
165        let text_doc_edits: Vec<lsp_types::TextDocumentEdit> = document_changes
166            .into_iter()
167            .filter_map(|change| match change {
168                lsp_types::DocumentChange::TextDocumentEdit(e) => Some(e),
169                lsp_types::DocumentChange::CreateFile(_)
170                | lsp_types::DocumentChange::RenameFile(_)
171                | lsp_types::DocumentChange::DeleteFile(_) => {
172                    tracing::debug!("dropping unsupported file-operation document change");
173                    dropped.unsupported_file_operation += 1;
174                    None
175                }
176            })
177            .collect();
178        for tde in text_doc_edits {
179            let edit_uri = &tde.text_document.text_document_identifier.uri;
180            if !uri_in_workspace_roots(edit_uri, workspace_roots) {
181                tracing::warn!(
182                    uri = edit_uri.as_ref(),
183                    "dropping out-of-workspace {edit_kind}"
184                );
185                dropped.out_of_workspace += 1;
186                continue;
187            }
188            let mut text_edits = Vec::with_capacity(tde.edits.len());
189            for one_of in tde.edits {
190                let text_edit = match one_of {
191                    lsp_types::Edit::TextEdit(te) => TextEdit {
192                        range: ctx.normalize_range(edit_uri, te.range).await,
193                        new_text: te.new_text,
194                    },
195                    lsp_types::Edit::AnnotatedTextEdit(ate) => TextEdit {
196                        range: ctx.normalize_range(edit_uri, ate.text_edit.range).await,
197                        new_text: ate.text_edit.new_text,
198                    },
199                    // Snippet edits are an LSP 3.18 addition mcpls does not
200                    // advertise support for (`WorkspaceEditClientCapabilities`
201                    // carries no `snippetEditSupport`). A server can still send
202                    // one; its `new_text` would carry literal snippet
203                    // placeholder syntax (e.g. `${1:name}`), which would be
204                    // written into the user's file as-is if treated as plain
205                    // text -- this is dropped instead, consistent with how
206                    // `CreateFile`/`RenameFile`/`DeleteFile` are already
207                    // dropped above rather than mistranslated.
208                    lsp_types::Edit::SnippetTextEdit(_) => {
209                        tracing::debug!("dropping unsupported snippet text edit");
210                        dropped.unsupported_snippet_edit += 1;
211                        continue;
212                    }
213                };
214                text_edits.push(text_edit);
215            }
216            result_changes.push(DocumentChanges {
217                uri: edit_uri.to_string(),
218                edits: text_edits,
219            });
220        }
221    }
222
223    (result_changes, dropped)
224}
225
226/// Upper bound on the number of `codeAction/resolve` round-trips attempted
227/// for a single `handle_code_actions` call.
228///
229/// mcpls advertises both `data_support` and `resolve_support: ["edit"]`
230/// (`lsp/lifecycle.rs`), so a server such as rust-analyzer may defer the
231/// edit on every returned action -- realistically 5-20 for a single cursor
232/// position. Resolves run concurrently (see `handle_code_actions`) and each
233/// is capped by `LspClient::code_action_resolve_timeout`, but an unbounded
234/// count would still let one tool call fan out an unbounded number of LSP
235/// requests. Actions beyond this limit are returned without an edit, exactly
236/// as they were before this round-trip existed (#432).
237const MAX_CODE_ACTION_RESOLVES: usize = 20;
238
239/// Resolve a code action's deferred `edit` via `codeAction/resolve`.
240///
241/// Per LSP 3.16, a server may return a `CodeAction` with `data: Some(_)` and
242/// `edit: None` from `textDocument/codeAction`, expecting the client to
243/// follow up with `codeAction/resolve` to obtain the actual edit (#432) --
244/// mcpls advertises `resolve_support` for `edit` (`lsp/lifecycle.rs`) but
245/// previously never issued that follow-up request, so such an action was
246/// always reported to the MCP caller with no edit at all.
247///
248/// Returns the *original* `action` with only its `edit` field replaced by
249/// the resolve response's `edit`, rather than the resolve response
250/// wholesale: mcpls's `resolve_support` advertises exactly one resolvable
251/// property (`edit`), so a server that builds a fresh `CodeAction` in its
252/// resolve handler instead of mutating the one it was handed could otherwise
253/// silently drop `kind`, `command`, `diagnostics`, `is_preferred`, or
254/// `disabled`.
255///
256/// Falls back to the original, edit-less `action` on any resolve failure
257/// (timeout, server error, or a server that does not actually implement
258/// resolve despite advertising it) -- a missing edit is strictly better than
259/// failing the whole `code_actions` call over one unresolvable action.
260async fn resolve_code_action(
261    client: &LspClient,
262    server_id: &ServerId,
263    mut action: lsp_types::CodeAction,
264) -> lsp_types::CodeAction {
265    match client
266        .request_typed::<lsp_types::CodeActionResolveRequest>(
267            action.clone(),
268            client.code_action_resolve_timeout(),
269        )
270        .await
271    {
272        Ok(resolved) => {
273            if resolved.edit.is_some() {
274                action.edit = resolved.edit;
275            } else {
276                tracing::debug!(
277                    %server_id,
278                    title = %action.title,
279                    "codeAction/resolve succeeded but returned no edit"
280                );
281            }
282            action
283        }
284        Err(err) => {
285            tracing::warn!(
286                %server_id,
287                title = %action.title,
288                error = %err,
289                "codeAction/resolve failed, returning action without edit"
290            );
291            action
292        }
293    }
294}
295
296/// Resolves up to [`MAX_CODE_ACTION_RESOLVES`] deferred actions in `entries`
297/// concurrently -- via [`resolve_code_action`] -- replacing each resolved
298/// entry in place. No-op when `resolve_supported` is `false`.
299///
300/// Runs the round-trips through a [`JoinSet`] rather than sequentially so
301/// this call's added latency stays close to one resolve's, not proportional
302/// to how many deferred actions the response contains (#432).
303async fn resolve_deferred_code_actions(
304    entries: &mut [lsp_types::CodeActionResponse],
305    client: &LspClient,
306    server_id: &ServerId,
307    resolve_supported: bool,
308) {
309    if !resolve_supported {
310        return;
311    }
312
313    let mut resolve_tasks = JoinSet::new();
314    let mut skipped_due_to_cap = 0usize;
315    for (index, entry) in entries.iter().enumerate() {
316        let lsp_types::CodeActionResponse::CodeAction(action) = entry else {
317            continue;
318        };
319        if action.edit.is_some() || action.data.is_none() {
320            continue;
321        }
322        if resolve_tasks.len() >= MAX_CODE_ACTION_RESOLVES {
323            skipped_due_to_cap += 1;
324            continue;
325        }
326        let client = client.clone();
327        let server_id = server_id.clone();
328        let action = action.clone();
329        resolve_tasks.spawn(async move {
330            (
331                index,
332                resolve_code_action(&client, &server_id, action).await,
333            )
334        });
335    }
336    if skipped_due_to_cap > 0 {
337        tracing::warn!(
338            %server_id,
339            skipped = skipped_due_to_cap,
340            cap = MAX_CODE_ACTION_RESOLVES,
341            "codeAction/resolve cap reached, returning some actions without edit"
342        );
343    }
344
345    while let Some(result) = resolve_tasks.join_next().await {
346        match result {
347            Ok((index, resolved_action)) => {
348                entries[index] = lsp_types::CodeActionResponse::CodeAction(resolved_action);
349            }
350            Err(join_err) => {
351                // The original, edit-less action already in `entries` is
352                // kept as-is -- same graceful-degradation outcome as a
353                // resolve request that returns an LSP error.
354                tracing::warn!(
355                    %server_id,
356                    error = %join_err,
357                    "codeAction/resolve task panicked, returning action without edit"
358                );
359            }
360        }
361    }
362}
363
364/// Convert LSP code action to MCP code action. `uri` is the queried
365/// document's own URI, used for the action's `diagnostics` (always scoped to
366/// the requested document); `edit`'s per-file URIs (from either `changes` or
367/// `documentChanges`) are each checked against `workspace_roots` before being
368/// trusted -- see [`crate::bridge::uri_in_workspace_roots`].
369async fn convert_code_action(
370    action: lsp_types::CodeAction,
371    ctx: &EncodingCtx,
372    uri: &lsp_types::Uri,
373    workspace_roots: &[PathBuf],
374) -> CodeAction {
375    let diagnostics = match action.diagnostics {
376        Some(diags) => {
377            let mut result = Vec::with_capacity(diags.len());
378            for d in &diags {
379                result.push(diagnostic_to_mcp(d, ctx, uri).await);
380            }
381            result
382        }
383        None => Vec::new(),
384    };
385
386    let edit = match action.edit {
387        Some(edit) => {
388            let (changes, dropped) =
389                convert_workspace_edit(edit, ctx, workspace_roots, "code-action edit").await;
390            Some(WorkspaceEditDescription { changes, dropped })
391        }
392        None => None,
393    };
394
395    let command = action.command.map(|cmd| {
396        let arguments = cmd.arguments.unwrap_or_else(Vec::new);
397        CommandDescription {
398            title: cmd.title,
399            command: cmd.command,
400            arguments,
401        }
402    });
403
404    CodeAction {
405        title: action.title,
406        kind: action.kind.map(String::from),
407        diagnostics,
408        edit,
409        command,
410        is_preferred: action.is_preferred.unwrap_or(false),
411    }
412}
413
414impl Translator {
415    /// Handle rename request.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if `new_name` exceeds the maximum allowed length,
420    /// the LSP request fails, the file cannot be opened, the routed server
421    /// does not advertise `renameProvider` support, or the server is still
422    /// indexing the workspace (see `Translator::wait_for_indexing_ready`) --
423    /// a rename needs the same whole-workspace reference index as
424    /// `get_references`.
425    #[allow(clippy::too_many_lines)]
426    pub async fn handle_rename(
427        &self,
428        file_path: String,
429        position: Position,
430        new_name: String,
431    ) -> Result<RenameResult> {
432        let Position { line, character } = position;
433        validate_rename_params(&new_name)?;
434
435        let (server_id, client, uri) = self
436            .prepare_gated_document(
437                &file_path,
438                ToolKind::Rename,
439                Capability::Rename,
440                IndexingGate::Required,
441            )
442            .await?;
443        let ctx = self.encoding_ctx(&server_id);
444        let lsp_position = ctx.to_lsp(&uri, line, character).await;
445
446        let params = LspRenameParams {
447            text_document_position_params: TextDocumentPositionParams {
448                text_document: TextDocumentIdentifier { uri },
449                position: lsp_position,
450            },
451            new_name,
452            work_done_progress_params: WorkDoneProgressParams::default(),
453        };
454
455        let response = client
456            .request_typed::<lsp_types::RenameRequest>(params, client.request_timeout())
457            .await?;
458
459        let (changes, dropped) = if let Some(edit) = response {
460            convert_workspace_edit(edit, &ctx, &self.workspace_roots, "rename edit").await
461        } else {
462            (vec![], DroppedEdits::default())
463        };
464
465        Ok(RenameResult {
466            changes,
467            dropped,
468            positions_degraded: ctx.positions_degraded(),
469        })
470    }
471
472    /// Handle format document request.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the LSP request fails, the file cannot be opened,
477    /// or the routed server does not advertise `documentFormattingProvider` support.
478    pub async fn handle_format_document(
479        &self,
480        file_path: String,
481        tab_size: u32,
482        insert_spaces: bool,
483    ) -> Result<FormatDocumentResult> {
484        let (server_id, client, uri) = self
485            .prepare_gated_document(
486                &file_path,
487                ToolKind::FormatDocument,
488                Capability::FormatDocument,
489                IndexingGate::NotRequired,
490            )
491            .await?;
492        let ctx = self.encoding_ctx(&server_id);
493        let response_uri = uri.clone();
494
495        let params = DocumentFormattingParams {
496            text_document: TextDocumentIdentifier { uri },
497            options: FormattingOptions {
498                tab_size,
499                insert_spaces,
500                ..Default::default()
501            },
502            work_done_progress_params: WorkDoneProgressParams::default(),
503        };
504
505        let response = client
506            .request_typed::<lsp_types::DocumentFormattingRequest>(params, client.request_timeout())
507            .await?;
508
509        let edits = response.unwrap_or_default();
510
511        let mut result_edits = Vec::with_capacity(edits.len());
512        for edit in edits {
513            result_edits.push(TextEdit {
514                range: ctx.normalize_range(&response_uri, edit.range).await,
515                new_text: edit.new_text,
516            });
517        }
518        let result = FormatDocumentResult {
519            edits: result_edits,
520            positions_degraded: ctx.positions_degraded(),
521        };
522
523        Ok(result)
524    }
525
526    /// Handle code actions request.
527    ///
528    /// For an action returned with `data` present but `edit` absent, and
529    /// only when the routed server's `codeActionProvider` advertises
530    /// `resolveProvider: true`, follows up with a `codeAction/resolve`
531    /// request to populate the edit before returning the action (#432).
532    ///
533    /// # Errors
534    ///
535    /// Returns an error if the LSP request fails, the file cannot be opened,
536    /// the routed server does not advertise `codeActionProvider` support, or
537    /// the server is still indexing the workspace (see
538    /// `wait_for_indexing_ready`).
539    pub async fn handle_code_actions(
540        &self,
541        file_path: String,
542        start: Position,
543        end: Position,
544        kind_filter: Option<String>,
545    ) -> Result<CodeActionsResult> {
546        validate_code_action_params(start, end, kind_filter.as_deref())?;
547
548        let (server_id, client, uri) = self
549            .prepare_gated_document(
550                &file_path,
551                ToolKind::CodeActions,
552                Capability::CodeActions,
553                IndexingGate::Required,
554            )
555            .await?;
556        let ctx = self.encoding_ctx(&server_id);
557        let response_uri = uri.clone();
558
559        let range = lsp_types::Range {
560            start: ctx.to_lsp(&uri, start.line, start.character).await,
561            end: ctx.to_lsp(&uri, end.line, end.character).await,
562        };
563
564        // Build context with optional kind filter
565        let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
566
567        // Pass empty diagnostics context — rust-analyzer generates code actions
568        // based on cursor position and its internal analysis state, not on the
569        // passed diagnostics.  Passing stale cached diagnostics (which may lack
570        // the internal `data` field ra uses for fix mapping) suppresses results.
571        let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
572
573        let params = lsp_types::CodeActionParams {
574            text_document: TextDocumentIdentifier { uri },
575            range,
576            context: lsp_types::CodeActionContext {
577                diagnostics: context_diagnostics,
578                only,
579                trigger_kind: Some(lsp_types::CodeActionTriggerKind::Invoked),
580            },
581            work_done_progress_params: WorkDoneProgressParams::default(),
582            partial_result_params: PartialResultParams::default(),
583        };
584
585        let response = client
586            .request_typed::<lsp_types::CodeActionRequest>(params, client.request_timeout())
587            .await?;
588        let mut entries = response.unwrap_or_default();
589        let resolve_supported = self.code_action_resolve_supported(&server_id);
590        resolve_deferred_code_actions(&mut entries, &client, &server_id, resolve_supported).await;
591
592        let mut actions = Vec::with_capacity(entries.len());
593        for action_or_command in entries {
594            let action = match action_or_command {
595                lsp_types::CodeActionResponse::CodeAction(action) => {
596                    convert_code_action(action, &ctx, &response_uri, &self.workspace_roots).await
597                }
598                lsp_types::CodeActionResponse::Command(cmd) => {
599                    let arguments = cmd.arguments.unwrap_or_else(Vec::new);
600                    CodeAction {
601                        title: cmd.title.clone(),
602                        kind: None,
603                        diagnostics: Vec::new(),
604                        edit: None,
605                        command: Some(CommandDescription {
606                            title: cmd.title,
607                            command: cmd.command,
608                            arguments,
609                        }),
610                        is_preferred: false,
611                    }
612                }
613            };
614            actions.push(action);
615        }
616
617        Ok(CodeActionsResult {
618            actions,
619            positions_degraded: ctx.positions_degraded(),
620        })
621    }
622}
623
624#[cfg(test)]
625#[allow(clippy::unwrap_used, clippy::expect_used)]
626mod tests {
627    use std::fs;
628
629    use super::*;
630    use crate::bridge::translator::dto::DiagnosticSeverity;
631    use crate::bridge::translator::testing::*;
632
633    /// S2/S4 regression: a `documentChanges` entry mixing a plain `TextEdit`
634    /// with an `Edit::SnippetTextEdit` (LSP 3.18, reachable even though mcpls
635    /// advertises no `snippetEditSupport`) must drop the snippet edit rather
636    /// than pass its literal placeholder syntax (`${1:...}`) through as
637    /// ordinary replacement text -- `handle_rename` is the one tool that
638    /// rewrites the user's files.
639    #[tokio::test]
640    #[allow(clippy::literal_string_with_formatting_args)]
641    async fn test_handle_rename_drops_snippet_text_edit_and_keeps_plain_edits() {
642        use std::sync::Arc;
643        use std::time::Duration;
644
645        use tempfile::TempDir;
646        use tokio::io::BufReader;
647        use tokio::time::timeout;
648        use url::Url;
649
650        use crate::config::ServerId;
651
652        let dir = TempDir::new().unwrap();
653        let server_id = ServerId::from("rust");
654        let caps = lsp_types::ServerCapabilities {
655            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
656            ..Default::default()
657        };
658        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
659
660        let file_path = dir.path().join("main.rs");
661        fs::write(&file_path, "fn old_name() {}").unwrap();
662
663        let translator = Arc::new(translator);
664        let handle = {
665            let translator = Arc::clone(&translator);
666            let path = file_path.to_str().unwrap().to_string();
667            tokio::spawn(async move {
668                translator
669                    .handle_rename(
670                        path,
671                        Position {
672                            line: 1,
673                            character: 4,
674                        },
675                        "new_name".to_string(),
676                    )
677                    .await
678            })
679        };
680
681        let file_uri = Url::from_file_path(&file_path).unwrap().to_string();
682        let mut wire = BufReader::new(&mut server.write_stdout);
683        let opened = read_framed_message(&mut wire).await;
684        assert_eq!(opened["method"], "textDocument/didOpen");
685        let request = read_framed_message(&mut wire).await;
686        assert_eq!(request["method"], "textDocument/rename");
687
688        write_response(
689            &mut server.read_half_stdin,
690            &request["id"],
691            serde_json::json!({
692                "documentChanges": [
693                    {
694                        "textDocument": { "uri": file_uri, "version": 1 },
695                        "edits": [
696                            {
697                                "range": {
698                                    "start": {"line": 0, "character": 3},
699                                    "end": {"line": 0, "character": 11}
700                                },
701                                "newText": "new_name"
702                            },
703                            {
704                                "range": {
705                                    "start": {"line": 0, "character": 0},
706                                    "end": {"line": 0, "character": 0}
707                                },
708                                "snippet": { "value": "${1:comment}\n", "kind": "snippet" }
709                            }
710                        ]
711                    }
712                ]
713            }),
714        )
715        .await;
716
717        let result = timeout(Duration::from_secs(2), handle)
718            .await
719            .expect("handler call should not hang")
720            .unwrap()
721            .unwrap();
722
723        assert_eq!(result.changes.len(), 1);
724        assert_eq!(
725            result.changes[0].edits.len(),
726            1,
727            "the snippet edit must be dropped, not converted to literal text"
728        );
729        assert_eq!(result.changes[0].edits[0].new_text, "new_name");
730        assert!(
731            !result.changes[0]
732                .edits
733                .iter()
734                .any(|e| e.new_text.contains("${1:comment}")),
735            "snippet placeholder syntax must never appear as literal replacement text"
736        );
737        assert_eq!(
738            result.dropped.unsupported_snippet_edit, 1,
739            "the dropped snippet edit must be tallied so callers can tell the rename is incomplete"
740        );
741        assert_eq!(result.dropped.out_of_workspace, 0);
742        assert_eq!(result.dropped.unsupported_file_operation, 0);
743    }
744
745    /// #415: a `documentChanges` entry whose URI falls outside every
746    /// configured workspace root must be dropped -- the routed LSP server is
747    /// a trust boundary, and a compromised/misbehaving server could
748    /// otherwise smuggle an out-of-workspace path into a `WorkspaceEdit`
749    /// alongside legitimate in-workspace entries.
750    #[tokio::test]
751    async fn test_handle_rename_drops_out_of_workspace_workspace_edit_entries() {
752        use std::sync::Arc;
753        use std::time::Duration;
754
755        use tempfile::TempDir;
756        use tokio::io::BufReader;
757        use tokio::time::timeout;
758        use url::Url;
759
760        use crate::config::ServerId;
761
762        let dir = TempDir::new().unwrap();
763        let server_id = ServerId::from("rust");
764        let caps = lsp_types::ServerCapabilities {
765            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
766            ..Default::default()
767        };
768        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
769
770        let file_path = dir.path().join("main.rs");
771        fs::write(&file_path, "fn old_name() {}").unwrap();
772        let inside_uri = Url::from_file_path(&file_path).unwrap().to_string();
773        let outside_uri = "file:///outside/workspace/evil.rs";
774
775        let translator = Arc::new(translator);
776        let handle = {
777            let translator = Arc::clone(&translator);
778            let path = file_path.to_str().unwrap().to_string();
779            tokio::spawn(async move {
780                translator
781                    .handle_rename(
782                        path,
783                        Position {
784                            line: 1,
785                            character: 4,
786                        },
787                        "new_name".to_string(),
788                    )
789                    .await
790            })
791        };
792
793        let mut wire = BufReader::new(&mut server.write_stdout);
794        let opened = read_framed_message(&mut wire).await;
795        assert_eq!(opened["method"], "textDocument/didOpen");
796        let request = read_framed_message(&mut wire).await;
797        assert_eq!(request["method"], "textDocument/rename");
798
799        let mut changes_map = serde_json::Map::new();
800        changes_map.insert(
801            inside_uri.clone(),
802            serde_json::json!([
803                {
804                    "range": {
805                        "start": {"line": 0, "character": 3},
806                        "end": {"line": 0, "character": 11}
807                    },
808                    "newText": "new_name"
809                }
810            ]),
811        );
812        changes_map.insert(
813            outside_uri.to_string(),
814            serde_json::json!([
815                {
816                    "range": {
817                        "start": {"line": 0, "character": 0},
818                        "end": {"line": 0, "character": 3}
819                    },
820                    "newText": "evil"
821                }
822            ]),
823        );
824
825        write_response(
826            &mut server.read_half_stdin,
827            &request["id"],
828            serde_json::json!({ "changes": changes_map }),
829        )
830        .await;
831
832        let result = timeout(Duration::from_secs(2), handle)
833            .await
834            .expect("handler call should not hang")
835            .unwrap()
836            .unwrap();
837
838        assert_eq!(
839            result.changes.len(),
840            1,
841            "the out-of-workspace entry must be dropped, not forwarded"
842        );
843        assert_eq!(result.changes[0].uri, inside_uri);
844        assert_eq!(
845            result.dropped.out_of_workspace, 1,
846            "the dropped out-of-workspace entry must be tallied so callers can tell the rename is incomplete"
847        );
848        assert_eq!(result.dropped.unsupported_file_operation, 0);
849        assert_eq!(result.dropped.unsupported_snippet_edit, 0);
850    }
851
852    /// #309: `new_name` has no inherent bound of its own and is forwarded to
853    /// the LSP server as-is, so it must be rejected before that happens.
854    #[test]
855    fn test_validate_rename_params_rejects_oversized_new_name() {
856        let new_name = "a".repeat(MAX_NEW_NAME_LENGTH + 1);
857        let result = validate_rename_params(&new_name);
858        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
859    }
860
861    #[test]
862    fn test_validate_rename_params_accepts_name_at_exact_limit() {
863        let new_name = "a".repeat(MAX_NEW_NAME_LENGTH);
864        assert!(validate_rename_params(&new_name).is_ok());
865    }
866
867    #[test]
868    fn test_validate_rename_params_accepts_typical_identifier() {
869        assert!(validate_rename_params("my_variable").is_ok());
870    }
871
872    /// #309: length checks have no lower bound -- an empty `new_name` is
873    /// syntactically valid input for this validator (semantic rejection of
874    /// an empty rename target, if desired, is a separate concern).
875    #[test]
876    fn test_validate_rename_params_accepts_empty_string() {
877        assert!(validate_rename_params("").is_ok());
878    }
879
880    #[tokio::test]
881    async fn test_handle_code_actions_invalid_kind() {
882        let translator = Translator::new();
883        let result = translator
884            .handle_code_actions(
885                "/tmp/test.rs".to_string(),
886                Position {
887                    line: 1,
888                    character: 1,
889                },
890                Position {
891                    line: 1,
892                    character: 10,
893                },
894                Some("invalid_kind".to_string()),
895            )
896            .await;
897        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
898    }
899
900    #[tokio::test]
901    async fn test_handle_code_actions_valid_kind_quickfix() {
902        use tempfile::TempDir;
903
904        let mut translator = Translator::new();
905        let temp_dir = TempDir::new().unwrap();
906        translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]);
907        let test_file = temp_dir.path().join("test.rs");
908        fs::write(&test_file, "fn main() {}").unwrap();
909
910        let result = translator
911            .handle_code_actions(
912                test_file.to_str().unwrap().to_string(),
913                Position {
914                    line: 1,
915                    character: 1,
916                },
917                Position {
918                    line: 1,
919                    character: 10,
920                },
921                Some("quickfix".to_string()),
922            )
923            .await;
924        // Will fail due to no LSP server, but validates kind is accepted
925        assert!(result.is_err());
926        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
927    }
928
929    #[tokio::test]
930    async fn test_handle_code_actions_valid_kind_refactor() {
931        use tempfile::TempDir;
932
933        let mut translator = Translator::new();
934        let temp_dir = TempDir::new().unwrap();
935        translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]);
936        let test_file = temp_dir.path().join("test.rs");
937        fs::write(&test_file, "fn main() {}").unwrap();
938
939        let result = translator
940            .handle_code_actions(
941                test_file.to_str().unwrap().to_string(),
942                Position {
943                    line: 1,
944                    character: 1,
945                },
946                Position {
947                    line: 1,
948                    character: 10,
949                },
950                Some("refactor".to_string()),
951            )
952            .await;
953        assert!(result.is_err());
954        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
955    }
956
957    #[tokio::test]
958    async fn test_handle_code_actions_valid_kind_refactor_extract() {
959        use tempfile::TempDir;
960
961        let mut translator = Translator::new();
962        let temp_dir = TempDir::new().unwrap();
963        translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]);
964        let test_file = temp_dir.path().join("test.rs");
965        fs::write(&test_file, "fn main() {}").unwrap();
966
967        let result = translator
968            .handle_code_actions(
969                test_file.to_str().unwrap().to_string(),
970                Position {
971                    line: 1,
972                    character: 1,
973                },
974                Position {
975                    line: 1,
976                    character: 10,
977                },
978                Some("refactor.extract".to_string()),
979            )
980            .await;
981        assert!(result.is_err());
982        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
983    }
984
985    #[tokio::test]
986    async fn test_handle_code_actions_valid_kind_source() {
987        use tempfile::TempDir;
988
989        let mut translator = Translator::new();
990        let temp_dir = TempDir::new().unwrap();
991        translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]);
992        let test_file = temp_dir.path().join("test.rs");
993        fs::write(&test_file, "fn main() {}").unwrap();
994
995        let result = translator
996            .handle_code_actions(
997                test_file.to_str().unwrap().to_string(),
998                Position {
999                    line: 1,
1000                    character: 1,
1001                },
1002                Position {
1003                    line: 1,
1004                    character: 10,
1005                },
1006                Some("source.organizeImports".to_string()),
1007            )
1008            .await;
1009        assert!(result.is_err());
1010        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1011    }
1012
1013    #[tokio::test]
1014    async fn test_handle_code_actions_invalid_range_zero() {
1015        let translator = Translator::new();
1016        let result = translator
1017            .handle_code_actions(
1018                "/tmp/test.rs".to_string(),
1019                Position {
1020                    line: 0,
1021                    character: 1,
1022                },
1023                Position {
1024                    line: 1,
1025                    character: 10,
1026                },
1027                None,
1028            )
1029            .await;
1030        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1031    }
1032
1033    #[tokio::test]
1034    async fn test_handle_code_actions_invalid_range_order() {
1035        let translator = Translator::new();
1036        let result = translator
1037            .handle_code_actions(
1038                "/tmp/test.rs".to_string(),
1039                Position {
1040                    line: 10,
1041                    character: 5,
1042                },
1043                Position {
1044                    line: 5,
1045                    character: 1,
1046                },
1047                None,
1048            )
1049            .await;
1050        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1051    }
1052
1053    #[tokio::test]
1054    async fn test_handle_code_actions_empty_range() {
1055        use tempfile::TempDir;
1056
1057        let mut translator = Translator::new();
1058        let temp_dir = TempDir::new().unwrap();
1059        translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]);
1060        let test_file = temp_dir.path().join("test.rs");
1061        fs::write(&test_file, "fn main() {}").unwrap();
1062
1063        // Empty range (same position) should be valid
1064        let result = translator
1065            .handle_code_actions(
1066                test_file.to_str().unwrap().to_string(),
1067                Position {
1068                    line: 1,
1069                    character: 5,
1070                },
1071                Position {
1072                    line: 1,
1073                    character: 5,
1074                },
1075                None,
1076            )
1077            .await;
1078        // Will fail due to no LSP server, but validates range is accepted
1079        assert!(result.is_err());
1080        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1081    }
1082
1083    #[tokio::test]
1084    async fn test_convert_code_action_minimal() {
1085        let lsp_action = lsp_types::CodeAction {
1086            title: "Fix issue".to_string(),
1087            kind: None,
1088            diagnostics: None,
1089            edit: None,
1090            command: None,
1091            is_preferred: None,
1092            disabled: None,
1093            tags: None,
1094            data: None,
1095        };
1096
1097        let result = convert_code_action(lsp_action, &test_ctx(), &test_uri(), &[]).await;
1098        assert_eq!(result.title, "Fix issue");
1099        assert!(result.kind.is_none());
1100        assert!(result.diagnostics.is_empty());
1101        assert!(result.edit.is_none());
1102        assert!(result.command.is_none());
1103        assert!(!result.is_preferred);
1104    }
1105
1106    #[tokio::test]
1107    #[allow(clippy::too_many_lines)]
1108    async fn test_convert_code_action_with_diagnostics_all_severities() {
1109        let lsp_diagnostics = vec![
1110            lsp_types::Diagnostic {
1111                range: lsp_types::Range {
1112                    start: lsp_types::Position {
1113                        line: 0,
1114                        character: 0,
1115                    },
1116                    end: lsp_types::Position {
1117                        line: 0,
1118                        character: 5,
1119                    },
1120                },
1121                severity: Some(lsp_types::DiagnosticSeverity::Error),
1122                message: "Error message".to_string().into(),
1123                code: Some(lsp_types::Code::Int(1)),
1124                source: None,
1125                code_description: None,
1126                related_information: None,
1127                tags: None,
1128                data: None,
1129            },
1130            lsp_types::Diagnostic {
1131                range: lsp_types::Range {
1132                    start: lsp_types::Position {
1133                        line: 1,
1134                        character: 0,
1135                    },
1136                    end: lsp_types::Position {
1137                        line: 1,
1138                        character: 5,
1139                    },
1140                },
1141                severity: Some(lsp_types::DiagnosticSeverity::Warning),
1142                message: "Warning message".to_string().into(),
1143                code: Some(lsp_types::Code::String("W001".to_string())),
1144                source: None,
1145                code_description: None,
1146                related_information: None,
1147                tags: None,
1148                data: None,
1149            },
1150            lsp_types::Diagnostic {
1151                range: lsp_types::Range {
1152                    start: lsp_types::Position {
1153                        line: 2,
1154                        character: 0,
1155                    },
1156                    end: lsp_types::Position {
1157                        line: 2,
1158                        character: 5,
1159                    },
1160                },
1161                severity: Some(lsp_types::DiagnosticSeverity::Information),
1162                message: "Info message".to_string().into(),
1163                code: None,
1164                source: None,
1165                code_description: None,
1166                related_information: None,
1167                tags: None,
1168                data: None,
1169            },
1170            lsp_types::Diagnostic {
1171                range: lsp_types::Range {
1172                    start: lsp_types::Position {
1173                        line: 3,
1174                        character: 0,
1175                    },
1176                    end: lsp_types::Position {
1177                        line: 3,
1178                        character: 5,
1179                    },
1180                },
1181                severity: Some(lsp_types::DiagnosticSeverity::Hint),
1182                message: "Hint message".to_string().into(),
1183                code: None,
1184                source: None,
1185                code_description: None,
1186                related_information: None,
1187                tags: None,
1188                data: None,
1189            },
1190        ];
1191
1192        let lsp_action = lsp_types::CodeAction {
1193            title: "Fix all issues".to_string(),
1194            kind: Some(lsp_types::CodeActionKind::QuickFix),
1195            diagnostics: Some(lsp_diagnostics),
1196            edit: None,
1197            command: None,
1198            is_preferred: None,
1199            disabled: None,
1200            tags: None,
1201            data: None,
1202        };
1203
1204        let result = convert_code_action(lsp_action, &test_ctx(), &test_uri(), &[]).await;
1205        assert_eq!(result.diagnostics.len(), 4);
1206        assert!(matches!(
1207            result.diagnostics[0].severity,
1208            DiagnosticSeverity::Error
1209        ));
1210        assert!(matches!(
1211            result.diagnostics[1].severity,
1212            DiagnosticSeverity::Warning
1213        ));
1214        assert!(matches!(
1215            result.diagnostics[2].severity,
1216            DiagnosticSeverity::Information
1217        ));
1218        assert!(matches!(
1219            result.diagnostics[3].severity,
1220            DiagnosticSeverity::Hint
1221        ));
1222        assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
1223        assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
1224    }
1225
1226    #[tokio::test]
1227    #[allow(clippy::mutable_key_type)]
1228    async fn test_convert_code_action_with_workspace_edit() {
1229        use std::collections::HashMap;
1230
1231        use url::Url;
1232
1233        let dir = tempfile::TempDir::new().unwrap();
1234        let file_path = dir.path().join("test.rs");
1235        fs::write(&file_path, "fn main() {}").unwrap();
1236        let uri_string = Url::from_file_path(&file_path).unwrap().to_string();
1237        let uri = lsp_types::Uri::from(uri_string.as_str());
1238        let mut changes_map = HashMap::new();
1239        changes_map.insert(
1240            uri,
1241            vec![lsp_types::TextEdit {
1242                range: lsp_types::Range {
1243                    start: lsp_types::Position {
1244                        line: 0,
1245                        character: 0,
1246                    },
1247                    end: lsp_types::Position {
1248                        line: 0,
1249                        character: 5,
1250                    },
1251                },
1252                new_text: "fixed".to_string(),
1253            }],
1254        );
1255
1256        let lsp_action = lsp_types::CodeAction {
1257            title: "Apply fix".to_string(),
1258            kind: Some(lsp_types::CodeActionKind::QuickFix),
1259            diagnostics: None,
1260            edit: Some(lsp_types::WorkspaceEdit {
1261                changes: Some(changes_map),
1262                document_changes: None,
1263                change_annotations: None,
1264            }),
1265            command: None,
1266            is_preferred: Some(true),
1267            disabled: None,
1268            tags: None,
1269            data: None,
1270        };
1271
1272        let workspace_roots = vec![dir.path().to_path_buf()];
1273        let result =
1274            convert_code_action(lsp_action, &test_ctx(), &test_uri(), &workspace_roots).await;
1275        assert!(result.edit.is_some());
1276        let edit = result.edit.unwrap();
1277        assert_eq!(edit.changes.len(), 1);
1278        assert_eq!(edit.changes[0].uri, uri_string);
1279        assert_eq!(edit.changes[0].edits.len(), 1);
1280        assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
1281        assert!(result.is_preferred);
1282    }
1283
1284    /// #429: a `codeAction` response carrying only `documentChanges` (the
1285    /// array form some servers, e.g. rust-analyzer, use instead of the
1286    /// legacy `changes` map) must still populate the action's edit list
1287    /// rather than silently dropping it.
1288    #[tokio::test]
1289    async fn test_convert_code_action_with_document_changes_only() {
1290        use url::Url;
1291
1292        let dir = tempfile::TempDir::new().unwrap();
1293        let file_path = dir.path().join("test.rs");
1294        fs::write(&file_path, "fn main() {}").unwrap();
1295        let uri_string = Url::from_file_path(&file_path).unwrap().to_string();
1296        let uri = lsp_types::Uri::from(uri_string.as_str());
1297        let text_document_edit = lsp_types::TextDocumentEdit {
1298            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1299                version: Some(1),
1300                text_document_identifier: TextDocumentIdentifier { uri },
1301            },
1302            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1303                range: lsp_types::Range {
1304                    start: lsp_types::Position {
1305                        line: 0,
1306                        character: 0,
1307                    },
1308                    end: lsp_types::Position {
1309                        line: 0,
1310                        character: 5,
1311                    },
1312                },
1313                new_text: "fixed".to_string(),
1314            })],
1315        };
1316
1317        let lsp_action = lsp_types::CodeAction {
1318            title: "Apply fix via documentChanges".to_string(),
1319            kind: Some(lsp_types::CodeActionKind::QuickFix),
1320            diagnostics: None,
1321            edit: Some(lsp_types::WorkspaceEdit {
1322                changes: None,
1323                document_changes: Some(vec![lsp_types::DocumentChange::TextDocumentEdit(
1324                    text_document_edit,
1325                )]),
1326                change_annotations: None,
1327            }),
1328            command: None,
1329            is_preferred: Some(true),
1330            disabled: None,
1331            tags: None,
1332            data: None,
1333        };
1334
1335        let workspace_roots = vec![dir.path().to_path_buf()];
1336        let result =
1337            convert_code_action(lsp_action, &test_ctx(), &test_uri(), &workspace_roots).await;
1338        assert!(result.edit.is_some());
1339        let edit = result.edit.unwrap();
1340        assert_eq!(edit.changes.len(), 1);
1341        assert_eq!(edit.changes[0].uri, uri_string);
1342        assert_eq!(edit.changes[0].edits.len(), 1);
1343        assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
1344        assert!(result.is_preferred);
1345    }
1346
1347    /// #429 companion: when a `WorkspaceEdit` carries both `changes` and
1348    /// `documentChanges`, `changes` must win and `documentChanges` must be
1349    /// ignored -- matching the precedence `convert_workspace_edit` already
1350    /// applies for `handle_rename`.
1351    #[tokio::test]
1352    #[allow(clippy::mutable_key_type)]
1353    async fn test_convert_code_action_changes_takes_precedence_over_document_changes() {
1354        use std::collections::HashMap;
1355
1356        use url::Url;
1357
1358        let dir = tempfile::TempDir::new().unwrap();
1359        let changes_path = dir.path().join("changes.rs");
1360        fs::write(&changes_path, "fn changes() {}").unwrap();
1361        let changes_uri_string = Url::from_file_path(&changes_path).unwrap().to_string();
1362        let changes_uri = lsp_types::Uri::from(changes_uri_string.as_str());
1363        let mut changes_map = HashMap::new();
1364        changes_map.insert(
1365            changes_uri,
1366            vec![lsp_types::TextEdit {
1367                range: lsp_types::Range {
1368                    start: lsp_types::Position {
1369                        line: 0,
1370                        character: 0,
1371                    },
1372                    end: lsp_types::Position {
1373                        line: 0,
1374                        character: 5,
1375                    },
1376                },
1377                new_text: "from_changes".to_string(),
1378            }],
1379        );
1380
1381        let document_changes_path = dir.path().join("document_changes.rs");
1382        fs::write(&document_changes_path, "fn document_changes() {}").unwrap();
1383        let document_changes_uri = lsp_types::Uri::from(
1384            Url::from_file_path(&document_changes_path)
1385                .unwrap()
1386                .as_str(),
1387        );
1388        let text_document_edit = lsp_types::TextDocumentEdit {
1389            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1390                version: Some(1),
1391                text_document_identifier: TextDocumentIdentifier {
1392                    uri: document_changes_uri,
1393                },
1394            },
1395            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1396                range: lsp_types::Range {
1397                    start: lsp_types::Position {
1398                        line: 0,
1399                        character: 0,
1400                    },
1401                    end: lsp_types::Position {
1402                        line: 0,
1403                        character: 5,
1404                    },
1405                },
1406                new_text: "from_document_changes".to_string(),
1407            })],
1408        };
1409
1410        let lsp_action = lsp_types::CodeAction {
1411            title: "Apply fix".to_string(),
1412            kind: Some(lsp_types::CodeActionKind::QuickFix),
1413            diagnostics: None,
1414            edit: Some(lsp_types::WorkspaceEdit {
1415                changes: Some(changes_map),
1416                document_changes: Some(vec![lsp_types::DocumentChange::TextDocumentEdit(
1417                    text_document_edit,
1418                )]),
1419                change_annotations: None,
1420            }),
1421            command: None,
1422            is_preferred: None,
1423            disabled: None,
1424            tags: None,
1425            data: None,
1426        };
1427
1428        let workspace_roots = vec![dir.path().to_path_buf()];
1429        let result =
1430            convert_code_action(lsp_action, &test_ctx(), &test_uri(), &workspace_roots).await;
1431        let edit = result.edit.unwrap();
1432        assert_eq!(
1433            edit.changes.len(),
1434            1,
1435            "only the `changes` entry should be present"
1436        );
1437        assert_eq!(edit.changes[0].uri, changes_uri_string);
1438        assert_eq!(edit.changes[0].edits[0].new_text, "from_changes");
1439        assert!(
1440            edit.dropped.is_empty(),
1441            "documentChanges being ignored in favor of changes is not a drop"
1442        );
1443    }
1444
1445    /// #429 companion / #415 parity: a `documentChanges` entry whose URI
1446    /// falls outside every configured workspace root must be dropped, the
1447    /// same trust-boundary check `changes` entries already get.
1448    #[tokio::test]
1449    async fn test_convert_code_action_document_changes_drops_out_of_workspace_entry() {
1450        use url::Url;
1451
1452        let dir = tempfile::TempDir::new().unwrap();
1453        let inside_path = dir.path().join("inside.rs");
1454        fs::write(&inside_path, "fn inside() {}").unwrap();
1455        let inside_uri_string = Url::from_file_path(&inside_path).unwrap().to_string();
1456        let inside_uri = lsp_types::Uri::from(inside_uri_string.as_str());
1457        let outside_uri = lsp_types::Uri::from("file:///outside/workspace/evil.rs");
1458
1459        let make_edit = |uri: lsp_types::Uri, new_text: &str| {
1460            lsp_types::DocumentChange::TextDocumentEdit(lsp_types::TextDocumentEdit {
1461                text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1462                    version: Some(1),
1463                    text_document_identifier: TextDocumentIdentifier { uri },
1464                },
1465                edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1466                    range: lsp_types::Range {
1467                        start: lsp_types::Position {
1468                            line: 0,
1469                            character: 0,
1470                        },
1471                        end: lsp_types::Position {
1472                            line: 0,
1473                            character: 3,
1474                        },
1475                    },
1476                    new_text: new_text.to_string(),
1477                })],
1478            })
1479        };
1480
1481        let lsp_action = lsp_types::CodeAction {
1482            title: "Apply fix".to_string(),
1483            kind: Some(lsp_types::CodeActionKind::QuickFix),
1484            diagnostics: None,
1485            edit: Some(lsp_types::WorkspaceEdit {
1486                changes: None,
1487                document_changes: Some(vec![
1488                    make_edit(inside_uri, "fixed"),
1489                    make_edit(outside_uri, "evil"),
1490                ]),
1491                change_annotations: None,
1492            }),
1493            command: None,
1494            is_preferred: None,
1495            disabled: None,
1496            tags: None,
1497            data: None,
1498        };
1499
1500        let workspace_roots = vec![dir.path().to_path_buf()];
1501        let result =
1502            convert_code_action(lsp_action, &test_ctx(), &test_uri(), &workspace_roots).await;
1503        let edit = result.edit.unwrap();
1504        assert_eq!(
1505            edit.changes.len(),
1506            1,
1507            "the out-of-workspace entry must be dropped, not forwarded"
1508        );
1509        assert_eq!(edit.changes[0].uri, inside_uri_string);
1510        assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
1511        assert_eq!(
1512            edit.dropped.out_of_workspace, 1,
1513            "the dropped out-of-workspace entry must be tallied so callers can tell the code action is incomplete"
1514        );
1515    }
1516
1517    /// #475: `CreateFile`/`RenameFile`/`DeleteFile` document changes -- e.g.
1518    /// rust-analyzer emitting `RenameFile` for a module rename -- must each be
1519    /// tallied under `dropped.unsupported_file_operation`, distinct from the
1520    /// other two drop reasons, while a plain `TextDocumentEdit` in the same
1521    /// response still survives.
1522    #[tokio::test]
1523    async fn test_convert_workspace_edit_tallies_dropped_file_operations() {
1524        use url::Url;
1525
1526        let dir = tempfile::TempDir::new().unwrap();
1527        let file_path = dir.path().join("kept.rs");
1528        fs::write(&file_path, "fn kept() {}").unwrap();
1529        let uri_string = Url::from_file_path(&file_path).unwrap().to_string();
1530        let uri = lsp_types::Uri::from(uri_string.as_str());
1531
1532        let text_document_edit = lsp_types::TextDocumentEdit {
1533            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1534                version: Some(1),
1535                text_document_identifier: TextDocumentIdentifier { uri: uri.clone() },
1536            },
1537            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1538                range: lsp_types::Range {
1539                    start: lsp_types::Position {
1540                        line: 0,
1541                        character: 0,
1542                    },
1543                    end: lsp_types::Position {
1544                        line: 0,
1545                        character: 2,
1546                    },
1547                },
1548                new_text: "kept".to_string(),
1549            })],
1550        };
1551
1552        let edit = lsp_types::WorkspaceEdit {
1553            changes: None,
1554            document_changes: Some(vec![
1555                lsp_types::DocumentChange::TextDocumentEdit(text_document_edit),
1556                lsp_types::DocumentChange::CreateFile(lsp_types::CreateFile {
1557                    uri: lsp_types::Uri::from("file:///workspace/new.rs"),
1558                    options: None,
1559                    annotation_id: None,
1560                }),
1561                lsp_types::DocumentChange::RenameFile(lsp_types::RenameFile {
1562                    old_uri: lsp_types::Uri::from("file:///workspace/old_module.rs"),
1563                    new_uri: lsp_types::Uri::from("file:///workspace/new_module.rs"),
1564                    options: None,
1565                    annotation_id: None,
1566                }),
1567                lsp_types::DocumentChange::DeleteFile(lsp_types::DeleteFile {
1568                    uri: lsp_types::Uri::from("file:///workspace/gone.rs"),
1569                    options: None,
1570                    annotation_id: None,
1571                }),
1572            ]),
1573            change_annotations: None,
1574        };
1575
1576        let workspace_roots = vec![dir.path().to_path_buf()];
1577        let (changes, dropped) =
1578            convert_workspace_edit(edit, &test_ctx(), &workspace_roots, "rename edit").await;
1579
1580        assert_eq!(
1581            changes.len(),
1582            1,
1583            "the plain TextDocumentEdit must survive alongside the dropped file operations"
1584        );
1585        assert_eq!(changes[0].uri, uri_string);
1586        assert_eq!(
1587            dropped.unsupported_file_operation, 3,
1588            "CreateFile, RenameFile, and DeleteFile must each be tallied"
1589        );
1590        assert_eq!(dropped.out_of_workspace, 0);
1591        assert_eq!(dropped.unsupported_snippet_edit, 0);
1592    }
1593
1594    /// #475: when every entry in a `WorkspaceEdit` is filtered out, the
1595    /// resulting `changes` list is empty just like "nothing to rename" would
1596    /// be -- `dropped` is what makes the two cases distinguishable.
1597    #[tokio::test]
1598    #[allow(clippy::mutable_key_type)]
1599    async fn test_convert_workspace_edit_everything_dropped_is_distinguishable_from_no_edits() {
1600        use std::collections::HashMap;
1601
1602        let outside_uri = lsp_types::Uri::from("file:///outside/workspace/evil.rs");
1603        let mut changes_map = HashMap::new();
1604        changes_map.insert(
1605            outside_uri,
1606            vec![lsp_types::TextEdit {
1607                range: lsp_types::Range {
1608                    start: lsp_types::Position {
1609                        line: 0,
1610                        character: 0,
1611                    },
1612                    end: lsp_types::Position {
1613                        line: 0,
1614                        character: 3,
1615                    },
1616                },
1617                new_text: "evil".to_string(),
1618            }],
1619        );
1620        let all_dropped_edit = lsp_types::WorkspaceEdit {
1621            changes: Some(changes_map),
1622            document_changes: None,
1623            change_annotations: None,
1624        };
1625
1626        let dir = tempfile::TempDir::new().unwrap();
1627        let workspace_roots = vec![dir.path().to_path_buf()];
1628        let (changes, dropped) = convert_workspace_edit(
1629            all_dropped_edit,
1630            &test_ctx(),
1631            &workspace_roots,
1632            "rename edit",
1633        )
1634        .await;
1635        assert!(changes.is_empty());
1636        assert!(
1637            !dropped.is_empty(),
1638            "an edit where everything was withheld must not look like an edit with nothing to do"
1639        );
1640        assert_eq!(dropped.out_of_workspace, 1);
1641
1642        let no_op_edit = lsp_types::WorkspaceEdit {
1643            changes: None,
1644            document_changes: None,
1645            change_annotations: None,
1646        };
1647        let (changes, dropped) =
1648            convert_workspace_edit(no_op_edit, &test_ctx(), &workspace_roots, "rename edit").await;
1649        assert!(changes.is_empty());
1650        assert!(
1651            dropped.is_empty(),
1652            "a genuinely empty edit must not be reported as having withheld anything"
1653        );
1654    }
1655
1656    /// #475 M1: a `WorkspaceEdit` populating both `changes` and
1657    /// `documentChanges` with the same withheld entry must not tally it
1658    /// twice -- `changes` takes exclusive precedence, so `documentChanges`
1659    /// is never even inspected once `changes` is present.
1660    #[tokio::test]
1661    #[allow(clippy::mutable_key_type)]
1662    async fn test_convert_workspace_edit_changes_precedence_avoids_double_counting_drops() {
1663        use std::collections::HashMap;
1664
1665        let outside_uri = lsp_types::Uri::from("file:///outside/workspace/evil.rs");
1666        let mut changes_map = HashMap::new();
1667        changes_map.insert(
1668            outside_uri.clone(),
1669            vec![lsp_types::TextEdit {
1670                range: lsp_types::Range {
1671                    start: lsp_types::Position {
1672                        line: 0,
1673                        character: 0,
1674                    },
1675                    end: lsp_types::Position {
1676                        line: 0,
1677                        character: 3,
1678                    },
1679                },
1680                new_text: "evil".to_string(),
1681            }],
1682        );
1683
1684        let text_document_edit = lsp_types::TextDocumentEdit {
1685            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1686                version: Some(1),
1687                text_document_identifier: TextDocumentIdentifier { uri: outside_uri },
1688            },
1689            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1690                range: lsp_types::Range {
1691                    start: lsp_types::Position {
1692                        line: 0,
1693                        character: 0,
1694                    },
1695                    end: lsp_types::Position {
1696                        line: 0,
1697                        character: 3,
1698                    },
1699                },
1700                new_text: "evil".to_string(),
1701            })],
1702        };
1703
1704        let edit = lsp_types::WorkspaceEdit {
1705            changes: Some(changes_map),
1706            document_changes: Some(vec![lsp_types::DocumentChange::TextDocumentEdit(
1707                text_document_edit,
1708            )]),
1709            change_annotations: None,
1710        };
1711
1712        let dir = tempfile::TempDir::new().unwrap();
1713        let workspace_roots = vec![dir.path().to_path_buf()];
1714        let (changes, dropped) =
1715            convert_workspace_edit(edit, &test_ctx(), &workspace_roots, "rename edit").await;
1716
1717        assert!(changes.is_empty());
1718        assert_eq!(
1719            dropped.out_of_workspace, 1,
1720            "documentChanges must be ignored entirely once changes is present, not merged in \
1721             and double-tallied"
1722        );
1723    }
1724
1725    /// #475 M1 asymmetric case: `changes` populates entries that are *all*
1726    /// dropped while `documentChanges` separately carries a distinct,
1727    /// in-workspace edit that would fully succeed. A design that falls back
1728    /// to `documentChanges` whenever `changes` yields no *surviving* entries
1729    /// (rather than deciding up front from field presence) would process
1730    /// `documentChanges` here, discarding the real `changes` drops in the
1731    /// process -- reporting `dropped.is_empty()` even though entries were
1732    /// genuinely withheld. `changes` being present must keep its own drop
1733    /// count intact regardless of what `documentChanges` separately contains.
1734    #[tokio::test]
1735    #[allow(clippy::mutable_key_type)]
1736    async fn test_convert_workspace_edit_changes_precedence_keeps_drops_when_document_changes_would_succeed()
1737     {
1738        use std::collections::HashMap;
1739
1740        use url::Url;
1741
1742        let dir = tempfile::TempDir::new().unwrap();
1743        let mut changes_map = HashMap::new();
1744        changes_map.insert(
1745            lsp_types::Uri::from("file:///outside/workspace/one.rs"),
1746            vec![lsp_types::TextEdit {
1747                range: lsp_types::Range {
1748                    start: lsp_types::Position {
1749                        line: 0,
1750                        character: 0,
1751                    },
1752                    end: lsp_types::Position {
1753                        line: 0,
1754                        character: 3,
1755                    },
1756                },
1757                new_text: "evil".to_string(),
1758            }],
1759        );
1760        changes_map.insert(
1761            lsp_types::Uri::from("file:///outside/workspace/two.rs"),
1762            vec![lsp_types::TextEdit {
1763                range: lsp_types::Range {
1764                    start: lsp_types::Position {
1765                        line: 0,
1766                        character: 0,
1767                    },
1768                    end: lsp_types::Position {
1769                        line: 0,
1770                        character: 3,
1771                    },
1772                },
1773                new_text: "evil".to_string(),
1774            }],
1775        );
1776
1777        let in_workspace_path = dir.path().join("kept.rs");
1778        fs::write(&in_workspace_path, "fn kept() {}").unwrap();
1779        let in_workspace_uri =
1780            lsp_types::Uri::from(Url::from_file_path(&in_workspace_path).unwrap().as_str());
1781        let text_document_edit = lsp_types::TextDocumentEdit {
1782            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1783                version: Some(1),
1784                text_document_identifier: TextDocumentIdentifier {
1785                    uri: in_workspace_uri,
1786                },
1787            },
1788            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1789                range: lsp_types::Range {
1790                    start: lsp_types::Position {
1791                        line: 0,
1792                        character: 0,
1793                    },
1794                    end: lsp_types::Position {
1795                        line: 0,
1796                        character: 2,
1797                    },
1798                },
1799                new_text: "kept".to_string(),
1800            })],
1801        };
1802
1803        let edit = lsp_types::WorkspaceEdit {
1804            changes: Some(changes_map),
1805            document_changes: Some(vec![lsp_types::DocumentChange::TextDocumentEdit(
1806                text_document_edit,
1807            )]),
1808            change_annotations: None,
1809        };
1810
1811        let workspace_roots = vec![dir.path().to_path_buf()];
1812        let (changes, dropped) =
1813            convert_workspace_edit(edit, &test_ctx(), &workspace_roots, "rename edit").await;
1814
1815        assert!(
1816            changes.is_empty(),
1817            "changes takes precedence even though every one of its entries was withheld"
1818        );
1819        assert_eq!(
1820            dropped.out_of_workspace, 2,
1821            "both changes-branch drops must be tallied, not lost by falling back to documentChanges"
1822        );
1823    }
1824
1825    /// #475 M1 (second bug): `changes` can be present as a literally empty
1826    /// map (`"changes": {}`, legal per `lsp_types::WorkspaceEdit`) rather
1827    /// than omitted -- branching on `Option::is_some()` alone would treat
1828    /// that as "use `changes`", silently discarding a populated
1829    /// `documentChanges` and returning a result indistinguishable from
1830    /// "nothing to rename".
1831    #[tokio::test]
1832    #[allow(clippy::mutable_key_type)]
1833    async fn test_convert_workspace_edit_falls_back_to_document_changes_when_changes_map_is_present_but_empty()
1834     {
1835        use std::collections::HashMap;
1836
1837        use url::Url;
1838
1839        let dir = tempfile::TempDir::new().unwrap();
1840        let file_path = dir.path().join("kept.rs");
1841        fs::write(&file_path, "fn kept() {}").unwrap();
1842        let uri_string = Url::from_file_path(&file_path).unwrap().to_string();
1843        let uri = lsp_types::Uri::from(uri_string.as_str());
1844
1845        let text_document_edit = lsp_types::TextDocumentEdit {
1846            text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
1847                version: Some(1),
1848                text_document_identifier: TextDocumentIdentifier { uri },
1849            },
1850            edits: vec![lsp_types::Edit::TextEdit(lsp_types::TextEdit {
1851                range: lsp_types::Range {
1852                    start: lsp_types::Position {
1853                        line: 0,
1854                        character: 0,
1855                    },
1856                    end: lsp_types::Position {
1857                        line: 0,
1858                        character: 2,
1859                    },
1860                },
1861                new_text: "kept".to_string(),
1862            })],
1863        };
1864
1865        let edit = lsp_types::WorkspaceEdit {
1866            changes: Some(HashMap::new()),
1867            document_changes: Some(vec![lsp_types::DocumentChange::TextDocumentEdit(
1868                text_document_edit,
1869            )]),
1870            change_annotations: None,
1871        };
1872
1873        let workspace_roots = vec![dir.path().to_path_buf()];
1874        let (changes, dropped) =
1875            convert_workspace_edit(edit, &test_ctx(), &workspace_roots, "rename edit").await;
1876
1877        assert_eq!(
1878            changes.len(),
1879            1,
1880            "an empty-but-present `changes` map must not be treated as authoritative over a \
1881             populated documentChanges"
1882        );
1883        assert_eq!(changes[0].uri, uri_string);
1884        assert!(dropped.is_empty());
1885    }
1886
1887    /// #475: `RenameResult::dropped` must round-trip through the wire format
1888    /// used by MCP responses -- present with per-reason counts when something
1889    /// was withheld, and omitted entirely (not `"dropped":{}`) when nothing
1890    /// was, so existing clients that ignore unknown fields see no change.
1891    #[test]
1892    fn test_rename_result_dropped_field_serde_presence() {
1893        let clean = RenameResult {
1894            changes: vec![],
1895            dropped: DroppedEdits::default(),
1896            positions_degraded: false,
1897        };
1898        let clean_json = serde_json::to_value(&clean).unwrap();
1899        assert!(
1900            clean_json.get("dropped").is_none(),
1901            "an empty DroppedEdits must be omitted from the serialized result, not `dropped: {{}}`"
1902        );
1903
1904        let incomplete = RenameResult {
1905            changes: vec![],
1906            dropped: DroppedEdits {
1907                out_of_workspace: 1,
1908                unsupported_file_operation: 2,
1909                unsupported_snippet_edit: 0,
1910            },
1911            positions_degraded: false,
1912        };
1913        let incomplete_json = serde_json::to_value(&incomplete).unwrap();
1914        let dropped_json = incomplete_json
1915            .get("dropped")
1916            .expect("non-empty DroppedEdits must be serialized");
1917        assert_eq!(dropped_json["out_of_workspace"], 1);
1918        assert_eq!(dropped_json["unsupported_file_operation"], 2);
1919        assert!(
1920            dropped_json.get("unsupported_snippet_edit").is_none(),
1921            "a zero-valued reason must itself be omitted per-field"
1922        );
1923    }
1924
1925    #[tokio::test]
1926    async fn test_convert_code_action_with_command() {
1927        let lsp_action = lsp_types::CodeAction {
1928            title: "Run command".to_string(),
1929            kind: Some(lsp_types::CodeActionKind::Refactor),
1930            diagnostics: None,
1931            edit: None,
1932            command: Some(lsp_types::Command {
1933                title: "Execute refactor".to_string(),
1934                command: "refactor.extract".to_string(),
1935                arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
1936                tooltip: None,
1937            }),
1938            is_preferred: None,
1939            disabled: None,
1940            tags: None,
1941            data: None,
1942        };
1943
1944        let result = convert_code_action(lsp_action, &test_ctx(), &test_uri(), &[]).await;
1945        assert!(result.command.is_some());
1946        let cmd = result.command.unwrap();
1947        assert_eq!(cmd.title, "Execute refactor");
1948        assert_eq!(cmd.command, "refactor.extract");
1949        assert_eq!(cmd.arguments.len(), 2);
1950    }
1951
1952    /// End-to-end: `handle_code_actions` must surface
1953    /// `Error::WorkspaceIndexing` -- not an empty result -- while the routed
1954    /// server is still `Loading`, without reaching the fake LSP server.
1955    #[tokio::test(start_paused = true)]
1956    async fn test_handle_code_actions_returns_workspace_indexing_error_when_loading() {
1957        use std::sync::Arc;
1958
1959        use tempfile::TempDir;
1960        use tokio::sync::Mutex;
1961
1962        use crate::bridge::NotificationCache;
1963        use crate::config::ServerId;
1964
1965        let dir = TempDir::new().unwrap();
1966        let server_id = ServerId::from("rust");
1967        let caps = lsp_types::ServerCapabilities {
1968            code_action_provider: Some(lsp_types::CodeActionProvider::Bool(true)),
1969            ..Default::default()
1970        };
1971        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
1972
1973        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1974        cache.lock().await.observe_indexing_signal(
1975            &server_id,
1976            "experimental/serverStatus",
1977            Some(&serde_json::json!({"quiescent": false})),
1978        );
1979        let translator = translator.with_notification_cache(cache);
1980
1981        let path = dir.path().join("main.rs");
1982        fs::write(&path, "fn main() {}").unwrap();
1983
1984        let err = translator
1985            .handle_code_actions(
1986                path.to_string_lossy().to_string(),
1987                Position {
1988                    line: 1,
1989                    character: 1,
1990                },
1991                Position {
1992                    line: 1,
1993                    character: 10,
1994                },
1995                None,
1996            )
1997            .await
1998            .unwrap_err();
1999
2000        assert!(matches!(
2001            err,
2002            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
2003        ));
2004    }
2005
2006    /// Companion: when the cache reports `Ready`, `handle_code_actions` must
2007    /// dispatch normally.
2008    #[tokio::test]
2009    async fn test_handle_code_actions_dispatches_when_indexing_ready() {
2010        use std::sync::Arc;
2011
2012        use tempfile::TempDir;
2013        use tokio::io::BufReader;
2014        use tokio::sync::Mutex;
2015
2016        use crate::bridge::NotificationCache;
2017        use crate::config::ServerId;
2018
2019        let dir = TempDir::new().unwrap();
2020        let server_id = ServerId::from("rust");
2021        let caps = lsp_types::ServerCapabilities {
2022            code_action_provider: Some(lsp_types::CodeActionProvider::Bool(true)),
2023            ..Default::default()
2024        };
2025        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2026
2027        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2028        cache.lock().await.observe_indexing_signal(
2029            &server_id,
2030            "experimental/serverStatus",
2031            Some(&serde_json::json!({"quiescent": true})),
2032        );
2033        let translator = Arc::new(translator.with_notification_cache(cache));
2034
2035        let path = dir.path().join("main.rs");
2036        fs::write(&path, "fn main() {}").unwrap();
2037
2038        let handle = {
2039            let translator = Arc::clone(&translator);
2040            let path = path.to_string_lossy().to_string();
2041            tokio::spawn(async move {
2042                translator
2043                    .handle_code_actions(
2044                        path,
2045                        Position {
2046                            line: 1,
2047                            character: 1,
2048                        },
2049                        Position {
2050                            line: 1,
2051                            character: 10,
2052                        },
2053                        None,
2054                    )
2055                    .await
2056            })
2057        };
2058
2059        let mut wire = BufReader::new(&mut server.write_stdout);
2060        let opened = read_framed_message(&mut wire).await;
2061        assert_eq!(opened["method"], "textDocument/didOpen");
2062        let request = read_framed_message(&mut wire).await;
2063        assert_eq!(request["method"], "textDocument/codeAction");
2064
2065        write_response(
2066            &mut server.read_half_stdin,
2067            &request["id"],
2068            serde_json::json!([]),
2069        )
2070        .await;
2071
2072        let result = handle.await.unwrap().unwrap();
2073        assert!(result.actions.is_empty());
2074    }
2075
2076    /// #432: an action returned with `data` but no `edit`, from a server
2077    /// advertising `codeActionProvider.resolveProvider: true`, must trigger
2078    /// a `codeAction/resolve` follow-up whose edit ends up in the result.
2079    #[tokio::test]
2080    async fn test_handle_code_actions_resolves_deferred_edit_when_supported() {
2081        use std::sync::Arc;
2082
2083        use tempfile::TempDir;
2084        use tokio::io::BufReader;
2085        use tokio::sync::Mutex;
2086        use url::Url;
2087
2088        use crate::bridge::NotificationCache;
2089        use crate::config::ServerId;
2090
2091        let dir = TempDir::new().unwrap();
2092        let server_id = ServerId::from("rust");
2093        let caps = lsp_types::ServerCapabilities {
2094            code_action_provider: Some(lsp_types::CodeActionProvider::CodeActionOptions(
2095                lsp_types::CodeActionOptions {
2096                    resolve_provider: Some(true),
2097                    ..Default::default()
2098                },
2099            )),
2100            ..Default::default()
2101        };
2102        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2103
2104        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2105        cache.lock().await.observe_indexing_signal(
2106            &server_id,
2107            "experimental/serverStatus",
2108            Some(&serde_json::json!({"quiescent": true})),
2109        );
2110        let translator = Arc::new(translator.with_notification_cache(cache));
2111
2112        let file_path = dir.path().join("main.rs");
2113        fs::write(&file_path, "fn main() {}").unwrap();
2114        let file_uri = Url::from_file_path(&file_path).unwrap().to_string();
2115
2116        let handle = {
2117            let translator = Arc::clone(&translator);
2118            let path = file_path.to_str().unwrap().to_string();
2119            tokio::spawn(async move {
2120                translator
2121                    .handle_code_actions(
2122                        path,
2123                        Position {
2124                            line: 1,
2125                            character: 1,
2126                        },
2127                        Position {
2128                            line: 1,
2129                            character: 10,
2130                        },
2131                        None,
2132                    )
2133                    .await
2134            })
2135        };
2136
2137        let mut wire = BufReader::new(&mut server.write_stdout);
2138        let opened = read_framed_message(&mut wire).await;
2139        assert_eq!(opened["method"], "textDocument/didOpen");
2140        let request = read_framed_message(&mut wire).await;
2141        assert_eq!(request["method"], "textDocument/codeAction");
2142
2143        write_response(
2144            &mut server.read_half_stdin,
2145            &request["id"],
2146            serde_json::json!([{
2147                "title": "Add missing import",
2148                "kind": "quickfix",
2149                "data": {"id": 42},
2150            }]),
2151        )
2152        .await;
2153
2154        let resolve_request = read_framed_message(&mut wire).await;
2155        assert_eq!(resolve_request["method"], "codeAction/resolve");
2156        assert_eq!(resolve_request["params"]["title"], "Add missing import");
2157
2158        let mut changes_map = serde_json::Map::new();
2159        changes_map.insert(
2160            file_uri,
2161            serde_json::json!([{
2162                "range": {
2163                    "start": {"line": 0, "character": 0},
2164                    "end": {"line": 0, "character": 0}
2165                },
2166                "newText": "use std::fmt;\n",
2167            }]),
2168        );
2169
2170        write_response(
2171            &mut server.read_half_stdin,
2172            &resolve_request["id"],
2173            serde_json::json!({
2174                "title": "Add missing import",
2175                "kind": "quickfix",
2176                "data": {"id": 42},
2177                "edit": { "changes": changes_map }
2178            }),
2179        )
2180        .await;
2181
2182        let result = handle.await.unwrap().unwrap();
2183        assert_eq!(result.actions.len(), 1);
2184        let edit = result.actions[0]
2185            .edit
2186            .as_ref()
2187            .expect("edit must be populated by codeAction/resolve");
2188        assert_eq!(edit.changes.len(), 1);
2189        assert_eq!(edit.changes[0].edits[0].new_text, "use std::fmt;\n");
2190    }
2191
2192    /// #432 companion: a server that does not advertise
2193    /// `codeActionProvider.resolveProvider: true` must never receive a
2194    /// `codeAction/resolve` follow-up, even for an action with `data` but no
2195    /// `edit` -- the action is returned as-is, without an edit.
2196    #[tokio::test]
2197    async fn test_handle_code_actions_skips_resolve_when_not_supported() {
2198        use std::sync::Arc;
2199        use std::time::Duration;
2200
2201        use tempfile::TempDir;
2202        use tokio::io::BufReader;
2203        use tokio::sync::Mutex;
2204        use tokio::time::timeout;
2205
2206        use crate::bridge::NotificationCache;
2207        use crate::config::ServerId;
2208
2209        let dir = TempDir::new().unwrap();
2210        let server_id = ServerId::from("rust");
2211        let caps = lsp_types::ServerCapabilities {
2212            code_action_provider: Some(lsp_types::CodeActionProvider::Bool(true)),
2213            ..Default::default()
2214        };
2215        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2216
2217        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2218        cache.lock().await.observe_indexing_signal(
2219            &server_id,
2220            "experimental/serverStatus",
2221            Some(&serde_json::json!({"quiescent": true})),
2222        );
2223        let translator = Arc::new(translator.with_notification_cache(cache));
2224
2225        let file_path = dir.path().join("main.rs");
2226        fs::write(&file_path, "fn main() {}").unwrap();
2227
2228        let handle = {
2229            let translator = Arc::clone(&translator);
2230            let path = file_path.to_str().unwrap().to_string();
2231            tokio::spawn(async move {
2232                translator
2233                    .handle_code_actions(
2234                        path,
2235                        Position {
2236                            line: 1,
2237                            character: 1,
2238                        },
2239                        Position {
2240                            line: 1,
2241                            character: 10,
2242                        },
2243                        None,
2244                    )
2245                    .await
2246            })
2247        };
2248
2249        let mut wire = BufReader::new(&mut server.write_stdout);
2250        let opened = read_framed_message(&mut wire).await;
2251        assert_eq!(opened["method"], "textDocument/didOpen");
2252        let request = read_framed_message(&mut wire).await;
2253        assert_eq!(request["method"], "textDocument/codeAction");
2254
2255        write_response(
2256            &mut server.read_half_stdin,
2257            &request["id"],
2258            serde_json::json!([{
2259                "title": "Add missing import",
2260                "kind": "quickfix",
2261                "data": {"id": 42},
2262            }]),
2263        )
2264        .await;
2265
2266        // No resolve request must ever arrive.
2267        let no_more_requests =
2268            timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
2269        assert!(
2270            no_more_requests.is_err(),
2271            "codeAction/resolve must not be sent when resolveProvider is unset"
2272        );
2273
2274        let result = handle.await.unwrap().unwrap();
2275        assert_eq!(result.actions.len(), 1);
2276        assert!(result.actions[0].edit.is_none());
2277    }
2278
2279    /// #432: a `codeAction/resolve` request that comes back as a JSON-RPC
2280    /// error must not fail the whole `code_actions` call -- the action is
2281    /// still returned, just without an edit (`resolve_code_action`'s `Err`
2282    /// fallback).
2283    #[tokio::test]
2284    async fn test_handle_code_actions_falls_back_when_resolve_errors() {
2285        use std::sync::Arc;
2286
2287        use tempfile::TempDir;
2288        use tokio::io::BufReader;
2289        use tokio::sync::Mutex;
2290
2291        use crate::bridge::NotificationCache;
2292        use crate::config::ServerId;
2293
2294        let dir = TempDir::new().unwrap();
2295        let server_id = ServerId::from("rust");
2296        let caps = lsp_types::ServerCapabilities {
2297            code_action_provider: Some(lsp_types::CodeActionProvider::CodeActionOptions(
2298                lsp_types::CodeActionOptions {
2299                    resolve_provider: Some(true),
2300                    ..Default::default()
2301                },
2302            )),
2303            ..Default::default()
2304        };
2305        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2306
2307        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2308        cache.lock().await.observe_indexing_signal(
2309            &server_id,
2310            "experimental/serverStatus",
2311            Some(&serde_json::json!({"quiescent": true})),
2312        );
2313        let translator = Arc::new(translator.with_notification_cache(cache));
2314
2315        let file_path = dir.path().join("main.rs");
2316        fs::write(&file_path, "fn main() {}").unwrap();
2317
2318        let handle = {
2319            let translator = Arc::clone(&translator);
2320            let path = file_path.to_str().unwrap().to_string();
2321            tokio::spawn(async move {
2322                translator
2323                    .handle_code_actions(
2324                        path,
2325                        Position {
2326                            line: 1,
2327                            character: 1,
2328                        },
2329                        Position {
2330                            line: 1,
2331                            character: 10,
2332                        },
2333                        None,
2334                    )
2335                    .await
2336            })
2337        };
2338
2339        let mut wire = BufReader::new(&mut server.write_stdout);
2340        let opened = read_framed_message(&mut wire).await;
2341        assert_eq!(opened["method"], "textDocument/didOpen");
2342        let request = read_framed_message(&mut wire).await;
2343        assert_eq!(request["method"], "textDocument/codeAction");
2344
2345        write_response(
2346            &mut server.read_half_stdin,
2347            &request["id"],
2348            serde_json::json!([{
2349                "title": "Add missing import",
2350                "kind": "quickfix",
2351                "data": {"id": 42},
2352            }]),
2353        )
2354        .await;
2355
2356        let resolve_request = read_framed_message(&mut wire).await;
2357        assert_eq!(resolve_request["method"], "codeAction/resolve");
2358
2359        write_error_response(
2360            &mut server.read_half_stdin,
2361            &resolve_request["id"],
2362            -32603,
2363            "internal error",
2364        )
2365        .await;
2366
2367        let result = handle.await.unwrap().unwrap();
2368        assert_eq!(result.actions.len(), 1);
2369        assert_eq!(result.actions[0].title, "Add missing import");
2370        assert!(
2371            result.actions[0].edit.is_none(),
2372            "a resolve error must not propagate, only leave the edit unset"
2373        );
2374    }
2375
2376    /// #432 companion: an action already carrying `edit: Some(_)` must never
2377    /// be re-resolved, even when it also carries `data: Some(_)` against a
2378    /// `resolveProvider: true` server.
2379    #[tokio::test]
2380    async fn test_handle_code_actions_skips_resolve_when_edit_already_present() {
2381        use std::sync::Arc;
2382        use std::time::Duration;
2383
2384        use tempfile::TempDir;
2385        use tokio::io::BufReader;
2386        use tokio::sync::Mutex;
2387        use tokio::time::timeout;
2388        use url::Url;
2389
2390        use crate::bridge::NotificationCache;
2391        use crate::config::ServerId;
2392
2393        let dir = TempDir::new().unwrap();
2394        let server_id = ServerId::from("rust");
2395        let caps = lsp_types::ServerCapabilities {
2396            code_action_provider: Some(lsp_types::CodeActionProvider::CodeActionOptions(
2397                lsp_types::CodeActionOptions {
2398                    resolve_provider: Some(true),
2399                    ..Default::default()
2400                },
2401            )),
2402            ..Default::default()
2403        };
2404        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2405
2406        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2407        cache.lock().await.observe_indexing_signal(
2408            &server_id,
2409            "experimental/serverStatus",
2410            Some(&serde_json::json!({"quiescent": true})),
2411        );
2412        let translator = Arc::new(translator.with_notification_cache(cache));
2413
2414        let file_path = dir.path().join("main.rs");
2415        fs::write(&file_path, "fn main() {}").unwrap();
2416        let file_uri = Url::from_file_path(&file_path).unwrap().to_string();
2417
2418        let handle = {
2419            let translator = Arc::clone(&translator);
2420            let path = file_path.to_str().unwrap().to_string();
2421            tokio::spawn(async move {
2422                translator
2423                    .handle_code_actions(
2424                        path,
2425                        Position {
2426                            line: 1,
2427                            character: 1,
2428                        },
2429                        Position {
2430                            line: 1,
2431                            character: 10,
2432                        },
2433                        None,
2434                    )
2435                    .await
2436            })
2437        };
2438
2439        let mut wire = BufReader::new(&mut server.write_stdout);
2440        let opened = read_framed_message(&mut wire).await;
2441        assert_eq!(opened["method"], "textDocument/didOpen");
2442        let request = read_framed_message(&mut wire).await;
2443        assert_eq!(request["method"], "textDocument/codeAction");
2444
2445        let mut changes_map = serde_json::Map::new();
2446        changes_map.insert(
2447            file_uri,
2448            serde_json::json!([{
2449                "range": {
2450                    "start": {"line": 0, "character": 0},
2451                    "end": {"line": 0, "character": 0}
2452                },
2453                "newText": "use std::fmt;\n",
2454            }]),
2455        );
2456
2457        write_response(
2458            &mut server.read_half_stdin,
2459            &request["id"],
2460            serde_json::json!([{
2461                "title": "Add missing import",
2462                "kind": "quickfix",
2463                "data": {"id": 42},
2464                "edit": { "changes": changes_map },
2465            }]),
2466        )
2467        .await;
2468
2469        // No resolve request must ever arrive: the action already has an edit.
2470        let no_more_requests =
2471            timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
2472        assert!(
2473            no_more_requests.is_err(),
2474            "codeAction/resolve must not be sent when edit is already present"
2475        );
2476
2477        let result = handle.await.unwrap().unwrap();
2478        assert_eq!(result.actions.len(), 1);
2479        let edit = result.actions[0]
2480            .edit
2481            .as_ref()
2482            .expect("original edit must be preserved");
2483        assert_eq!(edit.changes[0].edits[0].new_text, "use std::fmt;\n");
2484    }
2485
2486    /// #432 companion: an action with `data: None` must never be resolved,
2487    /// even against a `resolveProvider: true` server -- per LSP 3.16, `data`
2488    /// presence is what signals an action is resolvable.
2489    #[tokio::test]
2490    async fn test_handle_code_actions_skips_resolve_when_data_absent() {
2491        use std::sync::Arc;
2492        use std::time::Duration;
2493
2494        use tempfile::TempDir;
2495        use tokio::io::BufReader;
2496        use tokio::sync::Mutex;
2497        use tokio::time::timeout;
2498
2499        use crate::bridge::NotificationCache;
2500        use crate::config::ServerId;
2501
2502        let dir = TempDir::new().unwrap();
2503        let server_id = ServerId::from("rust");
2504        let caps = lsp_types::ServerCapabilities {
2505            code_action_provider: Some(lsp_types::CodeActionProvider::CodeActionOptions(
2506                lsp_types::CodeActionOptions {
2507                    resolve_provider: Some(true),
2508                    ..Default::default()
2509                },
2510            )),
2511            ..Default::default()
2512        };
2513        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2514
2515        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2516        cache.lock().await.observe_indexing_signal(
2517            &server_id,
2518            "experimental/serverStatus",
2519            Some(&serde_json::json!({"quiescent": true})),
2520        );
2521        let translator = Arc::new(translator.with_notification_cache(cache));
2522
2523        let file_path = dir.path().join("main.rs");
2524        fs::write(&file_path, "fn main() {}").unwrap();
2525
2526        let handle = {
2527            let translator = Arc::clone(&translator);
2528            let path = file_path.to_str().unwrap().to_string();
2529            tokio::spawn(async move {
2530                translator
2531                    .handle_code_actions(
2532                        path,
2533                        Position {
2534                            line: 1,
2535                            character: 1,
2536                        },
2537                        Position {
2538                            line: 1,
2539                            character: 10,
2540                        },
2541                        None,
2542                    )
2543                    .await
2544            })
2545        };
2546
2547        let mut wire = BufReader::new(&mut server.write_stdout);
2548        let opened = read_framed_message(&mut wire).await;
2549        assert_eq!(opened["method"], "textDocument/didOpen");
2550        let request = read_framed_message(&mut wire).await;
2551        assert_eq!(request["method"], "textDocument/codeAction");
2552
2553        write_response(
2554            &mut server.read_half_stdin,
2555            &request["id"],
2556            serde_json::json!([{
2557                "title": "Organize imports",
2558                "kind": "source.organizeImports",
2559            }]),
2560        )
2561        .await;
2562
2563        // No resolve request must ever arrive: the action has no `data`.
2564        let no_more_requests =
2565            timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
2566        assert!(
2567            no_more_requests.is_err(),
2568            "codeAction/resolve must not be sent when data is absent"
2569        );
2570
2571        let result = handle.await.unwrap().unwrap();
2572        assert_eq!(result.actions.len(), 1);
2573        assert!(result.actions[0].edit.is_none());
2574    }
2575
2576    /// `handle_rename` needs the same whole-workspace reference index as
2577    /// `get_references`; it must surface `Error::WorkspaceIndexing` -- not
2578    /// attempt a rename against a partial index -- while the routed server
2579    /// is still `Loading`, without reaching the fake LSP server.
2580    #[tokio::test(start_paused = true)]
2581    async fn test_handle_rename_returns_workspace_indexing_error_when_loading() {
2582        use std::sync::Arc;
2583
2584        use tempfile::TempDir;
2585        use tokio::sync::Mutex;
2586
2587        use crate::bridge::NotificationCache;
2588        use crate::config::ServerId;
2589
2590        let dir = TempDir::new().unwrap();
2591        let server_id = ServerId::from("rust");
2592        let caps = lsp_types::ServerCapabilities {
2593            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
2594            ..Default::default()
2595        };
2596        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
2597
2598        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2599        cache.lock().await.observe_indexing_signal(
2600            &server_id,
2601            "experimental/serverStatus",
2602            Some(&serde_json::json!({"quiescent": false})),
2603        );
2604        let translator = translator.with_notification_cache(cache);
2605
2606        let path = dir.path().join("main.rs");
2607        fs::write(&path, "fn old_name() {}").unwrap();
2608
2609        let err = translator
2610            .handle_rename(
2611                path.to_string_lossy().to_string(),
2612                Position {
2613                    line: 1,
2614                    character: 4,
2615                },
2616                "new_name".to_string(),
2617            )
2618            .await
2619            .unwrap_err();
2620
2621        assert!(matches!(
2622            err,
2623            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
2624        ));
2625    }
2626
2627    /// Companion: when the cache reports `Ready`, `handle_rename` must
2628    /// dispatch normally.
2629    #[tokio::test]
2630    async fn test_handle_rename_dispatches_when_indexing_ready() {
2631        use std::sync::Arc;
2632
2633        use tempfile::TempDir;
2634        use tokio::io::BufReader;
2635        use tokio::sync::Mutex;
2636
2637        use crate::bridge::NotificationCache;
2638        use crate::config::ServerId;
2639
2640        let dir = TempDir::new().unwrap();
2641        let server_id = ServerId::from("rust");
2642        let caps = lsp_types::ServerCapabilities {
2643            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
2644            ..Default::default()
2645        };
2646        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
2647
2648        let cache = Arc::new(Mutex::new(NotificationCache::new()));
2649        cache.lock().await.observe_indexing_signal(
2650            &server_id,
2651            "experimental/serverStatus",
2652            Some(&serde_json::json!({"quiescent": true})),
2653        );
2654        let translator = Arc::new(translator.with_notification_cache(cache));
2655
2656        let path = dir.path().join("main.rs");
2657        fs::write(&path, "fn old_name() {}").unwrap();
2658
2659        let handle = {
2660            let translator = Arc::clone(&translator);
2661            let path = path.to_string_lossy().to_string();
2662            tokio::spawn(async move {
2663                translator
2664                    .handle_rename(
2665                        path,
2666                        Position {
2667                            line: 1,
2668                            character: 4,
2669                        },
2670                        "new_name".to_string(),
2671                    )
2672                    .await
2673            })
2674        };
2675
2676        let mut wire = BufReader::new(&mut server.write_stdout);
2677        let opened = read_framed_message(&mut wire).await;
2678        assert_eq!(opened["method"], "textDocument/didOpen");
2679        let request = read_framed_message(&mut wire).await;
2680        assert_eq!(request["method"], "textDocument/rename");
2681
2682        write_response(
2683            &mut server.read_half_stdin,
2684            &request["id"],
2685            serde_json::Value::Null,
2686        )
2687        .await;
2688
2689        let result = handle.await.unwrap().unwrap();
2690        assert!(result.changes.is_empty());
2691    }
2692}