Skip to main content

leo_errors/common/
formatted.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use crate::{compute_exit_code, format_error_code, format_warning_code};
18use leo_span::{
19    SESSION_GLOBALS,
20    Span,
21    source_map::{LeoSourceCache, is_color},
22};
23
24pub use ariadne::Color;
25use ariadne::{IndexType, Report};
26use std::fmt;
27
28/// Represents error labels.
29#[derive(Debug)]
30pub struct Label {
31    msg: String,
32    span: Span,
33    color: Color,
34}
35
36impl Label {
37    pub fn new(span: Span) -> Self {
38        Self { msg: String::new(), span, color: Color::default() }
39    }
40
41    pub fn with_message(mut self, msg: impl fmt::Display) -> Self {
42        self.msg = msg.to_string();
43        self
44    }
45
46    pub fn with_color(mut self, color: Color) -> Self {
47        self.color = color;
48        self
49    }
50
51    /// Borrow the secondary label's message without copying.
52    ///
53    /// Surfaced for `Formatted::diagnostic_view`, which lowers labels into
54    /// LSP-facing related-information entries without re-parsing rendered text.
55    pub fn message(&self) -> &str {
56        &self.msg
57    }
58
59    /// Return the secondary label's source span.
60    ///
61    /// Used by structured diagnostic consumers (notably `leo-lsp`) to convert
62    /// labels into UTF-16 ranges while the session source map is still alive.
63    pub fn span(&self) -> Span {
64        self.span
65    }
66}
67
68/// Helper span for Ariadne that includes the source file start index.
69#[derive(Clone)]
70struct AriadneSpan {
71    file_start_index: u32,
72    span: Span,
73}
74
75impl ariadne::Span for AriadneSpan {
76    type SourceId = u32;
77
78    fn source(&self) -> &Self::SourceId {
79        &self.file_start_index
80    }
81
82    fn start(&self) -> usize {
83        (self.span.lo - self.file_start_index) as usize
84    }
85
86    fn end(&self) -> usize {
87        (self.span.hi - self.file_start_index) as usize
88    }
89}
90
91/// Formatted compiler error type
92///     undefined value `x`
93///     --> file.leo: 2:8
94///      |
95///    2 | let a = x;
96///      |         ^
97///      |
98///      = help: Initialize a variable `x` first.
99///
100/// Stores all error components as plain owned fields.
101/// The ariadne `Report` is built on the fly in `Display::fmt`.
102#[derive(Debug)]
103pub struct Formatted {
104    inner: Box<FormattedInner>,
105}
106
107#[derive(Debug)]
108struct FormattedInner {
109    message: String,
110    help: Option<String>,
111    note: Option<String>,
112    code: i32,
113    type_: String,
114    error: bool,
115    span: Span,
116    labels: Vec<Label>,
117    primary_span_underline: bool,
118}
119
120impl Formatted {
121    /// Creates a formatted error from a span and labels.
122    pub fn new_from_span<S>(
123        message: S,
124        help: Option<String>,
125        code: i32,
126        type_: String,
127        error: bool,
128        span: Span,
129        labels: Vec<Label>,
130    ) -> Self
131    where
132        S: ToString,
133    {
134        Self {
135            inner: Box::new(FormattedInner {
136                message: message.to_string(),
137                help,
138                note: None,
139                code,
140                type_,
141                error,
142                span,
143                labels,
144                primary_span_underline: false,
145            }),
146        }
147    }
148
149    /// Create a new error.
150    pub fn error(code_prefix: &str, code: i32, message: impl ToString, span: Span) -> Self {
151        Self::new_from_span(message, None, code, code_prefix.to_string(), true, span, vec![])
152    }
153
154    /// Create a new warning.
155    pub fn warning(code_prefix: &str, code: i32, message: impl ToString, span: Span) -> Self {
156        Self::new_from_span(message, None, code, code_prefix.to_string(), false, span, vec![])
157    }
158
159    pub fn with_help(mut self, help: impl fmt::Display) -> Self {
160        self.inner.help = Some(help.to_string());
161        self
162    }
163
164    pub fn with_note(mut self, note: impl fmt::Display) -> Self {
165        self.inner.note = Some(note.to_string());
166        self
167    }
168
169    pub fn with_label(mut self, label: Label) -> Self {
170        self.inner.labels.push(label);
171        self
172    }
173
174    /// Render the primary single-line span with a plain underline even when the
175    /// diagnostic has no primary label message.
176    pub fn with_primary_span_underline(mut self) -> Self {
177        self.inner.primary_span_underline = true;
178        self
179    }
180
181    pub fn with_labels(mut self, labels: impl IntoIterator<Item = Label>) -> Self {
182        self.inner.labels.extend(labels);
183        self
184    }
185
186    /// Gets the exit code.
187    pub fn exit_code(&self) -> i32 {
188        compute_exit_code(37, self.inner.code)
189    }
190
191    /// Gets a unique error identifier.
192    pub fn error_code(&self) -> String {
193        format_error_code(&self.inner.type_, 37, self.inner.code)
194    }
195
196    /// Gets a unique warning identifier.
197    pub fn warning_code(&self) -> String {
198        format_warning_code(&self.inner.type_, 37, self.inner.code)
199    }
200
201    /// Return the diagnostic's primary message without ariadne rendering.
202    ///
203    /// Used by tooling consumers (notably `leo-lsp`) that need the bare message
204    /// text rather than the formatted report. The returned slice borrows from
205    /// the same allocation as the rest of the diagnostic and therefore stays
206    /// valid for the lifetime of the `Formatted` value.
207    pub fn message(&self) -> &str {
208        &self.inner.message
209    }
210
211    /// Return the diagnostic's optional help text, if any.
212    ///
213    /// `leo-lsp` appends this to the LSP diagnostic message so editor clients
214    /// see the same hint that the CLI report would render.
215    pub fn help(&self) -> Option<&str> {
216        self.inner.help.as_deref()
217    }
218
219    /// Return the diagnostic's optional follow-up note text, if any.
220    ///
221    /// `leo-lsp` appends this to the LSP diagnostic message for parity with
222    /// CLI-rendered reports.
223    pub fn note(&self) -> Option<&str> {
224        self.inner.note.as_deref()
225    }
226
227    /// Return whether this diagnostic was raised as an error rather than a
228    /// warning.
229    ///
230    /// LSP severity mapping depends on this flag instead of inspecting the
231    /// rendered `Error`/`Warning` prefix in the formatted message.
232    pub fn is_error(&self) -> bool {
233        self.inner.error
234    }
235
236    /// Return the diagnostic's primary span.
237    ///
238    /// Callers must resolve this span against `leo_span` session globals to
239    /// recover the originating source file before the surrounding session is
240    /// torn down.
241    pub fn span(&self) -> Span {
242        self.inner.span
243    }
244
245    /// Iterate the diagnostic's secondary labels in declaration order.
246    ///
247    /// Labels carry their own span and human-readable message, which `leo-lsp`
248    /// surfaces as `Diagnostic.relatedInformation` when the client supports it.
249    pub fn labels(&self) -> impl Iterator<Item = &Label> {
250        self.inner.labels.iter()
251    }
252
253    /// Borrow this diagnostic as a plain structured view.
254    ///
255    /// The returned [`DiagnosticView`] exposes the same fields that are used
256    /// when rendering the ariadne report, without round-tripping through a
257    /// formatted string. Consumers like `leo-lsp` use the view to build LSP
258    /// `Diagnostic` payloads without parsing rendered output.
259    pub fn diagnostic_view(&self) -> DiagnosticView<'_> {
260        let code = if self.inner.error { self.error_code() } else { self.warning_code() };
261        let labels = self
262            .inner
263            .labels
264            .iter()
265            .map(|label| DiagnosticLabelView { message: label.message().to_owned(), span: label.span() })
266            .collect();
267        DiagnosticView {
268            message: &self.inner.message,
269            help: self.inner.help.as_deref(),
270            note: self.inner.note.as_deref(),
271            code,
272            is_error: self.inner.error,
273            span: Some(self.inner.span),
274            labels,
275        }
276    }
277
278    /// Resolve a Leo `Span` to an `AriadneSpan` using the source map.
279    fn resolve_span(span: Span, source_map: &leo_span::source_map::SourceMap) -> AriadneSpan {
280        let file_start_index = source_map.find_source_file(span.lo).unwrap().absolute_start;
281        AriadneSpan { file_start_index, span }
282    }
283
284    /// Build an ariadne Report from the stored fields.
285    fn build_report(&self) -> Report<'_, AriadneSpan> {
286        use leo_span::with_session_globals;
287
288        let primary_color = if self.inner.error { Color::Red } else { Color::Yellow };
289
290        with_session_globals(|s| {
291            let primary_span = Self::resolve_span(self.inner.span, &s.source_map);
292
293            // Ariadne only renders source lines for a multi-line label when its message is `Some(_)`
294            // (see `multi_labels_with_message` in ariadne's write.rs). For single-line spans we skip
295            // `with_message` to avoid the dangling `─┬─` / `╰──` decorations under the caret.
296            let primary_is_multiline = s.source_map.find_source_file(self.inner.span.lo).is_some_and(|f| {
297                let lo = (self.inner.span.lo - f.absolute_start) as usize;
298                let hi = (self.inner.span.hi - f.absolute_start) as usize;
299                f.src.as_bytes().get(lo..hi).is_some_and(|b| b.contains(&b'\n'))
300            });
301            let mut primary = ariadne::Label::new(primary_span.clone()).with_color(primary_color);
302            if primary_is_multiline {
303                primary = primary.with_message("here");
304            } else if self.inner.primary_span_underline {
305                primary = primary.with_message("");
306            }
307            let primary_label = std::iter::once(primary);
308
309            let extra_labels: Vec<_> = self
310                .inner
311                .labels
312                .iter()
313                .map(|l| {
314                    ariadne::Label::new(Self::resolve_span(l.span, &s.source_map))
315                        .with_message(&l.msg)
316                        .with_color(l.color)
317                })
318                .collect();
319
320            let mut report = Report::build(
321                if self.inner.error { ariadne::ReportKind::Error } else { ariadne::ReportKind::Warning },
322                primary_span,
323            )
324            .with_config(ariadne::Config::default().with_color(is_color()).with_index_type(IndexType::Byte))
325            .with_message(&self.inner.message)
326            .with_code(if self.inner.error { self.error_code() } else { self.warning_code() })
327            .with_labels(primary_label.chain(extra_labels));
328
329            if let Some(help) = &self.inner.help {
330                report = report.with_help(help);
331            }
332
333            if let Some(note) = &self.inner.note {
334                report = report.with_note(note);
335            }
336
337            report.finish()
338        })
339    }
340}
341
342impl fmt::Display for Formatted {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        if SESSION_GLOBALS.is_set() {
345            let report = self.build_report();
346            let mut cache = LeoSourceCache::new();
347            let mut buf = Vec::new();
348            report.write(&mut cache, &mut buf).map_err(|_| fmt::Error)?;
349            let output = String::from_utf8(buf).map_err(|_| fmt::Error)?;
350            let output = if self.inner.primary_span_underline {
351                normalize_empty_primary_label_underline(&output)
352            } else {
353                output
354            };
355            write!(f, "{output}")
356        } else {
357            // Fallback when session globals are unavailable (e.g. tests).
358            let (kind, code) =
359                if self.inner.error { ("Error", self.error_code()) } else { ("Warning", self.warning_code()) };
360            write!(f, "{kind} [{code}]: {}", self.inner.message)?;
361            if let Some(help) = &self.inner.help {
362                write!(f, "\n    = help: {help}")?;
363            }
364            if let Some(note) = &self.inner.note {
365                write!(f, "\n    = note: {note}")?;
366            }
367            Ok(())
368        }
369    }
370}
371
372fn normalize_empty_primary_label_underline(output: &str) -> String {
373    let mut normalized = Vec::new();
374    let mut lines = output.lines();
375
376    while let Some(line) = lines.next() {
377        // Ariadne requires an empty label message to draw the underline row, but
378        // that also emits an empty pointer connector. Keep the underline and
379        // drop only the connector line for diagnostics that explicitly opt in.
380        if line.contains('┬') && line.contains('─') {
381            normalized.push(line.replace('┬', "─").trim_end().to_owned());
382            if let Some(next) = lines.next()
383                && !next.contains('╰')
384            {
385                normalized.push(next.to_owned());
386            }
387        } else {
388            normalized.push(line.to_owned());
389        }
390    }
391
392    let mut output = normalized.join("\n");
393    if output.is_empty() || output.ends_with('\n') {
394        output
395    } else {
396        output.push('\n');
397        output
398    }
399}
400
401impl std::error::Error for Formatted {
402    fn description(&self) -> &str {
403        &self.inner.message
404    }
405}
406
407/// LSP-agnostic structured view of a compiler diagnostic.
408///
409/// Exposed so editor tooling — currently `leo-lsp` — can lower errors and
410/// warnings into editor-facing diagnostics without parsing rendered ariadne
411/// output. The view borrows from the originating [`Formatted`] for cheap
412/// strings while owning a small per-label `Vec`, which is the smallest shape
413/// that keeps secondary-label messages alive across an `extract_errs` call.
414#[derive(Debug, Clone)]
415pub struct DiagnosticView<'a> {
416    /// Primary human-readable message.
417    pub message: &'a str,
418    /// Optional help hint shown beneath the diagnostic on the CLI.
419    pub help: Option<&'a str>,
420    /// Optional follow-up note shown beneath the help line on the CLI.
421    pub note: Option<&'a str>,
422    /// Fully formatted code identifier (e.g. `EPAR0001` or `WTYC0001`).
423    pub code: String,
424    /// Whether the diagnostic is an error (`true`) or a warning (`false`).
425    pub is_error: bool,
426    /// Primary span, when the diagnostic ties to a concrete source location.
427    pub span: Option<Span>,
428    /// Secondary spans annotated with their own human-readable messages.
429    pub labels: Vec<DiagnosticLabelView>,
430}
431
432/// One secondary label paired with its source span.
433///
434/// `leo-lsp` lowers each label into `Diagnostic.relatedInformation` when the
435/// client advertises support, so it captures both the span and the message.
436#[derive(Debug, Clone)]
437pub struct DiagnosticLabelView {
438    /// Human-readable description for the label.
439    pub message: String,
440    /// Span associated with the label, resolved against session source globals.
441    pub span: Span,
442}
443
444#[cfg(test)]
445mod tests {
446    use super::{Color, Formatted, Label};
447    use leo_span::{Span, create_session_if_not_set_then, source_map::FileName};
448
449    /// Verifies the structured view round-trips primary message, code, help, and note.
450    #[test]
451    fn diagnostic_view_exposes_primary_fields() {
452        create_session_if_not_set_then(|_| {
453            let span = Span::default();
454            let error = Formatted::error("TST", 1, "boom", span).with_help("try again").with_note("note text");
455
456            let view = error.diagnostic_view();
457            assert_eq!(view.message, "boom");
458            assert_eq!(view.help, Some("try again"));
459            assert_eq!(view.note, Some("note text"));
460            assert_eq!(view.code, error.error_code());
461            assert!(view.is_error);
462            assert_eq!(view.span, Some(span));
463            assert!(view.labels.is_empty());
464        });
465    }
466
467    /// Verifies labels are exposed with their messages and spans intact.
468    #[test]
469    fn diagnostic_view_exposes_secondary_labels() {
470        create_session_if_not_set_then(|_| {
471            let primary = Span::new(0, 4);
472            let label_span = Span::new(5, 10);
473            let error = Formatted::error("TST", 2, "boom", primary)
474                .with_label(Label::new(label_span).with_message("see also").with_color(Color::Blue));
475
476            let view = error.diagnostic_view();
477            assert_eq!(view.labels.len(), 1);
478            assert_eq!(view.labels[0].message, "see also");
479            assert_eq!(view.labels[0].span, label_span);
480        });
481    }
482
483    /// Verifies warnings round-trip through the structured view with severity preserved.
484    #[test]
485    fn diagnostic_view_marks_warnings() {
486        create_session_if_not_set_then(|_| {
487            let warning = Formatted::warning("TST", 3, "watch out", Span::default());
488            let view = warning.diagnostic_view();
489            assert!(!view.is_error);
490            assert_eq!(view.code, warning.warning_code());
491        });
492    }
493
494    /// Verifies only multi-line primary spans render with a visible fallback label.
495    #[test]
496    fn multi_line_primary_span_uses_default_label() {
497        create_session_if_not_set_then(|s| {
498            let source = "program test.aleo {\n    @custom\n    constructor() {}\n}\n";
499            let file = s.source_map.new_source(source, FileName::Custom("test.leo".into()));
500            let lo = file.absolute_start + source.find("@custom").unwrap() as u32;
501            let hi = file.absolute_start + source.find(" {}\n").unwrap() as u32 + 3;
502            let rendered = Formatted::error("TST", 4, "boom", Span::new(lo, hi)).to_string();
503
504            assert!(rendered.contains("here"));
505
506            let lo = file.absolute_start + source.find("test").unwrap() as u32;
507            let hi = lo + "test".len() as u32;
508            let rendered = Formatted::error("TST", 5, "boom", Span::new(lo, hi)).to_string();
509
510            assert!(!rendered.contains("here"));
511        });
512    }
513
514    /// Verifies diagnostics can opt into an unmessaged primary underline without
515    /// inheriting ariadne's empty-message pointer connector.
516    #[test]
517    fn primary_span_underline_omits_empty_pointer_connector() {
518        create_session_if_not_set_then(|s| {
519            let source = "program test.aleo {\n}\n";
520            let file = s.source_map.new_source(source, FileName::Custom("test.leo".into()));
521            let lo = file.absolute_start + source.find("test.aleo").unwrap() as u32;
522            let hi = lo + "test.aleo".len() as u32;
523            let rendered =
524                Formatted::error("TST", 6, "boom", Span::new(lo, hi)).with_primary_span_underline().to_string();
525
526            assert!(rendered.chars().filter(|c| *c == '─').count() >= 13);
527            assert!(!rendered.contains('┬'));
528            assert!(!rendered.contains('╰'));
529        });
530    }
531}