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, 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
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_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
80/// Maximum length, in bytes, of a `rename_symbol` `new_name` parameter.
81///
82/// `new_name` is forwarded to the routed LSP server as-is with no inherent
83/// bound of its own -- unlike `workspace_symbol_search`'s `query` (see
84/// `validate_workspace_symbol_params`), it previously relied entirely on
85/// outer transport limits (#309). No real identifier approaches this length
86/// in any language mcpls targets.
87pub(super) const MAX_NEW_NAME_LENGTH: usize = 1_000;
88
89/// Validate parameters for `handle_rename`.
90fn 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
100/// Convert LSP code action to MCP code action. `uri` is the queried
101/// document's own URI, used for the action's `diagnostics` (always scoped to
102/// the requested document); `edit.changes` carries its own per-file URIs.
103async 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    /// Handle rename request.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if `new_name` exceeds the maximum allowed length,
171    /// the LSP request fails, the file cannot be opened, or the routed
172    /// server does not advertise `renameProvider` support.
173    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            // Prefer the legacy `changes` map (HashMap<Uri, Vec<TextEdit>>).
210            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            // Also handle `documentChanges` (array format returned by rust-analyzer).
227            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    /// Handle format document request.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the LSP request fails, the file cannot be opened,
274    /// or the routed server does not advertise `documentFormattingProvider` support.
275    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    /// Handle code actions request.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if the LSP request fails, the file cannot be opened,
332    /// or the routed server does not advertise `codeActionProvider` support.
333    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        // Build context with optional kind filter
375        let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
376
377        // Pass empty diagnostics context — rust-analyzer generates code actions
378        // based on cursor position and its internal analysis state, not on the
379        // passed diagnostics.  Passing stale cached diagnostics (which may lack
380        // the internal `data` field ra uses for fix mapping) suppresses results.
381        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    /// #309: `new_name` has no inherent bound of its own and is forwarded to
439    /// the LSP server as-is, so it must be rejected before that happens.
440    #[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    /// #309: length checks have no lower bound -- an empty `new_name` is
459    /// syntactically valid input for this validator (semantic rejection of
460    /// an empty rename target, if desired, is a separate concern).
461    #[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        // Will fail due to no LSP server, but validates kind is accepted
502        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        // Empty range (same position) should be valid
603        let result = translator
604            .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
605            .await;
606        // Will fail due to no LSP server, but validates range is accepted
607        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}