1use lsp_types::{
4 DocumentFormattingParams, FormattingOptions, PartialResultParams,
5 RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams,
6 WorkDoneProgressParams,
7};
8
9use super::Translator;
10use super::diagnostics::diagnostic_to_mcp;
11use super::dto::{
12 CodeAction, CodeActionsResult, CommandDescription, DocumentChanges, FormatDocumentResult,
13 Position, RenameResult, TextEdit, WorkspaceEditDescription,
14};
15use super::encoding_ctx::EncodingCtx;
16use super::routing::{MAX_POSITION_VALUE, MAX_RANGE_LINES};
17use crate::config::ToolKind;
18use crate::error::{Error, Result};
19
20fn validate_code_action_params(
23 start: Position,
24 end: Position,
25 kind_filter: Option<&str>,
26) -> Result<()> {
27 const VALID_ACTION_KINDS: &[&str] = &[
28 "quickfix",
29 "refactor",
30 "refactor.extract",
31 "refactor.inline",
32 "refactor.rewrite",
33 "source",
34 "source.organizeImports",
35 ];
36
37 let Position {
38 line: start_line,
39 character: start_character,
40 } = start;
41 let Position {
42 line: end_line,
43 character: end_character,
44 } = end;
45
46 if let Some(kind) = kind_filter
47 && !VALID_ACTION_KINDS
48 .iter()
49 .any(|k| k.eq_ignore_ascii_case(kind))
50 {
51 return Err(Error::InvalidToolParams(format!(
52 "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
53 )));
54 }
55
56 if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
57 return Err(Error::InvalidToolParams(
58 "Line and character positions must be >= 1".to_string(),
59 ));
60 }
61
62 if start_line > MAX_POSITION_VALUE
63 || start_character > MAX_POSITION_VALUE
64 || end_line > MAX_POSITION_VALUE
65 || end_character > MAX_POSITION_VALUE
66 {
67 return Err(Error::InvalidToolParams(format!(
68 "Position values must be <= {MAX_POSITION_VALUE}"
69 )));
70 }
71
72 if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
73 return Err(Error::InvalidToolParams(format!(
74 "Range size must be <= {MAX_RANGE_LINES} lines"
75 )));
76 }
77
78 if start_line > end_line || (start_line == end_line && start_character > end_character) {
79 return Err(Error::InvalidToolParams(
80 "Start position must be before or equal to end position".to_string(),
81 ));
82 }
83
84 Ok(())
85}
86
87pub(super) const MAX_NEW_NAME_LENGTH: usize = 1_000;
95
96fn validate_rename_params(new_name: &str) -> Result<()> {
98 if new_name.len() > MAX_NEW_NAME_LENGTH {
99 return Err(Error::InvalidToolParams(format!(
100 "new_name too long: {} bytes (max {MAX_NEW_NAME_LENGTH})",
101 new_name.len()
102 )));
103 }
104 Ok(())
105}
106
107async fn convert_code_action(
111 action: lsp_types::CodeAction,
112 ctx: &EncodingCtx,
113 uri: &lsp_types::Uri,
114) -> CodeAction {
115 let diagnostics = match action.diagnostics {
116 Some(diags) => {
117 let mut result = Vec::with_capacity(diags.len());
118 for d in &diags {
119 result.push(diagnostic_to_mcp(d, ctx, uri).await);
120 }
121 result
122 }
123 None => Vec::new(),
124 };
125
126 let edit = match action.edit {
127 Some(edit) => {
128 let changes = match edit.changes {
129 Some(changes_map) => {
130 let mut result = Vec::with_capacity(changes_map.len());
131 for (uri, edits) in changes_map {
132 let mut text_edits = Vec::with_capacity(edits.len());
133 for e in edits {
134 text_edits.push(TextEdit {
135 range: ctx.normalize_range(&uri, e.range).await,
136 new_text: e.new_text,
137 });
138 }
139 result.push(DocumentChanges {
140 uri: uri.to_string(),
141 edits: text_edits,
142 });
143 }
144 result
145 }
146 None => Vec::new(),
147 };
148 Some(WorkspaceEditDescription { changes })
149 }
150 None => None,
151 };
152
153 let command = action.command.map(|cmd| {
154 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
155 CommandDescription {
156 title: cmd.title,
157 command: cmd.command,
158 arguments,
159 }
160 });
161
162 CodeAction {
163 title: action.title,
164 kind: action.kind.map(String::from),
165 diagnostics,
166 edit,
167 command,
168 is_preferred: action.is_preferred.unwrap_or(false),
169 }
170}
171
172impl Translator {
173 pub async fn handle_rename(
181 &self,
182 file_path: String,
183 position: Position,
184 new_name: String,
185 ) -> Result<RenameResult> {
186 let Position { line, character } = position;
187 validate_rename_params(&new_name)?;
188
189 let (server_id, client, uri) = self
190 .prepare_gated_document(&file_path, ToolKind::Rename, "renameProvider", |caps| {
191 matches!(
192 caps.rename_provider,
193 Some(
194 lsp_types::RenameProvider::Bool(true)
195 | lsp_types::RenameProvider::RenameOptions(_)
196 )
197 )
198 })
199 .await?;
200 let ctx = self.encoding_ctx(&server_id);
201 let lsp_position = ctx.to_lsp(&uri, line, character).await;
202
203 let params = LspRenameParams {
204 text_document_position_params: TextDocumentPositionParams {
205 text_document: TextDocumentIdentifier { uri },
206 position: lsp_position,
207 },
208 new_name,
209 work_done_progress_params: WorkDoneProgressParams::default(),
210 };
211
212 let response = client
213 .request_typed::<lsp_types::RenameRequest>(params, client.request_timeout())
214 .await?;
215
216 let changes = if let Some(edit) = response {
217 let mut result_changes = Vec::new();
218
219 if let Some(changes_map) = edit.changes {
221 for (uri, edits) in changes_map {
222 let mut text_edits = Vec::with_capacity(edits.len());
223 for e in edits {
224 text_edits.push(TextEdit {
225 range: ctx.normalize_range(&uri, e.range).await,
226 new_text: e.new_text,
227 });
228 }
229 result_changes.push(DocumentChanges {
230 uri: uri.to_string(),
231 edits: text_edits,
232 });
233 }
234 }
235
236 if result_changes.is_empty() {
238 let text_doc_edits: Vec<lsp_types::TextDocumentEdit> = edit
239 .document_changes
240 .unwrap_or_default()
241 .into_iter()
242 .filter_map(|change| match change {
243 lsp_types::DocumentChange::TextDocumentEdit(e) => Some(e),
244 lsp_types::DocumentChange::CreateFile(_)
245 | lsp_types::DocumentChange::RenameFile(_)
246 | lsp_types::DocumentChange::DeleteFile(_) => None,
247 })
248 .collect();
249 for tde in text_doc_edits {
250 let edit_uri = &tde.text_document.text_document_identifier.uri;
251 let mut text_edits = Vec::with_capacity(tde.edits.len());
252 for one_of in tde.edits {
253 let text_edit = match one_of {
254 lsp_types::Edit::TextEdit(te) => TextEdit {
255 range: ctx.normalize_range(edit_uri, te.range).await,
256 new_text: te.new_text,
257 },
258 lsp_types::Edit::AnnotatedTextEdit(ate) => TextEdit {
259 range: ctx.normalize_range(edit_uri, ate.text_edit.range).await,
260 new_text: ate.text_edit.new_text,
261 },
262 lsp_types::Edit::SnippetTextEdit(_) => continue,
273 };
274 text_edits.push(text_edit);
275 }
276 result_changes.push(DocumentChanges {
277 uri: edit_uri.to_string(),
278 edits: text_edits,
279 });
280 }
281 }
282
283 result_changes
284 } else {
285 vec![]
286 };
287
288 Ok(RenameResult { changes })
289 }
290
291 pub async fn handle_format_document(
298 &self,
299 file_path: String,
300 tab_size: u32,
301 insert_spaces: bool,
302 ) -> Result<FormatDocumentResult> {
303 let (server_id, client, uri) = self
304 .prepare_gated_document(
305 &file_path,
306 ToolKind::FormatDocument,
307 "documentFormattingProvider",
308 |caps| {
309 matches!(
310 caps.document_formatting_provider,
311 Some(
312 lsp_types::DocumentFormattingProvider::Bool(true)
313 | lsp_types::DocumentFormattingProvider::DocumentFormattingOptions(
314 _
315 )
316 )
317 )
318 },
319 )
320 .await?;
321 let ctx = self.encoding_ctx(&server_id);
322 let response_uri = uri.clone();
323
324 let params = DocumentFormattingParams {
325 text_document: TextDocumentIdentifier { uri },
326 options: FormattingOptions {
327 tab_size,
328 insert_spaces,
329 ..Default::default()
330 },
331 work_done_progress_params: WorkDoneProgressParams::default(),
332 };
333
334 let response = client
335 .request_typed::<lsp_types::DocumentFormattingRequest>(params, client.request_timeout())
336 .await?;
337
338 let edits = response.unwrap_or_default();
339
340 let mut result_edits = Vec::with_capacity(edits.len());
341 for edit in edits {
342 result_edits.push(TextEdit {
343 range: ctx.normalize_range(&response_uri, edit.range).await,
344 new_text: edit.new_text,
345 });
346 }
347 let result = FormatDocumentResult {
348 edits: result_edits,
349 };
350
351 Ok(result)
352 }
353
354 pub async fn handle_code_actions(
361 &self,
362 file_path: String,
363 start: Position,
364 end: Position,
365 kind_filter: Option<String>,
366 ) -> Result<CodeActionsResult> {
367 validate_code_action_params(start, end, kind_filter.as_deref())?;
368
369 let (server_id, client, uri) = self
370 .prepare_gated_document(
371 &file_path,
372 ToolKind::CodeActions,
373 "codeActionProvider",
374 |caps| {
375 matches!(
376 caps.code_action_provider,
377 Some(
378 lsp_types::CodeActionProvider::Bool(true)
379 | lsp_types::CodeActionProvider::CodeActionOptions(_)
380 )
381 )
382 },
383 )
384 .await?;
385 let ctx = self.encoding_ctx(&server_id);
386 let response_uri = uri.clone();
387
388 let range = lsp_types::Range {
389 start: ctx.to_lsp(&uri, start.line, start.character).await,
390 end: ctx.to_lsp(&uri, end.line, end.character).await,
391 };
392
393 let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
395
396 let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
401
402 let params = lsp_types::CodeActionParams {
403 text_document: TextDocumentIdentifier { uri },
404 range,
405 context: lsp_types::CodeActionContext {
406 diagnostics: context_diagnostics,
407 only,
408 trigger_kind: Some(lsp_types::CodeActionTriggerKind::Invoked),
409 },
410 work_done_progress_params: WorkDoneProgressParams::default(),
411 partial_result_params: PartialResultParams::default(),
412 };
413
414 let response = client
415 .request_typed::<lsp_types::CodeActionRequest>(params, client.request_timeout())
416 .await?;
417 let response_vec = response.unwrap_or_default();
418 let mut actions = Vec::with_capacity(response_vec.len());
419
420 for action_or_command in response_vec {
421 let action = match action_or_command {
422 lsp_types::CodeActionResponse::CodeAction(action) => {
423 convert_code_action(action, &ctx, &response_uri).await
424 }
425 lsp_types::CodeActionResponse::Command(cmd) => {
426 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
427 CodeAction {
428 title: cmd.title.clone(),
429 kind: None,
430 diagnostics: Vec::new(),
431 edit: None,
432 command: Some(CommandDescription {
433 title: cmd.title,
434 command: cmd.command,
435 arguments,
436 }),
437 is_preferred: false,
438 }
439 }
440 };
441 actions.push(action);
442 }
443
444 Ok(CodeActionsResult { actions })
445 }
446}
447
448#[cfg(test)]
449#[allow(clippy::unwrap_used, clippy::expect_used)]
450mod tests {
451 use std::fs;
452
453 use super::*;
454 use crate::bridge::translator::dto::DiagnosticSeverity;
455 use crate::bridge::translator::testing::*;
456
457 #[tokio::test]
464 #[allow(clippy::literal_string_with_formatting_args)]
465 async fn test_handle_rename_drops_snippet_text_edit_and_keeps_plain_edits() {
466 use std::sync::Arc;
467 use std::time::Duration;
468
469 use tempfile::TempDir;
470 use tokio::io::BufReader;
471 use tokio::time::timeout;
472 use url::Url;
473
474 use crate::config::ServerId;
475
476 let dir = TempDir::new().unwrap();
477 let server_id = ServerId::from("rust");
478 let caps = lsp_types::ServerCapabilities {
479 rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
480 ..Default::default()
481 };
482 let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
483
484 let file_path = dir.path().join("main.rs");
485 fs::write(&file_path, "fn old_name() {}").unwrap();
486
487 let translator = Arc::new(translator);
488 let handle = {
489 let translator = Arc::clone(&translator);
490 let path = file_path.to_str().unwrap().to_string();
491 tokio::spawn(async move {
492 translator
493 .handle_rename(
494 path,
495 Position {
496 line: 1,
497 character: 4,
498 },
499 "new_name".to_string(),
500 )
501 .await
502 })
503 };
504
505 let file_uri = Url::from_file_path(&file_path).unwrap().to_string();
506 let mut wire = BufReader::new(&mut server.write_stdout);
507 let opened = read_framed_message(&mut wire).await;
508 assert_eq!(opened["method"], "textDocument/didOpen");
509 let request = read_framed_message(&mut wire).await;
510 assert_eq!(request["method"], "textDocument/rename");
511
512 write_response(
513 &mut server.read_half_stdin,
514 &request["id"],
515 serde_json::json!({
516 "documentChanges": [
517 {
518 "textDocument": { "uri": file_uri, "version": 1 },
519 "edits": [
520 {
521 "range": {
522 "start": {"line": 0, "character": 3},
523 "end": {"line": 0, "character": 11}
524 },
525 "newText": "new_name"
526 },
527 {
528 "range": {
529 "start": {"line": 0, "character": 0},
530 "end": {"line": 0, "character": 0}
531 },
532 "snippet": { "value": "${1:comment}\n", "kind": "snippet" }
533 }
534 ]
535 }
536 ]
537 }),
538 )
539 .await;
540
541 let result = timeout(Duration::from_secs(2), handle)
542 .await
543 .expect("handler call should not hang")
544 .unwrap()
545 .unwrap();
546
547 assert_eq!(result.changes.len(), 1);
548 assert_eq!(
549 result.changes[0].edits.len(),
550 1,
551 "the snippet edit must be dropped, not converted to literal text"
552 );
553 assert_eq!(result.changes[0].edits[0].new_text, "new_name");
554 assert!(
555 !result.changes[0]
556 .edits
557 .iter()
558 .any(|e| e.new_text.contains("${1:comment}")),
559 "snippet placeholder syntax must never appear as literal replacement text"
560 );
561 }
562
563 #[test]
566 fn test_validate_rename_params_rejects_oversized_new_name() {
567 let new_name = "a".repeat(MAX_NEW_NAME_LENGTH + 1);
568 let result = validate_rename_params(&new_name);
569 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
570 }
571
572 #[test]
573 fn test_validate_rename_params_accepts_name_at_exact_limit() {
574 let new_name = "a".repeat(MAX_NEW_NAME_LENGTH);
575 assert!(validate_rename_params(&new_name).is_ok());
576 }
577
578 #[test]
579 fn test_validate_rename_params_accepts_typical_identifier() {
580 assert!(validate_rename_params("my_variable").is_ok());
581 }
582
583 #[test]
587 fn test_validate_rename_params_accepts_empty_string() {
588 assert!(validate_rename_params("").is_ok());
589 }
590
591 #[tokio::test]
592 async fn test_handle_code_actions_invalid_kind() {
593 let translator = Translator::new();
594 let result = translator
595 .handle_code_actions(
596 "/tmp/test.rs".to_string(),
597 Position {
598 line: 1,
599 character: 1,
600 },
601 Position {
602 line: 1,
603 character: 10,
604 },
605 Some("invalid_kind".to_string()),
606 )
607 .await;
608 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
609 }
610
611 #[tokio::test]
612 async fn test_handle_code_actions_valid_kind_quickfix() {
613 use tempfile::TempDir;
614
615 let translator = Translator::new();
616 let temp_dir = TempDir::new().unwrap();
617 let test_file = temp_dir.path().join("test.rs");
618 fs::write(&test_file, "fn main() {}").unwrap();
619
620 let result = translator
621 .handle_code_actions(
622 test_file.to_str().unwrap().to_string(),
623 Position {
624 line: 1,
625 character: 1,
626 },
627 Position {
628 line: 1,
629 character: 10,
630 },
631 Some("quickfix".to_string()),
632 )
633 .await;
634 assert!(result.is_err());
636 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
637 }
638
639 #[tokio::test]
640 async fn test_handle_code_actions_valid_kind_refactor() {
641 use tempfile::TempDir;
642
643 let translator = Translator::new();
644 let temp_dir = TempDir::new().unwrap();
645 let test_file = temp_dir.path().join("test.rs");
646 fs::write(&test_file, "fn main() {}").unwrap();
647
648 let result = translator
649 .handle_code_actions(
650 test_file.to_str().unwrap().to_string(),
651 Position {
652 line: 1,
653 character: 1,
654 },
655 Position {
656 line: 1,
657 character: 10,
658 },
659 Some("refactor".to_string()),
660 )
661 .await;
662 assert!(result.is_err());
663 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
664 }
665
666 #[tokio::test]
667 async fn test_handle_code_actions_valid_kind_refactor_extract() {
668 use tempfile::TempDir;
669
670 let translator = Translator::new();
671 let temp_dir = TempDir::new().unwrap();
672 let test_file = temp_dir.path().join("test.rs");
673 fs::write(&test_file, "fn main() {}").unwrap();
674
675 let result = translator
676 .handle_code_actions(
677 test_file.to_str().unwrap().to_string(),
678 Position {
679 line: 1,
680 character: 1,
681 },
682 Position {
683 line: 1,
684 character: 10,
685 },
686 Some("refactor.extract".to_string()),
687 )
688 .await;
689 assert!(result.is_err());
690 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
691 }
692
693 #[tokio::test]
694 async fn test_handle_code_actions_valid_kind_source() {
695 use tempfile::TempDir;
696
697 let translator = Translator::new();
698 let temp_dir = TempDir::new().unwrap();
699 let test_file = temp_dir.path().join("test.rs");
700 fs::write(&test_file, "fn main() {}").unwrap();
701
702 let result = translator
703 .handle_code_actions(
704 test_file.to_str().unwrap().to_string(),
705 Position {
706 line: 1,
707 character: 1,
708 },
709 Position {
710 line: 1,
711 character: 10,
712 },
713 Some("source.organizeImports".to_string()),
714 )
715 .await;
716 assert!(result.is_err());
717 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
718 }
719
720 #[tokio::test]
721 async fn test_handle_code_actions_invalid_range_zero() {
722 let translator = Translator::new();
723 let result = translator
724 .handle_code_actions(
725 "/tmp/test.rs".to_string(),
726 Position {
727 line: 0,
728 character: 1,
729 },
730 Position {
731 line: 1,
732 character: 10,
733 },
734 None,
735 )
736 .await;
737 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
738 }
739
740 #[tokio::test]
741 async fn test_handle_code_actions_invalid_range_order() {
742 let translator = Translator::new();
743 let result = translator
744 .handle_code_actions(
745 "/tmp/test.rs".to_string(),
746 Position {
747 line: 10,
748 character: 5,
749 },
750 Position {
751 line: 5,
752 character: 1,
753 },
754 None,
755 )
756 .await;
757 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
758 }
759
760 #[tokio::test]
761 async fn test_handle_code_actions_empty_range() {
762 use tempfile::TempDir;
763
764 let translator = Translator::new();
765 let temp_dir = TempDir::new().unwrap();
766 let test_file = temp_dir.path().join("test.rs");
767 fs::write(&test_file, "fn main() {}").unwrap();
768
769 let result = translator
771 .handle_code_actions(
772 test_file.to_str().unwrap().to_string(),
773 Position {
774 line: 1,
775 character: 5,
776 },
777 Position {
778 line: 1,
779 character: 5,
780 },
781 None,
782 )
783 .await;
784 assert!(result.is_err());
786 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
787 }
788
789 #[tokio::test]
790 async fn test_convert_code_action_minimal() {
791 let lsp_action = lsp_types::CodeAction {
792 title: "Fix issue".to_string(),
793 kind: None,
794 diagnostics: None,
795 edit: None,
796 command: None,
797 is_preferred: None,
798 disabled: None,
799 tags: None,
800 data: None,
801 };
802
803 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
804 assert_eq!(result.title, "Fix issue");
805 assert!(result.kind.is_none());
806 assert!(result.diagnostics.is_empty());
807 assert!(result.edit.is_none());
808 assert!(result.command.is_none());
809 assert!(!result.is_preferred);
810 }
811
812 #[tokio::test]
813 #[allow(clippy::too_many_lines)]
814 async fn test_convert_code_action_with_diagnostics_all_severities() {
815 let lsp_diagnostics = vec![
816 lsp_types::Diagnostic {
817 range: lsp_types::Range {
818 start: lsp_types::Position {
819 line: 0,
820 character: 0,
821 },
822 end: lsp_types::Position {
823 line: 0,
824 character: 5,
825 },
826 },
827 severity: Some(lsp_types::DiagnosticSeverity::Error),
828 message: "Error message".to_string().into(),
829 code: Some(lsp_types::Code::Int(1)),
830 source: None,
831 code_description: None,
832 related_information: None,
833 tags: None,
834 data: None,
835 },
836 lsp_types::Diagnostic {
837 range: lsp_types::Range {
838 start: lsp_types::Position {
839 line: 1,
840 character: 0,
841 },
842 end: lsp_types::Position {
843 line: 1,
844 character: 5,
845 },
846 },
847 severity: Some(lsp_types::DiagnosticSeverity::Warning),
848 message: "Warning message".to_string().into(),
849 code: Some(lsp_types::Code::String("W001".to_string())),
850 source: None,
851 code_description: None,
852 related_information: None,
853 tags: None,
854 data: None,
855 },
856 lsp_types::Diagnostic {
857 range: lsp_types::Range {
858 start: lsp_types::Position {
859 line: 2,
860 character: 0,
861 },
862 end: lsp_types::Position {
863 line: 2,
864 character: 5,
865 },
866 },
867 severity: Some(lsp_types::DiagnosticSeverity::Information),
868 message: "Info message".to_string().into(),
869 code: None,
870 source: None,
871 code_description: None,
872 related_information: None,
873 tags: None,
874 data: None,
875 },
876 lsp_types::Diagnostic {
877 range: lsp_types::Range {
878 start: lsp_types::Position {
879 line: 3,
880 character: 0,
881 },
882 end: lsp_types::Position {
883 line: 3,
884 character: 5,
885 },
886 },
887 severity: Some(lsp_types::DiagnosticSeverity::Hint),
888 message: "Hint message".to_string().into(),
889 code: None,
890 source: None,
891 code_description: None,
892 related_information: None,
893 tags: None,
894 data: None,
895 },
896 ];
897
898 let lsp_action = lsp_types::CodeAction {
899 title: "Fix all issues".to_string(),
900 kind: Some(lsp_types::CodeActionKind::QuickFix),
901 diagnostics: Some(lsp_diagnostics),
902 edit: None,
903 command: None,
904 is_preferred: None,
905 disabled: None,
906 tags: None,
907 data: None,
908 };
909
910 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
911 assert_eq!(result.diagnostics.len(), 4);
912 assert!(matches!(
913 result.diagnostics[0].severity,
914 DiagnosticSeverity::Error
915 ));
916 assert!(matches!(
917 result.diagnostics[1].severity,
918 DiagnosticSeverity::Warning
919 ));
920 assert!(matches!(
921 result.diagnostics[2].severity,
922 DiagnosticSeverity::Information
923 ));
924 assert!(matches!(
925 result.diagnostics[3].severity,
926 DiagnosticSeverity::Hint
927 ));
928 assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
929 assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
930 }
931
932 #[tokio::test]
933 #[allow(clippy::mutable_key_type)]
934 async fn test_convert_code_action_with_workspace_edit() {
935 use std::collections::HashMap;
936
937 let uri = lsp_types::Uri::from("file:///test.rs");
938 let mut changes_map = HashMap::new();
939 changes_map.insert(
940 uri,
941 vec![lsp_types::TextEdit {
942 range: lsp_types::Range {
943 start: lsp_types::Position {
944 line: 0,
945 character: 0,
946 },
947 end: lsp_types::Position {
948 line: 0,
949 character: 5,
950 },
951 },
952 new_text: "fixed".to_string(),
953 }],
954 );
955
956 let lsp_action = lsp_types::CodeAction {
957 title: "Apply fix".to_string(),
958 kind: Some(lsp_types::CodeActionKind::QuickFix),
959 diagnostics: None,
960 edit: Some(lsp_types::WorkspaceEdit {
961 changes: Some(changes_map),
962 document_changes: None,
963 change_annotations: None,
964 }),
965 command: None,
966 is_preferred: Some(true),
967 disabled: None,
968 tags: None,
969 data: None,
970 };
971
972 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
973 assert!(result.edit.is_some());
974 let edit = result.edit.unwrap();
975 assert_eq!(edit.changes.len(), 1);
976 assert_eq!(edit.changes[0].uri, "file:///test.rs");
977 assert_eq!(edit.changes[0].edits.len(), 1);
978 assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
979 assert!(result.is_preferred);
980 }
981
982 #[tokio::test]
983 async fn test_convert_code_action_with_command() {
984 let lsp_action = lsp_types::CodeAction {
985 title: "Run command".to_string(),
986 kind: Some(lsp_types::CodeActionKind::Refactor),
987 diagnostics: None,
988 edit: None,
989 command: Some(lsp_types::Command {
990 title: "Execute refactor".to_string(),
991 command: "refactor.extract".to_string(),
992 arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
993 tooltip: None,
994 }),
995 is_preferred: None,
996 disabled: None,
997 tags: None,
998 data: None,
999 };
1000
1001 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
1002 assert!(result.command.is_some());
1003 let cmd = result.command.unwrap();
1004 assert_eq!(cmd.title, "Execute refactor");
1005 assert_eq!(cmd.command, "refactor.extract");
1006 assert_eq!(cmd.arguments.len(), 2);
1007 }
1008}