1use crate::{CapturedNode, MatchResult, Severity, Span};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
11pub struct Diagnostic {
12 pub severity: Severity,
14
15 pub rule_id: String,
17
18 pub message: String,
20
21 pub file_path: PathBuf,
23
24 pub span: Span,
26
27 pub snippet: Option<String>,
29
30 pub suggestion: Option<String>,
32
33 pub notes: Vec<String>,
35}
36
37impl Diagnostic {
38 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 pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
68 self.snippet = Some(snippet.into());
69 self
70 }
71
72 pub fn with_note(mut self, note: impl Into<String>) -> Self {
74 self.notes.push(note.into());
75 self
76 }
77
78 pub fn format_clippy(&self) -> String {
80 let mut output = String::new();
81
82 output.push_str(&format!(
84 "{}[{}]: {}\n",
85 self.severity, self.rule_id, self.message
86 ));
87
88 output.push_str(&format!(
90 " --> {}:{}:{}\n",
91 self.file_path.display(),
92 self.span.start.line,
93 self.span.start.column
94 ));
95
96 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 if let Some(ref suggestion) = self.suggestion {
108 output.push_str(&format!(" = help: {}\n", suggestion));
109 }
110
111 for note in &self.notes {
113 output.push_str(&format!(" = note: {}\n", note));
114 }
115
116 output
117 }
118
119 pub fn format_json(&self) -> String {
121 serde_json::to_string(&DiagnosticJson::from(self)).unwrap_or_default()
122 }
123}
124
125#[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
157pub 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 let placeholder = if name.starts_with('$') {
164 name.clone()
165 } else {
166 format!("${}", name)
167 };
168
169 let text_access = format!("{{{}.text}}", placeholder);
174 result = result.replace(&text_access, &node.text);
175
176 let braced = format!("{{{}}}", placeholder);
178 result = result.replace(&braced, &node.text);
179
180 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}