Skip to main content

mcpls_core/bridge/translator/
edits.rs

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