1use lsp_types::{
4 DocumentFormattingParams, FormattingOptions, PartialResultParams,
5 RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams,
6 WorkDoneProgressParams, WorkspaceEdit,
7};
8
9use super::Translator;
10use super::diagnostics::diagnostic_to_mcp;
11use super::dto::{
12 CodeAction, CodeActionsResult, CommandDescription, DocumentChanges, FormatDocumentResult,
13 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_line: u32,
24 start_character: u32,
25 end_line: u32,
26 end_character: u32,
27 kind_filter: Option<&str>,
28) -> Result<()> {
29 const VALID_ACTION_KINDS: &[&str] = &[
30 "quickfix",
31 "refactor",
32 "refactor.extract",
33 "refactor.inline",
34 "refactor.rewrite",
35 "source",
36 "source.organizeImports",
37 ];
38
39 if let Some(kind) = kind_filter
40 && !VALID_ACTION_KINDS
41 .iter()
42 .any(|k| k.eq_ignore_ascii_case(kind))
43 {
44 return Err(Error::InvalidToolParams(format!(
45 "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
46 )));
47 }
48
49 if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
50 return Err(Error::InvalidToolParams(
51 "Line and character positions must be >= 1".to_string(),
52 ));
53 }
54
55 if start_line > MAX_POSITION_VALUE
56 || start_character > MAX_POSITION_VALUE
57 || end_line > MAX_POSITION_VALUE
58 || end_character > MAX_POSITION_VALUE
59 {
60 return Err(Error::InvalidToolParams(format!(
61 "Position values must be <= {MAX_POSITION_VALUE}"
62 )));
63 }
64
65 if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
66 return Err(Error::InvalidToolParams(format!(
67 "Range size must be <= {MAX_RANGE_LINES} lines"
68 )));
69 }
70
71 if start_line > end_line || (start_line == end_line && start_character > end_character) {
72 return Err(Error::InvalidToolParams(
73 "Start position must be before or equal to end position".to_string(),
74 ));
75 }
76
77 Ok(())
78}
79
80pub(super) const MAX_NEW_NAME_LENGTH: usize = 1_000;
88
89fn validate_rename_params(new_name: &str) -> Result<()> {
91 if new_name.len() > MAX_NEW_NAME_LENGTH {
92 return Err(Error::InvalidToolParams(format!(
93 "new_name too long: {} bytes (max {MAX_NEW_NAME_LENGTH})",
94 new_name.len()
95 )));
96 }
97 Ok(())
98}
99
100async fn convert_code_action(
104 action: lsp_types::CodeAction,
105 ctx: &EncodingCtx,
106 uri: &lsp_types::Uri,
107) -> CodeAction {
108 let diagnostics = match action.diagnostics {
109 Some(diags) => {
110 let mut result = Vec::with_capacity(diags.len());
111 for d in &diags {
112 result.push(diagnostic_to_mcp(d, ctx, uri).await);
113 }
114 result
115 }
116 None => Vec::new(),
117 };
118
119 let edit = match action.edit {
120 Some(edit) => {
121 let changes = match edit.changes {
122 Some(changes_map) => {
123 let mut result = Vec::with_capacity(changes_map.len());
124 for (uri, edits) in changes_map {
125 let mut text_edits = Vec::with_capacity(edits.len());
126 for e in edits {
127 text_edits.push(TextEdit {
128 range: ctx.normalize_range(&uri, e.range).await,
129 new_text: e.new_text,
130 });
131 }
132 result.push(DocumentChanges {
133 uri: uri.to_string(),
134 edits: text_edits,
135 });
136 }
137 result
138 }
139 None => Vec::new(),
140 };
141 Some(WorkspaceEditDescription { changes })
142 }
143 None => None,
144 };
145
146 let command = action.command.map(|cmd| {
147 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
148 CommandDescription {
149 title: cmd.title,
150 command: cmd.command,
151 arguments,
152 }
153 });
154
155 CodeAction {
156 title: action.title,
157 kind: action.kind.map(|k| k.as_str().to_string()),
158 diagnostics,
159 edit,
160 command,
161 is_preferred: action.is_preferred.unwrap_or(false),
162 }
163}
164
165impl Translator {
166 pub async fn handle_rename(
174 &self,
175 file_path: String,
176 line: u32,
177 character: u32,
178 new_name: String,
179 ) -> Result<RenameResult> {
180 validate_rename_params(&new_name)?;
181
182 let (server_id, client, uri) = self
183 .prepare_gated_document(&file_path, ToolKind::Rename, "renameProvider", |caps| {
184 matches!(
185 caps.rename_provider,
186 Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
187 )
188 })
189 .await?;
190 let ctx = self.encoding_ctx(&server_id);
191 let lsp_position = ctx.to_lsp(&uri, line, character).await;
192
193 let params = LspRenameParams {
194 text_document_position: TextDocumentPositionParams {
195 text_document: TextDocumentIdentifier { uri },
196 position: lsp_position,
197 },
198 new_name,
199 work_done_progress_params: WorkDoneProgressParams::default(),
200 };
201
202 let response: Option<WorkspaceEdit> = client
203 .request("textDocument/rename", params, client.request_timeout())
204 .await?;
205
206 let changes = if let Some(edit) = response {
207 let mut result_changes = Vec::new();
208
209 if let Some(changes_map) = edit.changes {
211 for (uri, edits) in changes_map {
212 let mut text_edits = Vec::with_capacity(edits.len());
213 for e in edits {
214 text_edits.push(TextEdit {
215 range: ctx.normalize_range(&uri, e.range).await,
216 new_text: e.new_text,
217 });
218 }
219 result_changes.push(DocumentChanges {
220 uri: uri.to_string(),
221 edits: text_edits,
222 });
223 }
224 }
225
226 if result_changes.is_empty() {
228 let text_doc_edits = match edit.document_changes {
229 Some(lsp_types::DocumentChanges::Edits(edits)) => edits,
230 Some(lsp_types::DocumentChanges::Operations(ops)) => ops
231 .into_iter()
232 .filter_map(|op| match op {
233 lsp_types::DocumentChangeOperation::Edit(e) => Some(e),
234 lsp_types::DocumentChangeOperation::Op(_) => None,
235 })
236 .collect(),
237 None => vec![],
238 };
239 for tde in text_doc_edits {
240 let edit_uri = &tde.text_document.uri;
241 let mut text_edits = Vec::with_capacity(tde.edits.len());
242 for one_of in tde.edits {
243 text_edits.push(match one_of {
244 lsp_types::OneOf::Left(te) => TextEdit {
245 range: ctx.normalize_range(edit_uri, te.range).await,
246 new_text: te.new_text,
247 },
248 lsp_types::OneOf::Right(ate) => TextEdit {
249 range: ctx.normalize_range(edit_uri, ate.text_edit.range).await,
250 new_text: ate.text_edit.new_text,
251 },
252 });
253 }
254 result_changes.push(DocumentChanges {
255 uri: edit_uri.to_string(),
256 edits: text_edits,
257 });
258 }
259 }
260
261 result_changes
262 } else {
263 vec![]
264 };
265
266 Ok(RenameResult { changes })
267 }
268
269 pub async fn handle_format_document(
276 &self,
277 file_path: String,
278 tab_size: u32,
279 insert_spaces: bool,
280 ) -> Result<FormatDocumentResult> {
281 let (server_id, client, uri) = self
282 .prepare_gated_document(
283 &file_path,
284 ToolKind::FormatDocument,
285 "documentFormattingProvider",
286 |caps| {
287 matches!(
288 caps.document_formatting_provider,
289 Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
290 )
291 },
292 )
293 .await?;
294 let ctx = self.encoding_ctx(&server_id);
295 let response_uri = uri.clone();
296
297 let params = DocumentFormattingParams {
298 text_document: TextDocumentIdentifier { uri },
299 options: FormattingOptions {
300 tab_size,
301 insert_spaces,
302 ..Default::default()
303 },
304 work_done_progress_params: WorkDoneProgressParams::default(),
305 };
306
307 let response: Option<Vec<lsp_types::TextEdit>> = client
308 .request("textDocument/formatting", params, client.request_timeout())
309 .await?;
310
311 let edits = response.unwrap_or_default();
312
313 let mut result_edits = Vec::with_capacity(edits.len());
314 for edit in edits {
315 result_edits.push(TextEdit {
316 range: ctx.normalize_range(&response_uri, edit.range).await,
317 new_text: edit.new_text,
318 });
319 }
320 let result = FormatDocumentResult {
321 edits: result_edits,
322 };
323
324 Ok(result)
325 }
326
327 pub async fn handle_code_actions(
334 &self,
335 file_path: String,
336 start_line: u32,
337 start_character: u32,
338 end_line: u32,
339 end_character: u32,
340 kind_filter: Option<String>,
341 ) -> Result<CodeActionsResult> {
342 validate_code_action_params(
343 start_line,
344 start_character,
345 end_line,
346 end_character,
347 kind_filter.as_deref(),
348 )?;
349
350 let (server_id, client, uri) = self
351 .prepare_gated_document(
352 &file_path,
353 ToolKind::CodeActions,
354 "codeActionProvider",
355 |caps| {
356 matches!(
357 caps.code_action_provider,
358 Some(
359 lsp_types::CodeActionProviderCapability::Simple(true)
360 | lsp_types::CodeActionProviderCapability::Options(_)
361 )
362 )
363 },
364 )
365 .await?;
366 let ctx = self.encoding_ctx(&server_id);
367 let response_uri = uri.clone();
368
369 let range = lsp_types::Range {
370 start: ctx.to_lsp(&uri, start_line, start_character).await,
371 end: ctx.to_lsp(&uri, end_line, end_character).await,
372 };
373
374 let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
376
377 let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
382
383 let params = lsp_types::CodeActionParams {
384 text_document: TextDocumentIdentifier { uri },
385 range,
386 context: lsp_types::CodeActionContext {
387 diagnostics: context_diagnostics,
388 only,
389 trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
390 },
391 work_done_progress_params: WorkDoneProgressParams::default(),
392 partial_result_params: PartialResultParams::default(),
393 };
394
395 let response: Option<lsp_types::CodeActionResponse> = client
396 .request("textDocument/codeAction", params, client.request_timeout())
397 .await?;
398 let response_vec = response.unwrap_or_default();
399 let mut actions = Vec::with_capacity(response_vec.len());
400
401 for action_or_command in response_vec {
402 let action = match action_or_command {
403 lsp_types::CodeActionOrCommand::CodeAction(action) => {
404 convert_code_action(action, &ctx, &response_uri).await
405 }
406 lsp_types::CodeActionOrCommand::Command(cmd) => {
407 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
408 CodeAction {
409 title: cmd.title.clone(),
410 kind: None,
411 diagnostics: Vec::new(),
412 edit: None,
413 command: Some(CommandDescription {
414 title: cmd.title,
415 command: cmd.command,
416 arguments,
417 }),
418 is_preferred: false,
419 }
420 }
421 };
422 actions.push(action);
423 }
424
425 Ok(CodeActionsResult { actions })
426 }
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used, clippy::expect_used)]
431mod tests {
432 use std::fs;
433
434 use super::*;
435 use crate::bridge::translator::dto::DiagnosticSeverity;
436 use crate::bridge::translator::testing::*;
437
438 #[test]
441 fn test_validate_rename_params_rejects_oversized_new_name() {
442 let new_name = "a".repeat(MAX_NEW_NAME_LENGTH + 1);
443 let result = validate_rename_params(&new_name);
444 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
445 }
446
447 #[test]
448 fn test_validate_rename_params_accepts_name_at_exact_limit() {
449 let new_name = "a".repeat(MAX_NEW_NAME_LENGTH);
450 assert!(validate_rename_params(&new_name).is_ok());
451 }
452
453 #[test]
454 fn test_validate_rename_params_accepts_typical_identifier() {
455 assert!(validate_rename_params("my_variable").is_ok());
456 }
457
458 #[test]
462 fn test_validate_rename_params_accepts_empty_string() {
463 assert!(validate_rename_params("").is_ok());
464 }
465
466 #[tokio::test]
467 async fn test_handle_code_actions_invalid_kind() {
468 let translator = Translator::new();
469 let result = translator
470 .handle_code_actions(
471 "/tmp/test.rs".to_string(),
472 1,
473 1,
474 1,
475 10,
476 Some("invalid_kind".to_string()),
477 )
478 .await;
479 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
480 }
481
482 #[tokio::test]
483 async fn test_handle_code_actions_valid_kind_quickfix() {
484 use tempfile::TempDir;
485
486 let translator = Translator::new();
487 let temp_dir = TempDir::new().unwrap();
488 let test_file = temp_dir.path().join("test.rs");
489 fs::write(&test_file, "fn main() {}").unwrap();
490
491 let result = translator
492 .handle_code_actions(
493 test_file.to_str().unwrap().to_string(),
494 1,
495 1,
496 1,
497 10,
498 Some("quickfix".to_string()),
499 )
500 .await;
501 assert!(result.is_err());
503 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
504 }
505
506 #[tokio::test]
507 async fn test_handle_code_actions_valid_kind_refactor() {
508 use tempfile::TempDir;
509
510 let translator = Translator::new();
511 let temp_dir = TempDir::new().unwrap();
512 let test_file = temp_dir.path().join("test.rs");
513 fs::write(&test_file, "fn main() {}").unwrap();
514
515 let result = translator
516 .handle_code_actions(
517 test_file.to_str().unwrap().to_string(),
518 1,
519 1,
520 1,
521 10,
522 Some("refactor".to_string()),
523 )
524 .await;
525 assert!(result.is_err());
526 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
527 }
528
529 #[tokio::test]
530 async fn test_handle_code_actions_valid_kind_refactor_extract() {
531 use tempfile::TempDir;
532
533 let translator = Translator::new();
534 let temp_dir = TempDir::new().unwrap();
535 let test_file = temp_dir.path().join("test.rs");
536 fs::write(&test_file, "fn main() {}").unwrap();
537
538 let result = translator
539 .handle_code_actions(
540 test_file.to_str().unwrap().to_string(),
541 1,
542 1,
543 1,
544 10,
545 Some("refactor.extract".to_string()),
546 )
547 .await;
548 assert!(result.is_err());
549 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
550 }
551
552 #[tokio::test]
553 async fn test_handle_code_actions_valid_kind_source() {
554 use tempfile::TempDir;
555
556 let translator = Translator::new();
557 let temp_dir = TempDir::new().unwrap();
558 let test_file = temp_dir.path().join("test.rs");
559 fs::write(&test_file, "fn main() {}").unwrap();
560
561 let result = translator
562 .handle_code_actions(
563 test_file.to_str().unwrap().to_string(),
564 1,
565 1,
566 1,
567 10,
568 Some("source.organizeImports".to_string()),
569 )
570 .await;
571 assert!(result.is_err());
572 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
573 }
574
575 #[tokio::test]
576 async fn test_handle_code_actions_invalid_range_zero() {
577 let translator = Translator::new();
578 let result = translator
579 .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
580 .await;
581 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
582 }
583
584 #[tokio::test]
585 async fn test_handle_code_actions_invalid_range_order() {
586 let translator = Translator::new();
587 let result = translator
588 .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
589 .await;
590 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
591 }
592
593 #[tokio::test]
594 async fn test_handle_code_actions_empty_range() {
595 use tempfile::TempDir;
596
597 let translator = Translator::new();
598 let temp_dir = TempDir::new().unwrap();
599 let test_file = temp_dir.path().join("test.rs");
600 fs::write(&test_file, "fn main() {}").unwrap();
601
602 let result = translator
604 .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
605 .await;
606 assert!(result.is_err());
608 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
609 }
610
611 #[tokio::test]
612 async fn test_convert_code_action_minimal() {
613 let lsp_action = lsp_types::CodeAction {
614 title: "Fix issue".to_string(),
615 kind: None,
616 diagnostics: None,
617 edit: None,
618 command: None,
619 is_preferred: None,
620 disabled: None,
621 data: None,
622 };
623
624 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
625 assert_eq!(result.title, "Fix issue");
626 assert!(result.kind.is_none());
627 assert!(result.diagnostics.is_empty());
628 assert!(result.edit.is_none());
629 assert!(result.command.is_none());
630 assert!(!result.is_preferred);
631 }
632
633 #[tokio::test]
634 #[allow(clippy::too_many_lines)]
635 async fn test_convert_code_action_with_diagnostics_all_severities() {
636 let lsp_diagnostics = vec![
637 lsp_types::Diagnostic {
638 range: lsp_types::Range {
639 start: lsp_types::Position {
640 line: 0,
641 character: 0,
642 },
643 end: lsp_types::Position {
644 line: 0,
645 character: 5,
646 },
647 },
648 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
649 message: "Error message".to_string(),
650 code: Some(lsp_types::NumberOrString::Number(1)),
651 source: None,
652 code_description: None,
653 related_information: None,
654 tags: None,
655 data: None,
656 },
657 lsp_types::Diagnostic {
658 range: lsp_types::Range {
659 start: lsp_types::Position {
660 line: 1,
661 character: 0,
662 },
663 end: lsp_types::Position {
664 line: 1,
665 character: 5,
666 },
667 },
668 severity: Some(lsp_types::DiagnosticSeverity::WARNING),
669 message: "Warning message".to_string(),
670 code: Some(lsp_types::NumberOrString::String("W001".to_string())),
671 source: None,
672 code_description: None,
673 related_information: None,
674 tags: None,
675 data: None,
676 },
677 lsp_types::Diagnostic {
678 range: lsp_types::Range {
679 start: lsp_types::Position {
680 line: 2,
681 character: 0,
682 },
683 end: lsp_types::Position {
684 line: 2,
685 character: 5,
686 },
687 },
688 severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
689 message: "Info message".to_string(),
690 code: None,
691 source: None,
692 code_description: None,
693 related_information: None,
694 tags: None,
695 data: None,
696 },
697 lsp_types::Diagnostic {
698 range: lsp_types::Range {
699 start: lsp_types::Position {
700 line: 3,
701 character: 0,
702 },
703 end: lsp_types::Position {
704 line: 3,
705 character: 5,
706 },
707 },
708 severity: Some(lsp_types::DiagnosticSeverity::HINT),
709 message: "Hint message".to_string(),
710 code: None,
711 source: None,
712 code_description: None,
713 related_information: None,
714 tags: None,
715 data: None,
716 },
717 ];
718
719 let lsp_action = lsp_types::CodeAction {
720 title: "Fix all issues".to_string(),
721 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
722 diagnostics: Some(lsp_diagnostics),
723 edit: None,
724 command: None,
725 is_preferred: None,
726 disabled: None,
727 data: None,
728 };
729
730 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
731 assert_eq!(result.diagnostics.len(), 4);
732 assert!(matches!(
733 result.diagnostics[0].severity,
734 DiagnosticSeverity::Error
735 ));
736 assert!(matches!(
737 result.diagnostics[1].severity,
738 DiagnosticSeverity::Warning
739 ));
740 assert!(matches!(
741 result.diagnostics[2].severity,
742 DiagnosticSeverity::Information
743 ));
744 assert!(matches!(
745 result.diagnostics[3].severity,
746 DiagnosticSeverity::Hint
747 ));
748 assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
749 assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
750 }
751
752 #[tokio::test]
753 #[allow(clippy::mutable_key_type)]
754 async fn test_convert_code_action_with_workspace_edit() {
755 use std::collections::HashMap;
756 use std::str::FromStr;
757
758 let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
759 let mut changes_map = HashMap::new();
760 changes_map.insert(
761 uri,
762 vec![lsp_types::TextEdit {
763 range: lsp_types::Range {
764 start: lsp_types::Position {
765 line: 0,
766 character: 0,
767 },
768 end: lsp_types::Position {
769 line: 0,
770 character: 5,
771 },
772 },
773 new_text: "fixed".to_string(),
774 }],
775 );
776
777 let lsp_action = lsp_types::CodeAction {
778 title: "Apply fix".to_string(),
779 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
780 diagnostics: None,
781 edit: Some(lsp_types::WorkspaceEdit {
782 changes: Some(changes_map),
783 document_changes: None,
784 change_annotations: None,
785 }),
786 command: None,
787 is_preferred: Some(true),
788 disabled: None,
789 data: None,
790 };
791
792 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
793 assert!(result.edit.is_some());
794 let edit = result.edit.unwrap();
795 assert_eq!(edit.changes.len(), 1);
796 assert_eq!(edit.changes[0].uri, "file:///test.rs");
797 assert_eq!(edit.changes[0].edits.len(), 1);
798 assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
799 assert!(result.is_preferred);
800 }
801
802 #[tokio::test]
803 async fn test_convert_code_action_with_command() {
804 let lsp_action = lsp_types::CodeAction {
805 title: "Run command".to_string(),
806 kind: Some(lsp_types::CodeActionKind::REFACTOR),
807 diagnostics: None,
808 edit: None,
809 command: Some(lsp_types::Command {
810 title: "Execute refactor".to_string(),
811 command: "refactor.extract".to_string(),
812 arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
813 }),
814 is_preferred: None,
815 disabled: None,
816 data: None,
817 };
818
819 let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await;
820 assert!(result.command.is_some());
821 let cmd = result.command.unwrap();
822 assert_eq!(cmd.title, "Execute refactor");
823 assert_eq!(cmd.command, "refactor.extract");
824 assert_eq!(cmd.arguments.len(), 2);
825 }
826}