ryo-mutations 0.1.0

[experimental] Code transformation primitives for Rust source code
Documentation
//! CodeAction: rust-analyzer assists to Mutation conversion
//!
//! This module converts rust-analyzer code actions (assists) into
//! ryo-mutations, enabling semantic-aware refactoring.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::Mutation;

/// Kind of code action
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CodeActionKind {
    /// Quick fix for diagnostics
    QuickFix,
    /// Refactoring action
    Refactor,
    /// Refactoring that extracts code
    RefactorExtract,
    /// Refactoring that inlines code
    RefactorInline,
    /// Refactoring that rewrites code
    RefactorRewrite,
    /// Source organization
    Source,
    /// Organize imports
    SourceOrganizeImports,
    /// Other/unknown kind
    Other(String),
}

impl CodeActionKind {
    /// Parse from LSP code action kind string
    pub fn from_lsp(kind: &str) -> Self {
        match kind {
            "quickfix" => CodeActionKind::QuickFix,
            "refactor" => CodeActionKind::Refactor,
            "refactor.extract" => CodeActionKind::RefactorExtract,
            "refactor.inline" => CodeActionKind::RefactorInline,
            "refactor.rewrite" => CodeActionKind::RefactorRewrite,
            "source" => CodeActionKind::Source,
            "source.organizeImports" => CodeActionKind::SourceOrganizeImports,
            other => CodeActionKind::Other(other.to_string()),
        }
    }

    /// Check if this is a refactoring action
    pub fn is_refactor(&self) -> bool {
        matches!(
            self,
            CodeActionKind::Refactor
                | CodeActionKind::RefactorExtract
                | CodeActionKind::RefactorInline
                | CodeActionKind::RefactorRewrite
        )
    }
}

/// A code action from rust-analyzer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyzerCodeAction {
    /// Action title (human readable)
    pub title: String,
    /// Action kind
    pub kind: CodeActionKind,
    /// rust-analyzer assist ID (e.g., "fill_match_arms")
    pub assist_id: Option<String>,
    /// Text edits to apply
    pub edits: Vec<TextEdit>,
    /// File this action applies to
    pub file: PathBuf,
    /// Whether this action is preferred
    pub is_preferred: bool,
}

/// A text edit from a code action
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextEdit {
    /// File to edit
    pub file: PathBuf,
    /// Start position
    pub start: super::inlay_hints::Position,
    /// End position
    pub end: super::inlay_hints::Position,
    /// New text to insert
    pub new_text: String,
}

impl AnalyzerCodeAction {
    /// Get the short assist ID
    pub fn assist_id(&self) -> Option<&str> {
        self.assist_id.as_deref()
    }

    /// Check if this action can be converted to a Mutation.
    ///
    /// In v0.1.0 no `CodeAction` → `Mutation` mappings ship; the previous
    /// `fill_match_arms` / `add_missing_fields` / `add_explicit_type`
    /// derivations were removed because they require type-inference
    /// infrastructure that is not yet wired through `ASTRegApply`.
    /// Always returns `false` until those Mutations are reintroduced.
    pub fn has_mutation(&self) -> bool {
        false
    }
}

/// Trait for converting code actions to mutations
pub trait CodeActionToMutation {
    /// Convert this code action to a mutation if possible
    fn to_mutation(&self) -> Option<Box<dyn Mutation>>;
}

impl CodeActionToMutation for AnalyzerCodeAction {
    /// In v0.1.0 no `CodeAction` is convertible to a `Mutation`; see
    /// `has_mutation` for context. Returns `None` for every action.
    fn to_mutation(&self) -> Option<Box<dyn Mutation>> {
        None
    }
}

// ============================================================================
// Mutations derived from code actions
// ============================================================================
//
// FillMatchArmsMutation / AddMissingFieldsMutation / AddExplicitTypeMutation
// were removed in v0.1.0. They required type-inference / definition-lookup
// infrastructure that the V2 ASTRegApply path does not yet expose, so their
// `apply_to_registry` impls were stubbed with `todo!(...)` and would panic at
// runtime if invoked. They will be reintroduced when the analyzer integration
// gains the missing capabilities.

