Skip to main content

drawlang_syntax/
diag.rs

1//! Diagnostics: structured errors/warnings with spans, rendered Rust-style
2//! (colored, with source excerpts and carets) or as JSON for agents.
3
4use crate::span::{LineCol, SourceFile, Span};
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Severity {
10    Error,
11    Warning,
12}
13
14/// A labeled span inside a diagnostic. The first label is primary (rendered
15/// with `^^^`), the rest are secondary (rendered with `---`).
16#[derive(Debug, Clone, Serialize)]
17pub struct Label {
18    pub span: Span,
19    pub message: String,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct Diagnostic {
24    pub severity: Severity,
25    /// Stable code like `E0214` or `W0301`. See `drawlang explain <code>`.
26    pub code: &'static str,
27    pub message: String,
28    pub labels: Vec<Label>,
29    pub help: Option<String>,
30    pub notes: Vec<String>,
31}
32
33impl Diagnostic {
34    pub fn error(code: &'static str, message: impl Into<String>) -> Self {
35        Diagnostic {
36            severity: Severity::Error,
37            code,
38            message: message.into(),
39            labels: Vec::new(),
40            help: None,
41            notes: Vec::new(),
42        }
43    }
44
45    pub fn warning(code: &'static str, message: impl Into<String>) -> Self {
46        Diagnostic {
47            severity: Severity::Warning,
48            ..Self::error(code, message)
49        }
50    }
51
52    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
53        self.labels.push(Label {
54            span,
55            message: message.into(),
56        });
57        self
58    }
59
60    pub fn with_help(mut self, help: impl Into<String>) -> Self {
61        self.help = Some(help.into());
62        self
63    }
64
65    pub fn with_note(mut self, note: impl Into<String>) -> Self {
66        self.notes.push(note.into());
67        self
68    }
69
70    pub fn primary_span(&self) -> Option<Span> {
71        self.labels.first().map(|l| l.span)
72    }
73}
74
75/// JSON-facing shape: spans resolved to line/col so agents need no math.
76#[derive(Debug, Serialize)]
77pub struct ResolvedDiagnostic<'a> {
78    pub severity: Severity,
79    pub code: &'static str,
80    pub message: &'a str,
81    pub file: &'a str,
82    pub labels: Vec<ResolvedLabel<'a>>,
83    pub help: Option<&'a str>,
84    pub notes: &'a [String],
85}
86
87#[derive(Debug, Serialize)]
88pub struct ResolvedLabel<'a> {
89    pub message: &'a str,
90    pub span: Span,
91    pub start: LineCol,
92    pub end: LineCol,
93    /// The exact source text the label points at.
94    pub text: &'a str,
95}
96
97pub fn resolve<'a>(d: &'a Diagnostic, src: &'a SourceFile) -> ResolvedDiagnostic<'a> {
98    ResolvedDiagnostic {
99        severity: d.severity,
100        code: d.code,
101        message: &d.message,
102        file: &src.name,
103        labels: d
104            .labels
105            .iter()
106            .map(|l| ResolvedLabel {
107                message: &l.message,
108                span: l.span,
109                start: src.line_col(l.span.start),
110                end: src.line_col(l.span.end),
111                text: src.snippet(l.span),
112            })
113            .collect(),
114        help: d.help.as_deref(),
115        notes: &d.notes,
116    }
117}
118
119// ---------------------------------------------------------------- rendering
120
121pub struct Styles {
122    bold: &'static str,
123    red: &'static str,
124    yellow: &'static str,
125    blue: &'static str,
126    green: &'static str,
127    reset: &'static str,
128}
129
130impl Styles {
131    pub fn colored() -> Self {
132        Styles {
133            bold: "\x1b[1m",
134            red: "\x1b[1;31m",
135            yellow: "\x1b[1;33m",
136            blue: "\x1b[1;34m",
137            green: "\x1b[1;32m",
138            reset: "\x1b[0m",
139        }
140    }
141
142    pub fn plain() -> Self {
143        Styles {
144            bold: "",
145            red: "",
146            yellow: "",
147            blue: "",
148            green: "",
149            reset: "",
150        }
151    }
152}
153
154/// Render one diagnostic in rustc style:
155///
156/// ```text
157/// error[E0214]: unknown port `pciex` on `gpus[0]`
158///   --> arch.drawl:42:18
159///    |
160/// 42 |   host.cpu -> gpus[0].pciex : "PCIe 5.0 x16"
161///    |               ^^^^^^^^^^^^^ component `gpu` has no port `pciex`
162///    |
163/// help: `gpu` defines ports `nvlink`, `pcie` — did you mean `pcie`?
164/// ```
165pub fn render(d: &Diagnostic, src: &SourceFile, st: &Styles) -> String {
166    let mut out = String::new();
167    let (sev_color, sev_name) = match d.severity {
168        Severity::Error => (st.red, "error"),
169        Severity::Warning => (st.yellow, "warning"),
170    };
171    out.push_str(&format!(
172        "{sev_color}{sev_name}[{code}]{reset}{bold}: {msg}{reset}\n",
173        code = d.code,
174        msg = d.message,
175        sev_color = sev_color,
176        reset = st.reset,
177        bold = st.bold,
178    ));
179
180    if let Some(primary) = d.primary_span() {
181        let lc = src.line_col(primary.start);
182        // Gutter width fits the largest referenced line number.
183        let max_line = d
184            .labels
185            .iter()
186            .map(|l| src.line_col(l.span.start).line)
187            .max()
188            .unwrap_or(lc.line);
189        let gw = max_line.to_string().len();
190        out.push_str(&format!(
191            "{:gw$}{blue}-->{reset} {}:{}:{}\n",
192            "",
193            src.name,
194            lc.line,
195            lc.col,
196            gw = gw + 1,
197            blue = st.blue,
198            reset = st.reset,
199        ));
200        out.push_str(&format!(
201            "{:gw$} {blue}|{reset}\n",
202            "",
203            gw = gw,
204            blue = st.blue,
205            reset = st.reset
206        ));
207
208        for (i, label) in d.labels.iter().enumerate() {
209            let is_primary = i == 0;
210            let l_start = src.line_col(label.span.start);
211            let l_end = src.line_col(label.span.end);
212            let line_text = src.line_text(l_start.line);
213            out.push_str(&format!(
214                "{blue}{:>gw$} |{reset} {}\n",
215                l_start.line,
216                line_text,
217                gw = gw,
218                blue = st.blue,
219                reset = st.reset,
220            ));
221            // Caret column accounting: columns are char-based already.
222            let pad = l_start.col - 1;
223            let width = if l_end.line == l_start.line {
224                (l_end.col - l_start.col).max(1)
225            } else {
226                line_text.chars().count().saturating_sub(pad).max(1)
227            };
228            let (mark, color) = if is_primary {
229                ("^", sev_color)
230            } else {
231                ("-", st.blue)
232            };
233            out.push_str(&format!(
234                "{blue}{:gw$} |{reset} {:pad$}{color}{marks} {msg}{reset}\n",
235                "",
236                "",
237                gw = gw,
238                pad = pad,
239                color = color,
240                marks = mark.repeat(width),
241                msg = label.message,
242                blue = st.blue,
243                reset = st.reset,
244            ));
245        }
246        out.push_str(&format!(
247            "{:gw$} {blue}|{reset}\n",
248            "",
249            gw = gw,
250            blue = st.blue,
251            reset = st.reset
252        ));
253    }
254
255    if let Some(help) = &d.help {
256        out.push_str(&format!(
257            "{green}help{reset}{bold}:{reset} {help}\n",
258            green = st.green,
259            reset = st.reset,
260            bold = st.bold,
261            help = help
262        ));
263    }
264    for note in &d.notes {
265        out.push_str(&format!(
266            "{blue}note{reset}{bold}:{reset} {note}\n",
267            blue = st.blue,
268            reset = st.reset,
269            bold = st.bold,
270            note = note
271        ));
272    }
273    out
274}
275
276/// Render a batch plus the closing summary line, e.g.
277/// `error: could not check `arch.drawl` (3 errors, 1 warning)`.
278pub fn render_all(diags: &[Diagnostic], src: &SourceFile, st: &Styles) -> String {
279    let mut out = String::new();
280    for d in diags {
281        out.push_str(&render(d, src, st));
282        out.push('\n');
283    }
284    let errors = diags
285        .iter()
286        .filter(|d| d.severity == Severity::Error)
287        .count();
288    let warnings = diags
289        .iter()
290        .filter(|d| d.severity == Severity::Warning)
291        .count();
292    if errors > 0 || warnings > 0 {
293        let mut parts = Vec::new();
294        if errors > 0 {
295            parts.push(format!(
296                "{} error{}",
297                errors,
298                if errors == 1 { "" } else { "s" }
299            ));
300        }
301        if warnings > 0 {
302            parts.push(format!(
303                "{} warning{}",
304                warnings,
305                if warnings == 1 { "" } else { "s" }
306            ));
307        }
308        let (color, word) = if errors > 0 {
309            (st.red, "error")
310        } else {
311            (st.yellow, "warning")
312        };
313        out.push_str(&format!(
314            "{color}{word}{reset}{bold}: `{file}` emitted {summary}{reset}\n",
315            color = color,
316            word = word,
317            file = src.name,
318            summary = parts.join(", "),
319            reset = st.reset,
320            bold = st.bold,
321        ));
322    }
323    out
324}
325
326/// Levenshtein distance, used for did-you-mean suggestions.
327pub fn edit_distance(a: &str, b: &str) -> usize {
328    let a: Vec<char> = a.chars().collect();
329    let b: Vec<char> = b.chars().collect();
330    let mut prev: Vec<usize> = (0..=b.len()).collect();
331    let mut cur = vec![0; b.len() + 1];
332    for i in 1..=a.len() {
333        cur[0] = i;
334        for j in 1..=b.len() {
335            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
336            cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
337        }
338        std::mem::swap(&mut prev, &mut cur);
339    }
340    prev[b.len()]
341}
342
343/// Pick the closest candidate within a sane distance for "did you mean".
344pub fn suggest<'a>(input: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
345    let max = (input.chars().count() / 3).max(1) + 1;
346    candidates
347        .into_iter()
348        .map(|c| (edit_distance(input, c), c))
349        .filter(|(d, _)| *d <= max)
350        .min_by_key(|(d, _)| *d)
351        .map(|(_, c)| c)
352}