Skip to main content

forgekit_core/diagnostic/
mod.rs

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}