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