Skip to main content

polydat_grammar/
error.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Structured error types for the Polydat DSL with rich source context.
5//!
6//! Every error includes a source location (line:col), the relevant
7//! source text, and a clear message with suggestions where possible.
8
9use crate::lexer::Span;
10use std::fmt;
11
12/// Severity level for diagnostics.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Severity {
15    /// A fault that fails the compile.
16    Error,
17    /// A note that does not.
18    Warning,
19}
20
21/// A single diagnostic message with source context.
22#[derive(Debug, Clone)]
23pub struct Diagnostic {
24    /// Error or warning.
25    pub severity: Severity,
26    /// Where in the source.
27    pub span: Span,
28    /// The message.
29    pub message: String,
30    /// A suggested fix, if any.
31    pub hint: Option<String>,
32    /// The source line text (for display).
33    pub source_line: Option<String>,
34}
35
36impl Diagnostic {
37    /// An error at `span`.
38    pub fn error(span: Span, message: impl Into<String>) -> Self {
39        Self {
40            severity: Severity::Error,
41            span,
42            message: message.into(),
43            hint: None,
44            source_line: None,
45        }
46    }
47
48    /// A warning at `span`.
49    pub fn warning(span: Span, message: impl Into<String>) -> Self {
50        Self {
51            severity: Severity::Warning,
52            span,
53            message: message.into(),
54            hint: None,
55            source_line: None,
56        }
57    }
58
59    /// The same diagnostic with a hint.
60    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
61        self.hint = Some(hint.into());
62        self
63    }
64
65    /// The same diagnostic with the source line shown under it.
66    pub fn with_source_line(mut self, line: impl Into<String>) -> Self {
67        self.source_line = Some(line.into());
68        self
69    }
70}
71
72impl fmt::Display for Diagnostic {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        let severity = match self.severity {
75            Severity::Error => "error",
76            Severity::Warning => "warning",
77        };
78        write!(
79            f,
80            "{}:{}:{}: {}",
81            self.span.line, self.span.col, severity, self.message
82        )?;
83
84        if let Some(ref line) = self.source_line {
85            write!(f, "\n  | {line}")?;
86            // Underline the position
87            if self.span.col > 0 {
88                let padding = " ".repeat(self.span.col - 1);
89                write!(f, "\n  | {padding}^")?;
90            }
91        }
92
93        if let Some(ref hint) = self.hint {
94            write!(f, "\n  = hint: {hint}")?;
95        }
96
97        Ok(())
98    }
99}
100
101/// A collection of diagnostics from compilation.
102#[derive(Debug, Clone)]
103pub struct DiagnosticReport {
104    /// Every diagnostic recorded, in order.
105    pub diagnostics: Vec<Diagnostic>,
106    /// The original source text (for extracting source lines).
107    source_lines: Vec<String>,
108}
109
110impl DiagnosticReport {
111    /// An empty report over `source`, whose lines the diagnostics quote.
112    pub fn new(source: &str) -> Self {
113        Self {
114            diagnostics: Vec::new(),
115            source_lines: source.lines().map(|l| l.to_string()).collect(),
116        }
117    }
118
119    /// Record an error at `span`, quoting its source line.
120    pub fn error(&mut self, span: Span, message: impl Into<String>) {
121        let mut diag = Diagnostic::error(span, message);
122        if span.line > 0 && span.line <= self.source_lines.len() {
123            diag.source_line = Some(self.source_lines[span.line - 1].clone());
124        }
125        self.diagnostics.push(diag);
126    }
127
128    /// Record an error at `span` with a hint, quoting its source line.
129    pub fn error_with_hint(
130        &mut self,
131        span: Span,
132        message: impl Into<String>,
133        hint: impl Into<String>,
134    ) {
135        let mut diag = Diagnostic::error(span, message).with_hint(hint);
136        if span.line > 0 && span.line <= self.source_lines.len() {
137            diag.source_line = Some(self.source_lines[span.line - 1].clone());
138        }
139        self.diagnostics.push(diag);
140    }
141
142    /// Record a warning at `span`, quoting its source line.
143    pub fn warning(&mut self, span: Span, message: impl Into<String>) {
144        let mut diag = Diagnostic::warning(span, message);
145        if span.line > 0 && span.line <= self.source_lines.len() {
146            diag.source_line = Some(self.source_lines[span.line - 1].clone());
147        }
148        self.diagnostics.push(diag);
149    }
150
151    /// Record a warning at `span` with a hint, quoting its source line.
152    pub fn warning_with_hint(
153        &mut self,
154        span: Span,
155        message: impl Into<String>,
156        hint: impl Into<String>,
157    ) {
158        let mut diag = Diagnostic::warning(span, message).with_hint(hint);
159        if span.line > 0 && span.line <= self.source_lines.len() {
160            diag.source_line = Some(self.source_lines[span.line - 1].clone());
161        }
162        self.diagnostics.push(diag);
163    }
164
165    /// Whether any diagnostic is an error.
166    pub fn has_errors(&self) -> bool {
167        self.diagnostics
168            .iter()
169            .any(|d| d.severity == Severity::Error)
170    }
171
172    /// The errors, in order.
173    pub fn errors(&self) -> Vec<&Diagnostic> {
174        self.diagnostics
175            .iter()
176            .filter(|d| d.severity == Severity::Error)
177            .collect()
178    }
179
180    /// The warnings, in order.
181    pub fn warnings(&self) -> Vec<&Diagnostic> {
182        self.diagnostics
183            .iter()
184            .filter(|d| d.severity == Severity::Warning)
185            .collect()
186    }
187}
188
189impl fmt::Display for DiagnosticReport {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        for (i, diag) in self.diagnostics.iter().enumerate() {
192            if i > 0 {
193                writeln!(f)?;
194            }
195            write!(f, "{diag}")?;
196        }
197        Ok(())
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn diagnostic_display() {
207        let diag = Diagnostic::error(Span { line: 3, col: 12 }, "unknown function 'foobar'")
208            .with_hint("did you mean 'hash'?")
209            .with_source_line("  result := foobar(cycle)");
210        let s = diag.to_string();
211        assert!(s.contains("3:12:error"));
212        assert!(s.contains("unknown function"));
213        assert!(s.contains("foobar(cycle)"));
214        assert!(s.contains("did you mean"));
215    }
216
217    #[test]
218    fn report_collects() {
219        let mut report = DiagnosticReport::new("line1\nline2\nline3");
220        report.error(Span { line: 1, col: 1 }, "first error");
221        report.warning(Span { line: 2, col: 5 }, "a warning");
222        report.error(Span { line: 3, col: 1 }, "second error");
223        assert!(report.has_errors());
224        assert_eq!(report.errors().len(), 2);
225        assert_eq!(report.warnings().len(), 1);
226    }
227
228    #[test]
229    fn report_includes_source_line() {
230        let mut report = DiagnosticReport::new("input cycle: u64\nbad := ???");
231        report.error(Span { line: 2, col: 8 }, "unexpected token");
232        let s = report.to_string();
233        assert!(s.contains("bad := ???"));
234    }
235}