1use 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
29async 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
90struct 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 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 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 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 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
253const MAX_CODE_ACTION_RESOLVES: usize = 20;
265
266async 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
322async 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 tracing::warn!(
383 %server_id,
384 error = %join_err,
385 "codeAction/resolve task panicked, returning action without edit"
386 );
387 }
388 }
389 }
390}
391
392async 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
445async 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
461fn 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
473fn 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 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 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 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 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 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}