Skip to main content

ryo_pattern/
diagnostic.rs

1//! Diagnostic output for RyoPattern
2//!
3//! Provides Clippy-compatible diagnostic formatting.
4
5use crate::{CapturedNode, MatchResult, Severity, Span};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9/// A diagnostic message (Clippy-compatible)
10#[derive(Debug, Clone)]
11pub struct Diagnostic {
12    /// Severity level
13    pub severity: Severity,
14
15    /// Rule ID (e.g., "RL001")
16    pub rule_id: String,
17
18    /// Primary message
19    pub message: String,
20
21    /// Source file path
22    pub file_path: PathBuf,
23
24    /// Primary span
25    pub span: Span,
26
27    /// Code snippet with context
28    pub snippet: Option<String>,
29
30    /// Suggestion/help text
31    pub suggestion: Option<String>,
32
33    /// Additional notes
34    pub notes: Vec<String>,
35}
36
37impl Diagnostic {
38    /// Create a new diagnostic from a MatchResult
39    pub fn from_match_result(
40        result: &MatchResult,
41        file_path: impl Into<PathBuf>,
42        primary_capture: Option<&str>,
43    ) -> Option<Self> {
44        if !result.matched {
45            return None;
46        }
47
48        let span = primary_capture
49            .and_then(|name| result.captures.get(name))
50            .or_else(|| result.captures.values().next())
51            .map(|c| c.span)
52            .unwrap_or(Span::point(1, 1));
53
54        Some(Self {
55            severity: result.severity.unwrap_or(Severity::Warning),
56            rule_id: result.rule_id.clone().unwrap_or_else(|| "unknown".into()),
57            message: result.message.clone().unwrap_or_default(),
58            file_path: file_path.into(),
59            span,
60            snippet: None,
61            suggestion: result.suggestion.clone(),
62            notes: Vec::new(),
63        })
64    }
65
66    /// Add a code snippet
67    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
68        self.snippet = Some(snippet.into());
69        self
70    }
71
72    /// Add a note
73    pub fn with_note(mut self, note: impl Into<String>) -> Self {
74        self.notes.push(note.into());
75        self
76    }
77
78    /// Format as Clippy-style output
79    pub fn format_clippy(&self) -> String {
80        let mut output = String::new();
81
82        // Header: severity[rule_id]: message
83        output.push_str(&format!(
84            "{}[{}]: {}\n",
85            self.severity, self.rule_id, self.message
86        ));
87
88        // Location
89        output.push_str(&format!(
90            "  --> {}:{}:{}\n",
91            self.file_path.display(),
92            self.span.start.line,
93            self.span.start.column
94        ));
95
96        // Snippet if available
97        if let Some(ref snippet) = self.snippet {
98            output.push_str("   |\n");
99            for (i, line) in snippet.lines().enumerate() {
100                let line_num = self.span.start.line as usize + i;
101                output.push_str(&format!("{:>3} | {}\n", line_num, line));
102            }
103            output.push_str("   |\n");
104        }
105
106        // Suggestion
107        if let Some(ref suggestion) = self.suggestion {
108            output.push_str(&format!("   = help: {}\n", suggestion));
109        }
110
111        // Notes
112        for note in &self.notes {
113            output.push_str(&format!("   = note: {}\n", note));
114        }
115
116        output
117    }
118
119    /// Format as JSON for tooling integration
120    pub fn format_json(&self) -> String {
121        serde_json::to_string(&DiagnosticJson::from(self)).unwrap_or_default()
122    }
123}
124
125/// JSON-serializable diagnostic
126#[derive(Debug, Clone, serde::Serialize)]
127struct DiagnosticJson {
128    severity: String,
129    rule_id: String,
130    message: String,
131    file_path: String,
132    line: u32,
133    column: u32,
134    end_line: u32,
135    end_column: u32,
136    suggestion: Option<String>,
137    notes: Vec<String>,
138}
139
140impl From<&Diagnostic> for DiagnosticJson {
141    fn from(d: &Diagnostic) -> Self {
142        Self {
143            severity: d.severity.to_string(),
144            rule_id: d.rule_id.clone(),
145            message: d.message.clone(),
146            file_path: d.file_path.to_string_lossy().to_string(),
147            line: d.span.start.line,
148            column: d.span.start.column,
149            end_line: d.span.end.line,
150            end_column: d.span.end.column,
151            suggestion: d.suggestion.clone(),
152            notes: d.notes.clone(),
153        }
154    }
155}
156
157/// Message interpolation for captured variables
158pub fn interpolate_message(template: &str, captures: &HashMap<String, CapturedNode>) -> String {
159    let mut result = template.to_string();
160
161    for (name, node) in captures {
162        // Determine the placeholder format
163        let placeholder = if name.starts_with('$') {
164            name.clone()
165        } else {
166            format!("${}", name)
167        };
168
169        // Order matters: replace braced versions first, then bare placeholders
170        // to avoid partial replacements like {$VAR} -> {value}
171
172        // Support {$VAR.text} syntax (explicit .text access)
173        let text_access = format!("{{{}.text}}", placeholder);
174        result = result.replace(&text_access, &node.text);
175
176        // Also support {$VAR} syntax
177        let braced = format!("{{{}}}", placeholder);
178        result = result.replace(&braced, &node.text);
179
180        // Replace bare $VAR with captured text
181        result = result.replace(&placeholder, &node.text);
182    }
183
184    result
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::Position;
191
192    #[test]
193    fn test_interpolate_message() {
194        let mut captures = HashMap::new();
195        captures.insert(
196            "$UNWRAP".to_string(),
197            CapturedNode::new(Span::point(1, 1), "result.unwrap()"),
198        );
199        captures.insert(
200            "$RECEIVER".to_string(),
201            CapturedNode::new(Span::point(1, 1), "result"),
202        );
203
204        let msg = interpolate_message("Found $UNWRAP on receiver $RECEIVER", &captures);
205        assert_eq!(msg, "Found result.unwrap() on receiver result");
206
207        let msg2 = interpolate_message("Replace {$UNWRAP} with {$RECEIVER}?", &captures);
208        assert_eq!(msg2, "Replace result.unwrap() with result?");
209    }
210
211    #[test]
212    fn test_diagnostic_format_clippy() {
213        let diag = Diagnostic {
214            severity: Severity::Warning,
215            rule_id: "RL001".to_string(),
216            message: "Avoid unwrap() in public function".to_string(),
217            file_path: PathBuf::from("src/lib.rs"),
218            span: Span::new(
219                Position {
220                    line: 42,
221                    column: 10,
222                },
223                Position {
224                    line: 42,
225                    column: 25,
226                },
227            ),
228            snippet: Some("    let x = result.unwrap();".to_string()),
229            suggestion: Some("Use ? operator or expect()".to_string()),
230            notes: vec!["Consider error handling".to_string()],
231        };
232
233        let output = diag.format_clippy();
234        assert!(output.contains("warning[RL001]"));
235        assert!(output.contains("src/lib.rs:42:10"));
236        assert!(output.contains("Avoid unwrap()"));
237        assert!(output.contains("help: Use ? operator"));
238        assert!(output.contains("note: Consider error handling"));
239    }
240
241    #[test]
242    fn test_diagnostic_from_match_result() {
243        let result = MatchResult::matched().capture(
244            "$UNWRAP",
245            CapturedNode::new(
246                Span::new(
247                    Position {
248                        line: 10,
249                        column: 5,
250                    },
251                    Position {
252                        line: 10,
253                        column: 20,
254                    },
255                ),
256                "x.unwrap()",
257            ),
258        );
259
260        let mut result_with_rule = result;
261        result_with_rule.rule_id = Some("RL001".to_string());
262        result_with_rule.severity = Some(Severity::Warning);
263        result_with_rule.message = Some("Found unwrap".to_string());
264
265        let diag = Diagnostic::from_match_result(&result_with_rule, "src/main.rs", Some("$UNWRAP"));
266
267        assert!(diag.is_some());
268        let d = diag.unwrap();
269        assert_eq!(d.rule_id, "RL001");
270        assert_eq!(d.span.start.line, 10);
271    }
272}