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