joule_common/
diagnostic.rs

1//! Diagnostic messages (errors, warnings, notes)
2//!
3//! This module provides types for reporting compiler diagnostics to the user.
4
5use crate::span::Span;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Severity level of a diagnostic
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum Severity {
12    /// An error that prevents compilation
13    Error,
14    /// A warning that doesn't prevent compilation
15    Warning,
16    /// An informational message
17    Info,
18    /// A note (usually attached to another diagnostic)
19    Note,
20}
21
22impl fmt::Display for Severity {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Error => write!(f, "error"),
26            Self::Warning => write!(f, "warning"),
27            Self::Info => write!(f, "info"),
28            Self::Note => write!(f, "note"),
29        }
30    }
31}
32
33/// A label within a diagnostic (highlights a specific span)
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Label {
36    pub span: Span,
37    pub message: Option<String>,
38    pub style: LabelStyle,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum LabelStyle {
43    Primary,
44    Secondary,
45}
46
47impl Label {
48    pub fn primary(span: Span, message: impl Into<String>) -> Self {
49        Self {
50            span,
51            message: Some(message.into()),
52            style: LabelStyle::Primary,
53        }
54    }
55
56    pub fn secondary(span: Span, message: impl Into<String>) -> Self {
57        Self {
58            span,
59            message: Some(message.into()),
60            style: LabelStyle::Secondary,
61        }
62    }
63
64    pub const fn primary_unlabeled(span: Span) -> Self {
65        Self {
66            span,
67            message: None,
68            style: LabelStyle::Primary,
69        }
70    }
71}
72
73/// A compiler diagnostic
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Diagnostic {
76    pub severity: Severity,
77    pub message: String,
78    pub labels: Vec<Label>,
79    pub notes: Vec<String>,
80    pub code: Option<String>,
81}
82
83impl Diagnostic {
84    /// Create a new error diagnostic
85    pub fn error(message: impl Into<String>) -> Self {
86        Self {
87            severity: Severity::Error,
88            message: message.into(),
89            labels: Vec::new(),
90            notes: Vec::new(),
91            code: None,
92        }
93    }
94
95    /// Create a new warning diagnostic
96    pub fn warning(message: impl Into<String>) -> Self {
97        Self {
98            severity: Severity::Warning,
99            message: message.into(),
100            labels: Vec::new(),
101            notes: Vec::new(),
102            code: None,
103        }
104    }
105
106    /// Create a new info diagnostic
107    pub fn info(message: impl Into<String>) -> Self {
108        Self {
109            severity: Severity::Info,
110            message: message.into(),
111            labels: Vec::new(),
112            notes: Vec::new(),
113            code: None,
114        }
115    }
116
117    /// Add a label to this diagnostic
118    pub fn with_label(mut self, label: Label) -> Self {
119        self.labels.push(label);
120        self
121    }
122
123    /// Add multiple labels to this diagnostic
124    pub fn with_labels(mut self, labels: impl IntoIterator<Item = Label>) -> Self {
125        self.labels.extend(labels);
126        self
127    }
128
129    /// Add a note to this diagnostic
130    pub fn with_note(mut self, note: impl Into<String>) -> Self {
131        self.notes.push(note.into());
132        self
133    }
134
135    /// Set an error code
136    pub fn with_code(mut self, code: impl Into<String>) -> Self {
137        self.code = Some(code.into());
138        self
139    }
140
141    /// Check if this is an error
142    pub fn is_error(&self) -> bool {
143        self.severity == Severity::Error
144    }
145
146    /// Check if this is a warning
147    pub fn is_warning(&self) -> bool {
148        self.severity == Severity::Warning
149    }
150}
151
152impl fmt::Display for Diagnostic {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        if let Some(code) = &self.code {
155            write!(f, "{} [{}]: {}", self.severity, code, self.message)?;
156        } else {
157            write!(f, "{}: {}", self.severity, self.message)?;
158        }
159
160        for note in &self.notes {
161            write!(f, "\n  note: {note}")?;
162        }
163
164        Ok(())
165    }
166}
167
168/// Compute the Levenshtein edit distance between two strings.
169pub fn edit_distance(a: &str, b: &str) -> usize {
170    let a_len = a.len();
171    let b_len = b.len();
172    if a_len == 0 {
173        return b_len;
174    }
175    if b_len == 0 {
176        return a_len;
177    }
178
179    let mut prev: Vec<usize> = (0..=b_len).collect();
180    let mut curr = vec![0; b_len + 1];
181
182    for (i, ca) in a.chars().enumerate() {
183        curr[0] = i + 1;
184        for (j, cb) in b.chars().enumerate() {
185            let cost = if ca == cb { 0 } else { 1 };
186            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
187        }
188        std::mem::swap(&mut prev, &mut curr);
189    }
190    prev[b_len]
191}
192
193/// Find the most similar string from a list of candidates.
194///
195/// Returns the closest match if within the edit distance threshold:
196/// - For names <= 5 characters: threshold is 2
197/// - For longer names: threshold is 3
198pub fn suggest_similar<'a>(query: &str, candidates: &[&'a str]) -> Option<&'a str> {
199    if query.is_empty() || candidates.is_empty() {
200        return None;
201    }
202    let max_distance = if query.len() <= 5 { 2 } else { 3 };
203    candidates
204        .iter()
205        .filter(|c| **c != query) // don't suggest the same name
206        .filter_map(|c| {
207            let d = edit_distance(query, c);
208            if d <= max_distance && d > 0 {
209                Some((*c, d))
210            } else {
211                None
212            }
213        })
214        .min_by_key(|(_, d)| *d)
215        .map(|(c, _)| c)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_diagnostic_error() {
224        let diag = Diagnostic::error("type mismatch")
225            .with_code("E0308")
226            .with_label(Label::primary(Span::new(10, 20, 0), "expected i32"))
227            .with_note("this is a note");
228
229        assert!(diag.is_error());
230        assert_eq!(diag.code, Some("E0308".to_string()));
231        assert_eq!(diag.labels.len(), 1);
232        assert_eq!(diag.notes.len(), 1);
233    }
234
235    #[test]
236    fn test_diagnostic_warning() {
237        let diag = Diagnostic::warning("unused variable");
238        assert!(diag.is_warning());
239        assert!(!diag.is_error());
240    }
241
242    #[test]
243    fn test_diagnostic_display() {
244        let diag = Diagnostic::error("test error").with_code("E0001");
245        let display = format!("{}", diag);
246        assert!(display.contains("error"));
247        assert!(display.contains("E0001"));
248        assert!(display.contains("test error"));
249    }
250
251    #[test]
252    fn test_edit_distance() {
253        assert_eq!(edit_distance("", ""), 0);
254        assert_eq!(edit_distance("abc", "abc"), 0);
255        assert_eq!(edit_distance("abc", "abd"), 1);
256        assert_eq!(edit_distance("abc", "ab"), 1);
257        assert_eq!(edit_distance("kitten", "sitting"), 3);
258    }
259
260    #[test]
261    fn test_suggest_similar() {
262        let candidates = &["count", "counter", "amount", "total"];
263        assert_eq!(suggest_similar("cont", candidates), Some("count"));
264        assert_eq!(suggest_similar("amout", candidates), Some("amount"));
265        assert_eq!(suggest_similar("xyz", candidates), None);
266        assert_eq!(suggest_similar("count", candidates), Some("counter")); // exact match excluded, nearest non-exact returned
267    }
268}