Skip to main content

mcpls_core/bridge/translator/
edits.rs

1//! Rename, format-document, and code-actions handlers.
2
3use 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
20/// Convert LSP range to MCP range (0-based to 1-based).
21/// Validate parameters for `handle_code_actions`.
22fn 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
87/// Maximum length, in bytes, of a `rename_symbol` `new_name` parameter.
88///
89/// `new_name` is forwarded to the routed LSP server as-is with no inherent
90/// bound of its own -- unlike `workspace_symbol_search`'s `query` (see
91/// `validate_workspace_symbol_params`), it previously relied entirely on
92/// outer transport limits (#309). No real identifier approaches this length
93/// in any language mcpls targets.
94pub(super) const MAX_NEW_NAME_LENGTH: usize = 1_000;
95
96/// Validate parameters for `handle_rename`.
97fn 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
107/// Convert LSP code action to MCP code action. `uri` is the queried
108/// document's own URI, used for the action's `diagnostics` (always scoped to
109/// the requested document); `edit.changes` carries its own per-file URIs.
110async 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    /// Handle rename request.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if `new_name` exceeds the maximum allowed length,
178    /// the LSP request fails, the file cannot be opened, or the routed
179    /// server does not advertise `renameProvider` support.
180    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            // Prefer the legacy `changes` map (HashMap<Uri, Vec<TextEdit>>).
220            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            // Also handle `documentChanges` (array format returned by rust-analyzer).
237            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                            // Snippet edits are an LSP 3.18 addition mcpls does not
263                            // advertise support for (`WorkspaceEditClientCapabilities`
264                            // carries no `snippetEditSupport`). A server can still send
265                            // one; its `new_text` would carry literal snippet
266                            // placeholder syntax (e.g. `${1:name}`), which would be
267                            // written into the user's file as-is if treated as plain
268                            // text -- this is the one tool that rewrites files, so the
269                            // edit is dropped instead, consistent with how
270                            // `CreateFile`/`RenameFile`/`DeleteFile` are already
271                            // dropped above rather than mistranslated.
272                            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    /// Handle format document request.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if the LSP request fails, the file cannot be opened,
296    /// or the routed server does not advertise `documentFormattingProvider` support.
297    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    /// Handle code actions request.
355    ///
356    /// # Errors
357    ///
358    /// Returns an error if the LSP request fails, the file cannot be opened,
359    /// or the routed server does not advertise `codeActionProvider` support.
360    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        // Build context with optional kind filter
394        let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
395
396        // Pass empty diagnostics context — rust-analyzer generates code actions
397        // based on cursor position and its internal analysis state, not on the
398        // passed diagnostics.  Passing stale cached diagnostics (which may lack
399        // the internal `data` field ra uses for fix mapping) suppresses results.
400        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    /// S2/S4 regression: a `documentChanges` entry mixing a plain `TextEdit`
458    /// with an `Edit::SnippetTextEdit` (LSP 3.18, reachable even though mcpls
459    /// advertises no `snippetEditSupport`) must drop the snippet edit rather
460    /// than pass its literal placeholder syntax (`${1:...}`) through as
461    /// ordinary replacement text -- `handle_rename` is the one tool that
462    /// rewrites the user's files.
463    #[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    /// #309: `new_name` has no inherent bound of its own and is forwarded to
564    /// the LSP server as-is, so it must be rejected before that happens.
565    #[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    /// #309: length checks have no lower bound -- an empty `new_name` is
584    /// syntactically valid input for this validator (semantic rejection of
585    /// an empty rename target, if desired, is a separate concern).
586    #[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        // Will fail due to no LSP server, but validates kind is accepted
635        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        // Empty range (same position) should be valid
770        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        // Will fail due to no LSP server, but validates range is accepted
785        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}