/// Parse code actions from LSP response
///
/// Note: This function is designed for future LSP client integration.
/// Currently unused but will be called when AnalyzerClient connects to rust-analyzer.
#[allow(dead_code)]
pub fn parse_code_actions(
    response: &serde_json::Value,
    file: impl Into<PathBuf>,
) -> Result<Vec<AnalyzerCodeAction>, serde_json::Error> {
    let file = file.into();
    let lsp_actions: Vec<LspCodeAction> = serde_json::from_value(response.clone())?;

    Ok(lsp_actions
        .into_iter()
        .map(|a| a.into_analyzer_action(&file))
        .collect())
}

// LSP response parsing structures
// These are used by parse_code_actions() for future LSP integration

/// LSP CodeAction format
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspCodeAction {
    title: String,
    kind: Option<String>,
    #[serde(default)]
    is_preferred: bool,
    edit: Option<LspWorkspaceEdit>,
    data: Option<serde_json::Value>,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspWorkspaceEdit {
    changes: Option<std::collections::HashMap<String, Vec<LspTextEdit>>>,
    #[serde(rename = "documentChanges")]
    document_changes: Option<Vec<LspDocumentChange>>,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspDocumentChange {
    #[serde(rename = "textDocument")]
    text_document: LspVersionedTextDocument,
    edits: Vec<LspTextEdit>,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspVersionedTextDocument {
    uri: String,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspTextEdit {
    range: LspRange,
    #[serde(rename = "newText")]
    new_text: String,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspRange {
    start: LspPosition,
    end: LspPosition,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct LspPosition {
    line: u32,
    character: u32,
}

#[allow(dead_code)]
impl LspCodeAction {
    fn into_analyzer_action(self, default_file: &Path) -> AnalyzerCodeAction {
        let kind = self
            .kind
            .as_deref()
            .map(CodeActionKind::from_lsp)
            .unwrap_or(CodeActionKind::Other("unknown".to_string()));

        // Extract assist ID from data if available
        let assist_id = self
            .data
            .as_ref()
            .and_then(|d| d.get("id").and_then(|v| v.as_str().map(String::from)));

        // Convert edits
        let mut edits = Vec::new();
        if let Some(edit) = self.edit {
            if let Some(changes) = edit.changes {
                for (uri, text_edits) in changes {
                    let file = PathBuf::from(uri.strip_prefix("file://").unwrap_or(&uri));
                    for te in text_edits {
                        edits.push(TextEdit {
                            file: file.clone(),
                            start: super::inlay_hints::Position {
                                line: te.range.start.line,
                                character: te.range.start.character,
                            },
                            end: super::inlay_hints::Position {
                                line: te.range.end.line,
                                character: te.range.end.character,
                            },
                            new_text: te.new_text,
                        });
                    }
                }
            }
            if let Some(doc_changes) = edit.document_changes {
                for dc in doc_changes {
                    let file = PathBuf::from(
                        dc.text_document
                            .uri
                            .strip_prefix("file://")
                            .unwrap_or(&dc.text_document.uri),
                    );
                    for te in dc.edits {
                        edits.push(TextEdit {
                            file: file.clone(),
                            start: super::inlay_hints::Position {
                                line: te.range.start.line,
                                character: te.range.start.character,
                            },
                            end: super::inlay_hints::Position {
                                line: te.range.end.line,
                                character: te.range.end.character,
                            },
                            new_text: te.new_text,
                        });
                    }
                }
            }
        }

        AnalyzerCodeAction {
            title: self.title,
            kind,
            assist_id,
            edits,
            file: default_file.to_path_buf(),
            is_preferred: self.is_preferred,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_code_action_kind_from_lsp() {
        assert_eq!(
            CodeActionKind::from_lsp("quickfix"),
            CodeActionKind::QuickFix
        );
        assert_eq!(
            CodeActionKind::from_lsp("refactor.extract"),
            CodeActionKind::RefactorExtract
        );
        assert!(matches!(
            CodeActionKind::from_lsp("custom"),
            CodeActionKind::Other(_)
        ));
    }

    #[test]
    fn test_code_action_kind_is_refactor() {
        assert!(CodeActionKind::Refactor.is_refactor());
        assert!(CodeActionKind::RefactorExtract.is_refactor());
        assert!(!CodeActionKind::QuickFix.is_refactor());
    }
}