ryo-pattern 0.1.0

RyoPattern - AST pattern matching and lint rules for Ryo
Documentation
//! Diagnostic output for RyoPattern
//!
//! Provides Clippy-compatible diagnostic formatting.

use crate::{CapturedNode, MatchResult, Severity, Span};
use std::collections::HashMap;
use std::path::PathBuf;

/// A diagnostic message (Clippy-compatible)
#[derive(Debug, Clone)]
pub struct Diagnostic {
    /// Severity level
    pub severity: Severity,

    /// Rule ID (e.g., "RL001")
    pub rule_id: String,

    /// Primary message
    pub message: String,

    /// Source file path
    pub file_path: PathBuf,

    /// Primary span
    pub span: Span,

    /// Code snippet with context
    pub snippet: Option<String>,

    /// Suggestion/help text
    pub suggestion: Option<String>,

    /// Additional notes
    pub notes: Vec<String>,
}

impl Diagnostic {
    /// Create a new diagnostic from a MatchResult
    pub fn from_match_result(
        result: &MatchResult,
        file_path: impl Into<PathBuf>,
        primary_capture: Option<&str>,
    ) -> Option<Self> {
        if !result.matched {
            return None;
        }

        let span = primary_capture
            .and_then(|name| result.captures.get(name))
            .or_else(|| result.captures.values().next())
            .map(|c| c.span)
            .unwrap_or(Span::point(1, 1));

        Some(Self {
            severity: result.severity.unwrap_or(Severity::Warning),
            rule_id: result.rule_id.clone().unwrap_or_else(|| "unknown".into()),
            message: result.message.clone().unwrap_or_default(),
            file_path: file_path.into(),
            span,
            snippet: None,
            suggestion: result.suggestion.clone(),
            notes: Vec::new(),
        })
    }

    /// Add a code snippet
    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
        self.snippet = Some(snippet.into());
        self
    }

    /// Add a note
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.notes.push(note.into());
        self
    }

    /// Format as Clippy-style output
    pub fn format_clippy(&self) -> String {
        let mut output = String::new();

        // Header: severity[rule_id]: message
        output.push_str(&format!(
            "{}[{}]: {}\n",
            self.severity, self.rule_id, self.message
        ));

        // Location
        output.push_str(&format!(
            "  --> {}:{}:{}\n",
            self.file_path.display(),
            self.span.start.line,
            self.span.start.column
        ));

        // Snippet if available
        if let Some(ref snippet) = self.snippet {
            output.push_str("   |\n");
            for (i, line) in snippet.lines().enumerate() {
                let line_num = self.span.start.line as usize + i;
                output.push_str(&format!("{:>3} | {}\n", line_num, line));
            }
            output.push_str("   |\n");
        }

        // Suggestion
        if let Some(ref suggestion) = self.suggestion {
            output.push_str(&format!("   = help: {}\n", suggestion));
        }

        // Notes
        for note in &self.notes {
            output.push_str(&format!("   = note: {}\n", note));
        }

        output
    }

    /// Format as JSON for tooling integration
    pub fn format_json(&self) -> String {
        serde_json::to_string(&DiagnosticJson::from(self)).unwrap_or_default()
    }
}

/// JSON-serializable diagnostic
#[derive(Debug, Clone, serde::Serialize)]
struct DiagnosticJson {
    severity: String,
    rule_id: String,
    message: String,
    file_path: String,
    line: u32,
    column: u32,
    end_line: u32,
    end_column: u32,
    suggestion: Option<String>,
    notes: Vec<String>,
}

impl From<&Diagnostic> for DiagnosticJson {
    fn from(d: &Diagnostic) -> Self {
        Self {
            severity: d.severity.to_string(),
            rule_id: d.rule_id.clone(),
            message: d.message.clone(),
            file_path: d.file_path.to_string_lossy().to_string(),
            line: d.span.start.line,
            column: d.span.start.column,
            end_line: d.span.end.line,
            end_column: d.span.end.column,
            suggestion: d.suggestion.clone(),
            notes: d.notes.clone(),
        }
    }
}

/// Message interpolation for captured variables
pub fn interpolate_message(template: &str, captures: &HashMap<String, CapturedNode>) -> String {
    let mut result = template.to_string();

    for (name, node) in captures {
        // Determine the placeholder format
        let placeholder = if name.starts_with('$') {
            name.clone()
        } else {
            format!("${}", name)
        };

        // Order matters: replace braced versions first, then bare placeholders
        // to avoid partial replacements like {$VAR} -> {value}

        // Support {$VAR.text} syntax (explicit .text access)
        let text_access = format!("{{{}.text}}", placeholder);
        result = result.replace(&text_access, &node.text);

        // Also support {$VAR} syntax
        let braced = format!("{{{}}}", placeholder);
        result = result.replace(&braced, &node.text);

        // Replace bare $VAR with captured text
        result = result.replace(&placeholder, &node.text);
    }

    result
}

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

    #[test]
    fn test_interpolate_message() {
        let mut captures = HashMap::new();
        captures.insert(
            "$UNWRAP".to_string(),
            CapturedNode::new(Span::point(1, 1), "result.unwrap()"),
        );
        captures.insert(
            "$RECEIVER".to_string(),
            CapturedNode::new(Span::point(1, 1), "result"),
        );

        let msg = interpolate_message("Found $UNWRAP on receiver $RECEIVER", &captures);
        assert_eq!(msg, "Found result.unwrap() on receiver result");

        let msg2 = interpolate_message("Replace {$UNWRAP} with {$RECEIVER}?", &captures);
        assert_eq!(msg2, "Replace result.unwrap() with result?");
    }

    #[test]
    fn test_diagnostic_format_clippy() {
        let diag = Diagnostic {
            severity: Severity::Warning,
            rule_id: "RL001".to_string(),
            message: "Avoid unwrap() in public function".to_string(),
            file_path: PathBuf::from("src/lib.rs"),
            span: Span::new(
                Position {
                    line: 42,
                    column: 10,
                },
                Position {
                    line: 42,
                    column: 25,
                },
            ),
            snippet: Some("    let x = result.unwrap();".to_string()),
            suggestion: Some("Use ? operator or expect()".to_string()),
            notes: vec!["Consider error handling".to_string()],
        };

        let output = diag.format_clippy();
        assert!(output.contains("warning[RL001]"));
        assert!(output.contains("src/lib.rs:42:10"));
        assert!(output.contains("Avoid unwrap()"));
        assert!(output.contains("help: Use ? operator"));
        assert!(output.contains("note: Consider error handling"));
    }

    #[test]
    fn test_diagnostic_from_match_result() {
        let result = MatchResult::matched().capture(
            "$UNWRAP",
            CapturedNode::new(
                Span::new(
                    Position {
                        line: 10,
                        column: 5,
                    },
                    Position {
                        line: 10,
                        column: 20,
                    },
                ),
                "x.unwrap()",
            ),
        );

        let mut result_with_rule = result;
        result_with_rule.rule_id = Some("RL001".to_string());
        result_with_rule.severity = Some(Severity::Warning);
        result_with_rule.message = Some("Found unwrap".to_string());

        let diag = Diagnostic::from_match_result(&result_with_rule, "src/main.rs", Some("$UNWRAP"));

        assert!(diag.is_some());
        let d = diag.unwrap();
        assert_eq!(d.rule_id, "RL001");
        assert_eq!(d.span.start.line, 10);
    }
}