1use std::path::PathBuf;
2
3use crate::types::Span;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Diagnostic {
7 pub severity: DiagnosticSeverity,
8 pub code: Option<String>,
9 pub message: String,
10 pub source: DiagnosticSource,
11 pub location: Option<DiagnosticLocation>,
12 pub related: Vec<RelatedInfo>,
13 pub fixes: Vec<FixSuggestion>,
14}
15
16impl Diagnostic {
17 pub fn error(message: impl Into<String>) -> Self {
18 Self {
19 severity: DiagnosticSeverity::Error,
20 code: None,
21 message: message.into(),
22 source: DiagnosticSource::Unknown,
23 location: None,
24 related: Vec::new(),
25 fixes: Vec::new(),
26 }
27 }
28
29 pub fn warning(message: impl Into<String>) -> Self {
30 Self {
31 severity: DiagnosticSeverity::Warning,
32 code: None,
33 message: message.into(),
34 source: DiagnosticSource::Unknown,
35 location: None,
36 related: Vec::new(),
37 fixes: Vec::new(),
38 }
39 }
40
41 pub fn with_code(mut self, code: impl Into<String>) -> Self {
42 self.code = Some(code.into());
43 self
44 }
45
46 pub fn with_source(mut self, source: DiagnosticSource) -> Self {
47 self.source = source;
48 self
49 }
50
51 pub fn with_location(mut self, loc: DiagnosticLocation) -> Self {
52 self.location = Some(loc);
53 self
54 }
55
56 pub fn with_fix(mut self, fix: FixSuggestion) -> Self {
57 self.fixes.push(fix);
58 self
59 }
60
61 pub fn with_related(mut self, info: RelatedInfo) -> Self {
62 self.related.push(info);
63 self
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DiagnosticSeverity {
69 Error,
70 Warning,
71 Info,
72 Hint,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum DiagnosticSource {
77 Compiler(String),
78 Linter(String),
79 TestRunner,
80 TypeChecker,
81 GraphAnalysis,
82 Unknown,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct DiagnosticLocation {
87 pub file: PathBuf,
88 pub line: usize,
89 pub column: Option<usize>,
90 pub span: Option<Span>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct RelatedInfo {
95 pub message: String,
96 pub file: PathBuf,
97 pub line: usize,
98 pub column: Option<usize>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FixSuggestion {
103 pub title: String,
104 pub edits: Vec<TextEdit>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct TextEdit {
109 pub file: PathBuf,
110 pub range: Span,
111 pub new_text: String,
112}
113
114pub trait DiagnosticParser: Send + Sync {
115 fn parse(&self, stdout: &str, stderr: &str) -> Vec<Diagnostic>;
116}
117
118pub struct CargoDiagnosticParser;
119
120impl DiagnosticParser for CargoDiagnosticParser {
121 fn parse(&self, _stdout: &str, stderr: &str) -> Vec<Diagnostic> {
122 let mut diagnostics = Vec::new();
123
124 for line in stderr.lines() {
125 if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
126 if let Some(reason) = value.get("reason").and_then(|r| r.as_str()) {
127 if reason == "compiler-message" {
128 if let Some(msg) = value.get("message") {
129 if let Some(d) = parse_cargo_message(msg) {
130 diagnostics.push(d);
131 }
132 }
133 }
134 }
135 } else if is_generic_rust_error(line) {
136 if let Some(d) = parse_rustc_line(line) {
137 diagnostics.push(d);
138 }
139 }
140 }
141
142 diagnostics
143 }
144}
145
146fn parse_cargo_message(msg: &serde_json::Value) -> Option<Diagnostic> {
147 let message = msg.get("message")?.as_str()?.to_string();
148 let level = msg.get("level")?.as_str().unwrap_or("error");
149 let code = msg
150 .get("code")
151 .and_then(|c| c.get("code"))
152 .and_then(|c| c.as_str())
153 .map(|s| s.to_string());
154
155 let severity = match level {
156 "error" => DiagnosticSeverity::Error,
157 "warning" => DiagnosticSeverity::Warning,
158 "note" => DiagnosticSeverity::Info,
159 _ => DiagnosticSeverity::Hint,
160 };
161
162 let spans = msg
163 .get("spans")
164 .and_then(|s| s.as_array())
165 .map(|arr| {
166 arr.iter()
167 .filter_map(|s| {
168 let file = s.get("file_name")?.as_str()?;
169 let line = s.get("line_start")?.as_u64()? as usize;
170 let column = s
171 .get("column_start")
172 .and_then(|c| c.as_u64())
173 .map(|c| c as usize);
174 Some(DiagnosticLocation {
175 file: PathBuf::from(file),
176 line,
177 column,
178 span: None,
179 })
180 })
181 .collect::<Vec<_>>()
182 })
183 .unwrap_or_default();
184
185 let location = spans.into_iter().next();
186
187 let children = msg
188 .get("children")
189 .and_then(|c| c.as_array())
190 .map(|arr| {
191 arr.iter()
192 .filter_map(|child| {
193 let child_msg = child.get("message")?.as_str()?.to_string();
194 Some(RelatedInfo {
195 message: child_msg,
196 file: PathBuf::new(),
197 line: 0,
198 column: None,
199 })
200 })
201 .collect::<Vec<_>>()
202 })
203 .unwrap_or_default();
204
205 Some(Diagnostic {
206 severity,
207 code,
208 message,
209 source: DiagnosticSource::Compiler("rustc".to_string()),
210 location,
211 related: children,
212 fixes: Vec::new(),
213 })
214}
215
216fn is_generic_rust_error(line: &str) -> bool {
217 let re = regex::Regex::new(r"^error\[E\d{4}\]:|^error:|^warning:").unwrap();
218 re.is_match(line)
219}
220
221fn parse_rustc_line(line: &str) -> Option<Diagnostic> {
222 let re = regex::Regex::new(r"^(error|warning)\[(E\d+)\]: (.+)").unwrap();
223 let caps = re.captures(line)?;
224
225 let level = caps.get(1)?.as_str();
226 let code = caps.get(2)?.as_str().to_string();
227 let message = caps.get(3)?.as_str().to_string();
228
229 let severity = if level == "error" {
230 DiagnosticSeverity::Error
231 } else {
232 DiagnosticSeverity::Warning
233 };
234
235 Some(
236 Diagnostic::error(message)
237 .with_code(code)
238 .with_source(DiagnosticSource::Compiler("rustc".to_string())),
239 )
240 .map(|mut d| {
241 d.severity = severity;
242 d
243 })
244}
245
246pub struct GoDiagnosticParser;
247
248impl DiagnosticParser for GoDiagnosticParser {
249 fn parse(&self, _stdout: &str, stderr: &str) -> Vec<Diagnostic> {
250 let mut diagnostics = Vec::new();
251 let re =
252 regex::Regex::new(r"^(?P<file>[^:\s]+):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)")
253 .unwrap();
254
255 for line in stderr.lines() {
256 if let Some(caps) = re.captures(line) {
257 let file = caps.name("file").map(|m| m.as_str()).unwrap_or("");
258 let line_num = caps
259 .name("line")
260 .and_then(|m| m.as_str().parse::<usize>().ok())
261 .unwrap_or(0);
262 let col = caps
263 .name("col")
264 .and_then(|m| m.as_str().parse::<usize>().ok());
265 let message = caps
266 .name("message")
267 .map(|m| m.as_str().trim().to_string())
268 .unwrap_or_default();
269
270 let severity = if message.starts_with("cannot")
271 || message.starts_with("undefined")
272 || message.starts_with("syntax error")
273 {
274 DiagnosticSeverity::Error
275 } else {
276 DiagnosticSeverity::Warning
277 };
278
279 diagnostics.push(Diagnostic {
280 severity,
281 code: None,
282 message,
283 source: DiagnosticSource::Compiler("go".to_string()),
284 location: Some(DiagnosticLocation {
285 file: PathBuf::from(file),
286 line: line_num,
287 column: col,
288 span: None,
289 }),
290 related: Vec::new(),
291 fixes: Vec::new(),
292 });
293 }
294 }
295
296 diagnostics
297 }
298}
299
300pub struct GenericDiagnosticParser {
301 pub tool_name: String,
302}
303
304impl DiagnosticParser for GenericDiagnosticParser {
305 fn parse(&self, _stdout: &str, stderr: &str) -> Vec<Diagnostic> {
306 let mut diagnostics = Vec::new();
307 let re = regex::Regex::new(
308 r"^(?P<file>[^:\s]+):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.+)",
309 )
310 .unwrap();
311
312 for line in stderr.lines() {
313 if let Some(caps) = re.captures(line) {
314 let file = caps.name("file").map(|m| m.as_str()).unwrap_or("");
315 let line_num = caps
316 .name("line")
317 .and_then(|m| m.as_str().parse::<usize>().ok())
318 .unwrap_or(0);
319 let col = caps
320 .name("col")
321 .and_then(|m| m.as_str().parse::<usize>().ok());
322 let message = caps
323 .name("message")
324 .map(|m| m.as_str().trim().to_string())
325 .unwrap_or_default();
326
327 let severity = if message.starts_with("error") {
328 DiagnosticSeverity::Error
329 } else if message.starts_with("warning") {
330 DiagnosticSeverity::Warning
331 } else {
332 DiagnosticSeverity::Info
333 };
334
335 diagnostics.push(Diagnostic {
336 severity,
337 code: None,
338 message,
339 source: DiagnosticSource::Compiler(self.tool_name.clone()),
340 location: Some(DiagnosticLocation {
341 file: PathBuf::from(file),
342 line: line_num,
343 column: col,
344 span: None,
345 }),
346 related: Vec::new(),
347 fixes: Vec::new(),
348 });
349 }
350 }
351
352 diagnostics
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_diagnostic_error_builder() {
362 let d = Diagnostic::error("something broke")
363 .with_code("E0001")
364 .with_source(DiagnosticSource::Compiler("rustc".to_string()));
365 assert_eq!(d.severity, DiagnosticSeverity::Error);
366 assert_eq!(d.code.as_deref(), Some("E0001"));
367 assert_eq!(d.message, "something broke");
368 }
369
370 #[test]
371 fn test_diagnostic_warning_builder() {
372 let d = Diagnostic::warning("unused variable");
373 assert_eq!(d.severity, DiagnosticSeverity::Warning);
374 assert!(d.code.is_none());
375 }
376
377 #[test]
378 fn test_diagnostic_with_location() {
379 let d = Diagnostic::error("bad code").with_location(DiagnosticLocation {
380 file: PathBuf::from("src/main.rs"),
381 line: 42,
382 column: Some(10),
383 span: None,
384 });
385 assert!(d.location.is_some());
386 let loc = d.location.unwrap();
387 assert_eq!(loc.line, 42);
388 }
389
390 #[test]
391 fn test_diagnostic_with_fix() {
392 let d = Diagnostic::error("missing semicolon").with_fix(FixSuggestion {
393 title: "Add semicolon".to_string(),
394 edits: vec![TextEdit {
395 file: PathBuf::from("foo.rs"),
396 range: Span { start: 10, end: 10 },
397 new_text: ";".to_string(),
398 }],
399 });
400 assert_eq!(d.fixes.len(), 1);
401 assert_eq!(d.fixes[0].title, "Add semicolon");
402 }
403
404 #[test]
405 fn test_cargo_parser_json_message() {
406 let json = r#"{"reason":"compiler-message","message":{"message":"cannot find value `x` in this scope","code":{"code":"E0425","explanation":""},"level":"error","spans":[{"file_name":"src/main.rs","byte_start":100,"byte_end":101,"line_start":5,"line_end":5,"column_start":12,"column_end":13}]}}"#;
407 let parser = CargoDiagnosticParser;
408 let diags = parser.parse("", json);
409 assert_eq!(diags.len(), 1);
410 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
411 assert_eq!(diags[0].code.as_deref(), Some("E0425"));
412 assert!(diags[0].location.is_some());
413 let loc = diags[0].location.as_ref().unwrap();
414 assert_eq!(loc.file, PathBuf::from("src/main.rs"));
415 assert_eq!(loc.line, 5);
416 }
417
418 #[test]
419 fn test_cargo_parser_rustc_line() {
420 let stderr = "error[E0425]: cannot find value `x` in this scope\n";
421 let parser = CargoDiagnosticParser;
422 let diags = parser.parse("", stderr);
423 assert_eq!(diags.len(), 1);
424 assert_eq!(diags[0].code.as_deref(), Some("E0425"));
425 }
426
427 #[test]
428 fn test_cargo_parser_ignores_noise() {
429 let stderr = " Compiling my-crate v0.1.0\n Finished dev profile\n";
430 let parser = CargoDiagnosticParser;
431 let diags = parser.parse("", stderr);
432 assert!(diags.is_empty());
433 }
434
435 #[test]
436 fn test_go_parser() {
437 let stderr =
438 "main.go:10:3: syntax error: unexpected }\nother.go:5:1: cannot find package\n";
439 let parser = GoDiagnosticParser;
440 let diags = parser.parse("", stderr);
441 assert_eq!(diags.len(), 2);
442 assert_eq!(diags[0].location.as_ref().unwrap().line, 10);
443 assert_eq!(diags[1].location.as_ref().unwrap().line, 5);
444 }
445
446 #[test]
447 fn test_go_parser_ignores_clean() {
448 let stderr = "build\n";
449 let parser = GoDiagnosticParser;
450 let diags = parser.parse("", stderr);
451 assert!(diags.is_empty());
452 }
453
454 #[test]
455 fn test_generic_parser() {
456 let stderr = "src/main.rs:42:10: error: undeclared identifier\nsrc/lib.rs:5:1: warning: unused import\n";
457 let parser = GenericDiagnosticParser {
458 tool_name: "cc".to_string(),
459 };
460 let diags = parser.parse("", stderr);
461 assert_eq!(diags.len(), 2);
462 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
463 assert_eq!(diags[1].severity, DiagnosticSeverity::Warning);
464 }
465
466 #[test]
467 fn test_generic_parser_no_column() {
468 let stderr = "main.c:15: implicit declaration of function\n";
469 let parser = GenericDiagnosticParser {
470 tool_name: "gcc".to_string(),
471 };
472 let diags = parser.parse("", stderr);
473 assert_eq!(diags.len(), 1);
474 let loc = diags[0].location.as_ref().unwrap();
475 assert_eq!(loc.line, 15);
476 assert!(loc.column.is_none());
477 }
478}