Skip to main content

ryo_mutations/analyzer/
code_action.rs

1//! CodeAction: rust-analyzer assists to Mutation conversion
2//!
3//! This module converts rust-analyzer code actions (assists) into
4//! ryo-mutations, enabling semantic-aware refactoring.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9use crate::Mutation;
10
11/// Kind of code action
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum CodeActionKind {
15    /// Quick fix for diagnostics
16    QuickFix,
17    /// Refactoring action
18    Refactor,
19    /// Refactoring that extracts code
20    RefactorExtract,
21    /// Refactoring that inlines code
22    RefactorInline,
23    /// Refactoring that rewrites code
24    RefactorRewrite,
25    /// Source organization
26    Source,
27    /// Organize imports
28    SourceOrganizeImports,
29    /// Other/unknown kind
30    Other(String),
31}
32
33impl CodeActionKind {
34    /// Parse from LSP code action kind string
35    pub fn from_lsp(kind: &str) -> Self {
36        match kind {
37            "quickfix" => CodeActionKind::QuickFix,
38            "refactor" => CodeActionKind::Refactor,
39            "refactor.extract" => CodeActionKind::RefactorExtract,
40            "refactor.inline" => CodeActionKind::RefactorInline,
41            "refactor.rewrite" => CodeActionKind::RefactorRewrite,
42            "source" => CodeActionKind::Source,
43            "source.organizeImports" => CodeActionKind::SourceOrganizeImports,
44            other => CodeActionKind::Other(other.to_string()),
45        }
46    }
47
48    /// Check if this is a refactoring action
49    pub fn is_refactor(&self) -> bool {
50        matches!(
51            self,
52            CodeActionKind::Refactor
53                | CodeActionKind::RefactorExtract
54                | CodeActionKind::RefactorInline
55                | CodeActionKind::RefactorRewrite
56        )
57    }
58}
59
60/// A code action from rust-analyzer
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AnalyzerCodeAction {
63    /// Action title (human readable)
64    pub title: String,
65    /// Action kind
66    pub kind: CodeActionKind,
67    /// rust-analyzer assist ID (e.g., "fill_match_arms")
68    pub assist_id: Option<String>,
69    /// Text edits to apply
70    pub edits: Vec<TextEdit>,
71    /// File this action applies to
72    pub file: PathBuf,
73    /// Whether this action is preferred
74    pub is_preferred: bool,
75}
76
77/// A text edit from a code action
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TextEdit {
80    /// File to edit
81    pub file: PathBuf,
82    /// Start position
83    pub start: super::inlay_hints::Position,
84    /// End position
85    pub end: super::inlay_hints::Position,
86    /// New text to insert
87    pub new_text: String,
88}
89
90impl AnalyzerCodeAction {
91    /// Get the short assist ID
92    pub fn assist_id(&self) -> Option<&str> {
93        self.assist_id.as_deref()
94    }
95
96    /// Check if this action can be converted to a Mutation.
97    ///
98    /// In v0.1.0 no `CodeAction` → `Mutation` mappings ship; the previous
99    /// `fill_match_arms` / `add_missing_fields` / `add_explicit_type`
100    /// derivations were removed because they require type-inference
101    /// infrastructure that is not yet wired through `ASTRegApply`.
102    /// Always returns `false` until those Mutations are reintroduced.
103    pub fn has_mutation(&self) -> bool {
104        false
105    }
106}
107
108/// Trait for converting code actions to mutations
109pub trait CodeActionToMutation {
110    /// Convert this code action to a mutation if possible
111    fn to_mutation(&self) -> Option<Box<dyn Mutation>>;
112}
113
114impl CodeActionToMutation for AnalyzerCodeAction {
115    /// In v0.1.0 no `CodeAction` is convertible to a `Mutation`; see
116    /// `has_mutation` for context. Returns `None` for every action.
117    fn to_mutation(&self) -> Option<Box<dyn Mutation>> {
118        None
119    }
120}
121
122// ============================================================================
123// Mutations derived from code actions
124// ============================================================================
125//
126// FillMatchArmsMutation / AddMissingFieldsMutation / AddExplicitTypeMutation
127// were removed in v0.1.0. They required type-inference / definition-lookup
128// infrastructure that the V2 ASTRegApply path does not yet expose, so their
129// `apply_to_registry` impls were stubbed with `todo!(...)` and would panic at
130// runtime if invoked. They will be reintroduced when the analyzer integration
131// gains the missing capabilities.
132
133/// Parse code actions from LSP response
134///
135/// Note: This function is designed for future LSP client integration.
136/// Currently unused but will be called when AnalyzerClient connects to rust-analyzer.
137#[allow(dead_code)]
138pub fn parse_code_actions(
139    response: &serde_json::Value,
140    file: impl Into<PathBuf>,
141) -> Result<Vec<AnalyzerCodeAction>, serde_json::Error> {
142    let file = file.into();
143    let lsp_actions: Vec<LspCodeAction> = serde_json::from_value(response.clone())?;
144
145    Ok(lsp_actions
146        .into_iter()
147        .map(|a| a.into_analyzer_action(&file))
148        .collect())
149}
150
151// LSP response parsing structures
152// These are used by parse_code_actions() for future LSP integration
153
154/// LSP CodeAction format
155#[allow(dead_code)]
156#[derive(Debug, Deserialize)]
157struct LspCodeAction {
158    title: String,
159    kind: Option<String>,
160    #[serde(default)]
161    is_preferred: bool,
162    edit: Option<LspWorkspaceEdit>,
163    data: Option<serde_json::Value>,
164}
165
166#[allow(dead_code)]
167#[derive(Debug, Deserialize)]
168struct LspWorkspaceEdit {
169    changes: Option<std::collections::HashMap<String, Vec<LspTextEdit>>>,
170    #[serde(rename = "documentChanges")]
171    document_changes: Option<Vec<LspDocumentChange>>,
172}
173
174#[allow(dead_code)]
175#[derive(Debug, Deserialize)]
176struct LspDocumentChange {
177    #[serde(rename = "textDocument")]
178    text_document: LspVersionedTextDocument,
179    edits: Vec<LspTextEdit>,
180}
181
182#[allow(dead_code)]
183#[derive(Debug, Deserialize)]
184struct LspVersionedTextDocument {
185    uri: String,
186}
187
188#[allow(dead_code)]
189#[derive(Debug, Deserialize)]
190struct LspTextEdit {
191    range: LspRange,
192    #[serde(rename = "newText")]
193    new_text: String,
194}
195
196#[allow(dead_code)]
197#[derive(Debug, Deserialize)]
198struct LspRange {
199    start: LspPosition,
200    end: LspPosition,
201}
202
203#[allow(dead_code)]
204#[derive(Debug, Deserialize)]
205struct LspPosition {
206    line: u32,
207    character: u32,
208}
209
210#[allow(dead_code)]
211impl LspCodeAction {
212    fn into_analyzer_action(self, default_file: &Path) -> AnalyzerCodeAction {
213        let kind = self
214            .kind
215            .as_deref()
216            .map(CodeActionKind::from_lsp)
217            .unwrap_or(CodeActionKind::Other("unknown".to_string()));
218
219        // Extract assist ID from data if available
220        let assist_id = self
221            .data
222            .as_ref()
223            .and_then(|d| d.get("id").and_then(|v| v.as_str().map(String::from)));
224
225        // Convert edits
226        let mut edits = Vec::new();
227        if let Some(edit) = self.edit {
228            if let Some(changes) = edit.changes {
229                for (uri, text_edits) in changes {
230                    let file = PathBuf::from(uri.strip_prefix("file://").unwrap_or(&uri));
231                    for te in text_edits {
232                        edits.push(TextEdit {
233                            file: file.clone(),
234                            start: super::inlay_hints::Position {
235                                line: te.range.start.line,
236                                character: te.range.start.character,
237                            },
238                            end: super::inlay_hints::Position {
239                                line: te.range.end.line,
240                                character: te.range.end.character,
241                            },
242                            new_text: te.new_text,
243                        });
244                    }
245                }
246            }
247            if let Some(doc_changes) = edit.document_changes {
248                for dc in doc_changes {
249                    let file = PathBuf::from(
250                        dc.text_document
251                            .uri
252                            .strip_prefix("file://")
253                            .unwrap_or(&dc.text_document.uri),
254                    );
255                    for te in dc.edits {
256                        edits.push(TextEdit {
257                            file: file.clone(),
258                            start: super::inlay_hints::Position {
259                                line: te.range.start.line,
260                                character: te.range.start.character,
261                            },
262                            end: super::inlay_hints::Position {
263                                line: te.range.end.line,
264                                character: te.range.end.character,
265                            },
266                            new_text: te.new_text,
267                        });
268                    }
269                }
270            }
271        }
272
273        AnalyzerCodeAction {
274            title: self.title,
275            kind,
276            assist_id,
277            edits,
278            file: default_file.to_path_buf(),
279            is_preferred: self.is_preferred,
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_code_action_kind_from_lsp() {
290        assert_eq!(
291            CodeActionKind::from_lsp("quickfix"),
292            CodeActionKind::QuickFix
293        );
294        assert_eq!(
295            CodeActionKind::from_lsp("refactor.extract"),
296            CodeActionKind::RefactorExtract
297        );
298        assert!(matches!(
299            CodeActionKind::from_lsp("custom"),
300            CodeActionKind::Other(_)
301        ));
302    }
303
304    #[test]
305    fn test_code_action_kind_is_refactor() {
306        assert!(CodeActionKind::Refactor.is_refactor());
307        assert!(CodeActionKind::RefactorExtract.is_refactor());
308        assert!(!CodeActionKind::QuickFix.is_refactor());
309    }
310}