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