Skip to main content

decy_parser/
diagnostic.rs

1//! Structured diagnostic reporting for C parse errors.
2//!
3//! Provides rustc-style error messages with source locations, code snippets,
4//! explanatory notes, and actionable fix suggestions extracted from clang.
5
6use colored::Colorize;
7use std::fmt;
8
9/// Severity level of a diagnostic.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12    /// Informational note
13    Note,
14    /// Compiler warning
15    Warning,
16    /// Compiler error (blocks compilation)
17    Error,
18    /// Fatal error (stops processing)
19    Fatal,
20}
21
22impl Severity {
23    /// Returns the display tag for this severity level.
24    pub fn tag(&self) -> &'static str {
25        match self {
26            Self::Note => "note",
27            Self::Warning => "warning",
28            Self::Error => "error",
29            Self::Fatal => "fatal",
30        }
31    }
32}
33
34impl fmt::Display for Severity {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "{}", self.tag())
37    }
38}
39
40/// Category of the error for the `error[category]` display.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ErrorCategory {
43    /// Parse/syntax error
44    Parse,
45    /// Type error
46    Type,
47    /// Semantic error
48    Semantic,
49    /// I/O error
50    Io,
51    /// Transpilation error
52    Transpile,
53    /// Internal error
54    Internal,
55}
56
57impl fmt::Display for ErrorCategory {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Parse => write!(f, "parse"),
61            Self::Type => write!(f, "type"),
62            Self::Semantic => write!(f, "semantic"),
63            Self::Io => write!(f, "io"),
64            Self::Transpile => write!(f, "transpile"),
65            Self::Internal => write!(f, "internal"),
66        }
67    }
68}
69
70/// A structured diagnostic extracted from the C parser (clang-sys).
71///
72/// Contains source location, code snippet, categorized explanation, and
73/// actionable fix suggestion, formatted in rustc-style output.
74#[derive(Debug, Clone)]
75pub struct Diagnostic {
76    /// Severity: Note, Warning, Error, Fatal
77    pub severity: Severity,
78    /// Error message from clang (e.g. "expected ')'")
79    pub message: String,
80    /// Source file path
81    pub file: Option<String>,
82    /// 1-based line number
83    pub line: Option<u32>,
84    /// 1-based column number
85    pub column: Option<u32>,
86    /// Clang diagnostic category (e.g. "Parse Issue")
87    pub category: Option<String>,
88    /// Suggested fixes from clang fix-its
89    pub fix_its: Vec<String>,
90    /// 3-line code snippet with caret indicator
91    pub snippet: Option<String>,
92    /// Explanatory note (WHY it failed)
93    pub note: Option<String>,
94    /// Actionable help (HOW to fix)
95    pub help: Option<String>,
96}
97
98impl Diagnostic {
99    /// Create a new diagnostic with the given severity and message.
100    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
101        Self {
102            severity,
103            message: message.into(),
104            file: None,
105            line: None,
106            column: None,
107            category: None,
108            fix_its: Vec::new(),
109            snippet: None,
110            note: None,
111            help: None,
112        }
113    }
114
115    /// Infer the error category from the clang category or message content.
116    pub fn error_category(&self) -> ErrorCategory {
117        if let Some(ref cat) = self.category {
118            let cat_lower = cat.to_lowercase();
119            if cat_lower.contains("parse") || cat_lower.contains("syntax") {
120                return ErrorCategory::Parse;
121            }
122            if cat_lower.contains("type") {
123                return ErrorCategory::Type;
124            }
125            if cat_lower.contains("semantic") {
126                return ErrorCategory::Semantic;
127            }
128        }
129
130        let msg = self.message.to_lowercase();
131        if msg.contains("expected") || msg.contains("unterminated") || msg.contains("extraneous") {
132            ErrorCategory::Parse
133        } else if msg.contains("incompatible") || msg.contains("implicit conversion") {
134            ErrorCategory::Type
135        } else if msg.contains("undeclared") || msg.contains("redefinition") {
136            ErrorCategory::Semantic
137        } else {
138            ErrorCategory::Parse
139        }
140    }
141
142    /// Build a 3-line code snippet with a caret pointing at the error column.
143    pub fn build_snippet(source: &str, line: u32, column: Option<u32>) -> Option<String> {
144        let lines: Vec<&str> = source.lines().collect();
145        let line_idx = line.checked_sub(1)? as usize;
146        if line_idx >= lines.len() {
147            return None;
148        }
149
150        let mut out = String::new();
151        let gutter_width = format!("{}", line_idx + 2).len().max(2);
152
153        // Line before (context)
154        if line_idx > 0 {
155            out.push_str(&format!(
156                "{:>width$}|    {}\n",
157                line_idx,
158                lines[line_idx - 1],
159                width = gutter_width
160            ));
161        }
162
163        // Error line
164        out.push_str(&format!(
165            "{:>width$}|    {}\n",
166            line_idx + 1,
167            lines[line_idx],
168            width = gutter_width
169        ));
170
171        // Caret line
172        if let Some(col) = column {
173            let col_idx = (col as usize).saturating_sub(1);
174            let padding = " ".repeat(col_idx);
175            out.push_str(&format!(
176                "{:>width$}|    {}^\n",
177                "",
178                padding,
179                width = gutter_width
180            ));
181        }
182
183        // Line after (context)
184        if line_idx + 1 < lines.len() {
185            out.push_str(&format!(
186                "{:>width$}|    {}\n",
187                line_idx + 2,
188                lines[line_idx + 1],
189                width = gutter_width
190            ));
191        }
192
193        Some(out)
194    }
195
196    /// Populate `note` and `help` based on common C error patterns.
197    pub fn infer_note_and_help(&mut self) {
198        let msg = self.message.to_lowercase();
199
200        if msg.contains("expected ')'") {
201            self.note = Some("Unclosed parenthesis in expression or function call.".into());
202            self.help = Some("Add the missing ')' to close the expression.".into());
203        } else if msg.contains("expected ';'") {
204            self.note = Some("Missing semicolon after statement.".into());
205            self.help = Some("Add ';' at the end of the statement.".into());
206        } else if msg.contains("use of undeclared identifier")
207            || msg.contains("undeclared identifier")
208        {
209            self.note = Some("Variable or function not declared in this scope.".into());
210            self.help =
211                Some("Declare the variable before use, or check for typos in the name.".into());
212        } else if msg.contains("implicit declaration of function") {
213            self.note = Some("Function called before it is declared.".into());
214            self.help = Some(
215                "Add a #include for the header that declares this function, or add a forward declaration.".into(),
216            );
217        } else if msg.contains("incompatible") && msg.contains("type") {
218            self.note = Some("Type mismatch in assignment or return value.".into());
219            self.help =
220                Some("Ensure the types match, or add an explicit cast if intentional.".into());
221        } else if msg.contains("expected '}'") {
222            self.note = Some("Unclosed brace in block or struct definition.".into());
223            self.help = Some("Add the missing '}' to close the block.".into());
224        } else if msg.contains("redefinition of") {
225            self.note = Some("This name was already defined earlier in the same scope.".into());
226            self.help = Some("Rename one of the definitions, or use a different scope.".into());
227        } else if msg.contains("expected expression") {
228            self.note =
229                Some("The parser expected a value or expression but found something else.".into());
230            self.help = Some("Check for missing operands or misplaced punctuation.".into());
231        } else if !self.fix_its.is_empty() {
232            self.help = Some(self.fix_its.join("; "));
233        }
234    }
235}
236
237impl fmt::Display for Diagnostic {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        // Line 1: error[category]: message
240        let category = self.error_category();
241        let header = format!("{}[{}]", self.severity.tag(), category);
242        let colored_header = match self.severity {
243            Severity::Error | Severity::Fatal => header.red().bold().to_string(),
244            Severity::Warning => header.yellow().bold().to_string(),
245            Severity::Note => header.cyan().bold().to_string(),
246        };
247        writeln!(f, "{}: {}", colored_header, self.message.bold())?;
248
249        // Line 2: --> file:line:col
250        if let Some(ref file) = self.file {
251            let loc = match (self.line, self.column) {
252                (Some(l), Some(c)) => format!("{}:{}:{}", file, l, c),
253                (Some(l), None) => format!("{}:{}", file, l),
254                _ => file.clone(),
255            };
256            writeln!(f, " {} {}", "-->".blue().bold(), loc)?;
257        }
258
259        // Code snippet
260        if let Some(ref snippet) = self.snippet {
261            // Color the gutter (pipe characters) blue
262            for line in snippet.lines() {
263                if let Some(pipe_pos) = line.find('|') {
264                    let gutter = &line[..=pipe_pos];
265                    let rest = &line[pipe_pos + 1..];
266                    if rest.trim() == "^" || rest.contains('^') {
267                        // Caret line: gutter blue, caret red
268                        writeln!(f, " {}{}", gutter.blue(), rest.red())?;
269                    } else {
270                        writeln!(f, " {}{}", gutter.blue(), rest)?;
271                    }
272                } else {
273                    writeln!(f, " {}", line)?;
274                }
275            }
276        }
277
278        // note: explanation
279        if let Some(ref note) = self.note {
280            writeln!(f, "  {}: {}", "note".cyan().bold(), note)?;
281        }
282
283        // help: suggestion
284        if let Some(ref help) = self.help {
285            writeln!(f, "  {}: {}", "help".green().bold(), help)?;
286        }
287
288        Ok(())
289    }
290}
291
292/// Error wrapping one or more diagnostics from the C parser.
293///
294/// Implements `std::error::Error` so it propagates through `anyhow::Result` chains
295/// and can be downcast in `main.rs` for rich formatting.
296#[derive(Debug)]
297pub struct DiagnosticError {
298    /// All diagnostics collected from the parse attempt
299    pub diagnostics: Vec<Diagnostic>,
300}
301
302impl DiagnosticError {
303    /// Create a new `DiagnosticError` with the given diagnostics.
304    pub fn new(diagnostics: Vec<Diagnostic>) -> Self {
305        Self { diagnostics }
306    }
307}
308
309impl fmt::Display for DiagnosticError {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        for diag in &self.diagnostics {
312            write!(f, "{}", diag)?;
313        }
314        let error_count = self
315            .diagnostics
316            .iter()
317            .filter(|d| d.severity >= Severity::Error)
318            .count();
319        if error_count > 0 {
320            write!(
321                f,
322                "{}",
323                format!(
324                    "aborting due to {} previous error{}",
325                    error_count,
326                    if error_count == 1 { "" } else { "s" }
327                )
328                .red()
329                .bold()
330            )?;
331        }
332        Ok(())
333    }
334}
335
336impl std::error::Error for DiagnosticError {}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_severity_ordering() {
344        assert!(Severity::Note < Severity::Warning);
345        assert!(Severity::Warning < Severity::Error);
346        assert!(Severity::Error < Severity::Fatal);
347    }
348
349    #[test]
350    fn test_severity_tags() {
351        assert_eq!(Severity::Note.tag(), "note");
352        assert_eq!(Severity::Warning.tag(), "warning");
353        assert_eq!(Severity::Error.tag(), "error");
354        assert_eq!(Severity::Fatal.tag(), "fatal");
355    }
356
357    #[test]
358    fn test_error_category_from_clang_category() {
359        let mut d = Diagnostic::new(Severity::Error, "expected ')'");
360        d.category = Some("Parse Issue".into());
361        assert_eq!(d.error_category(), ErrorCategory::Parse);
362
363        d.category = Some("Semantic Issue".into());
364        assert_eq!(d.error_category(), ErrorCategory::Semantic);
365    }
366
367    #[test]
368    fn test_error_category_from_message() {
369        let d = Diagnostic::new(Severity::Error, "expected ';' after expression");
370        assert_eq!(d.error_category(), ErrorCategory::Parse);
371
372        let d = Diagnostic::new(Severity::Error, "use of undeclared identifier 'x'");
373        assert_eq!(d.error_category(), ErrorCategory::Semantic);
374
375        let d = Diagnostic::new(Severity::Error, "incompatible pointer types");
376        assert_eq!(d.error_category(), ErrorCategory::Type);
377    }
378
379    #[test]
380    fn test_display_contains_header() {
381        let d = Diagnostic::new(Severity::Error, "expected ')'");
382        let output = format!("{}", d);
383        assert!(output.contains("error[parse]"));
384        assert!(output.contains("expected ')'"));
385    }
386
387    #[test]
388    fn test_display_contains_location() {
389        let mut d = Diagnostic::new(Severity::Error, "expected ')'");
390        d.file = Some("test.c".into());
391        d.line = Some(15);
392        d.column = Some(22);
393        let output = format!("{}", d);
394        assert!(output.contains("-->"));
395        assert!(output.contains("test.c:15:22"));
396    }
397
398    #[test]
399    fn test_display_contains_snippet() {
400        let mut d = Diagnostic::new(Severity::Error, "expected ')'");
401        d.snippet = Some("14|    int y = 10;\n15|    int x = foo(bar;\n  |                    ^\n16|    return 0;\n".into());
402        let output = format!("{}", d);
403        assert!(output.contains("|"));
404        assert!(output.contains("^"));
405    }
406
407    #[test]
408    fn test_display_contains_note_and_help() {
409        let mut d = Diagnostic::new(Severity::Error, "expected ')'");
410        d.note = Some("Unclosed parenthesis.".into());
411        d.help = Some("Add ')' to close.".into());
412        let output = format!("{}", d);
413        assert!(output.contains("note:"));
414        assert!(output.contains("help:"));
415        assert!(output.contains("Unclosed parenthesis."));
416        assert!(output.contains("Add ')' to close."));
417    }
418
419    #[test]
420    fn test_build_snippet_middle_of_file() {
421        let source = "line 1\nline 2\nline 3\nline 4\nline 5";
422        let snippet = Diagnostic::build_snippet(source, 3, Some(4)).unwrap();
423        assert!(snippet.contains("line 2")); // context before
424        assert!(snippet.contains("line 3")); // error line
425        assert!(snippet.contains("line 4")); // context after
426        assert!(snippet.contains("^")); // caret
427    }
428
429    #[test]
430    fn test_build_snippet_first_line() {
431        let source = "int x = foo(bar;\nint y = 10;";
432        let snippet = Diagnostic::build_snippet(source, 1, Some(16)).unwrap();
433        assert!(snippet.contains("int x = foo(bar;"));
434        assert!(snippet.contains("^"));
435        // No line before
436    }
437
438    #[test]
439    fn test_build_snippet_last_line() {
440        let source = "int y = 10;\nint x = foo(bar;";
441        let snippet = Diagnostic::build_snippet(source, 2, Some(16)).unwrap();
442        assert!(snippet.contains("int x = foo(bar;"));
443        assert!(snippet.contains("^"));
444        // No line after
445    }
446
447    #[test]
448    fn test_build_snippet_no_column() {
449        let source = "line 1\nline 2\nline 3";
450        let snippet = Diagnostic::build_snippet(source, 2, None).unwrap();
451        assert!(snippet.contains("line 2"));
452        assert!(!snippet.contains("^")); // No caret without column
453    }
454
455    #[test]
456    fn test_build_snippet_out_of_bounds() {
457        let source = "line 1\nline 2";
458        assert!(Diagnostic::build_snippet(source, 99, Some(1)).is_none());
459        assert!(Diagnostic::build_snippet(source, 0, Some(1)).is_none());
460    }
461
462    #[test]
463    fn test_infer_note_expected_paren() {
464        let mut d = Diagnostic::new(Severity::Error, "expected ')'");
465        d.infer_note_and_help();
466        assert!(d.note.is_some());
467        assert!(d.help.is_some());
468        assert!(d.note.unwrap().contains("parenthesis"));
469    }
470
471    #[test]
472    fn test_infer_note_expected_semicolon() {
473        let mut d = Diagnostic::new(Severity::Error, "expected ';' after expression");
474        d.infer_note_and_help();
475        assert!(d.note.unwrap().contains("semicolon"));
476    }
477
478    #[test]
479    fn test_infer_note_undeclared() {
480        let mut d = Diagnostic::new(Severity::Error, "use of undeclared identifier 'foo'");
481        d.infer_note_and_help();
482        assert!(d.note.unwrap().contains("not declared"));
483    }
484
485    #[test]
486    fn test_infer_note_falls_back_to_fix_its() {
487        let mut d = Diagnostic::new(Severity::Error, "some unusual error");
488        d.fix_its = vec!["insert ';'".into()];
489        d.infer_note_and_help();
490        assert!(d.help.unwrap().contains("insert ';'"));
491    }
492
493    #[test]
494    fn test_diagnostic_error_display() {
495        let d1 = Diagnostic::new(Severity::Error, "first error");
496        let d2 = Diagnostic::new(Severity::Error, "second error");
497        let err = DiagnosticError::new(vec![d1, d2]);
498        let output = format!("{}", err);
499        assert!(output.contains("first error"));
500        assert!(output.contains("second error"));
501        assert!(output.contains("2 previous errors"));
502    }
503
504    #[test]
505    fn test_diagnostic_error_single() {
506        let d = Diagnostic::new(Severity::Error, "only error");
507        let err = DiagnosticError::new(vec![d]);
508        let output = format!("{}", err);
509        assert!(output.contains("1 previous error"));
510        // No plural "s"
511        assert!(!output.contains("1 previous errors"));
512    }
513
514    #[test]
515    fn test_diagnostic_error_is_std_error() {
516        let err: Box<dyn std::error::Error> =
517            Box::new(DiagnosticError::new(vec![Diagnostic::new(
518                Severity::Error,
519                "test",
520            )]));
521        assert!(!err.to_string().is_empty());
522    }
523
524    #[test]
525    fn test_warning_severity_display() {
526        let d = Diagnostic::new(Severity::Warning, "implicit conversion loses precision");
527        let output = format!("{}", d);
528        assert!(output.contains("warning"));
529    }
530}