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}
118
119impl Formatted {
120    /// Creates a formatted error from a span and labels.
121    pub fn new_from_span<S>(
122        message: S,
123        help: Option<String>,
124        code: i32,
125        type_: String,
126        error: bool,
127        span: Span,
128        labels: Vec<Label>,
129    ) -> Self
130    where
131        S: ToString,
132    {
133        Self {
134            inner: Box::new(FormattedInner {
135                message: message.to_string(),
136                help,
137                note: None,
138                code,
139                type_,
140                error,
141                span,
142                labels,
143            }),
144        }
145    }
146
147    /// Create a new error.
148    pub fn error(code_prefix: &str, code: i32, message: impl ToString, span: Span) -> Self {
149        Self::new_from_span(message, None, code, code_prefix.to_string(), true, span, vec![])
150    }
151
152    /// Create a new warning.
153    pub fn warning(code_prefix: &str, code: i32, message: impl ToString, span: Span) -> Self {
154        Self::new_from_span(message, None, code, code_prefix.to_string(), false, span, vec![])
155    }
156
157    pub fn with_help(mut self, help: impl fmt::Display) -> Self {
158        self.inner.help = Some(help.to_string());
159        self
160    }
161
162    pub fn with_note(mut self, note: impl fmt::Display) -> Self {
163        self.inner.note = Some(note.to_string());
164        self
165    }
166
167    pub fn with_label(mut self, label: Label) -> Self {
168        self.inner.labels.push(label);
169        self
170    }
171
172    pub fn with_labels(mut self, labels: impl IntoIterator<Item = Label>) -> Self {
173        self.inner.labels.extend(labels);
174        self
175    }
176
177    /// Gets the exit code.
178    pub fn exit_code(&self) -> i32 {
179        compute_exit_code(37, self.inner.code)
180    }
181
182    /// Gets a unique error identifier.
183    pub fn error_code(&self) -> String {
184        format_error_code(&self.inner.type_, 37, self.inner.code)
185    }
186
187    /// Gets a unique warning identifier.
188    pub fn warning_code(&self) -> String {
189        format_warning_code(&self.inner.type_, 37, self.inner.code)
190    }
191
192    /// Return the diagnostic's primary message without ariadne rendering.
193    ///
194    /// Used by tooling consumers (notably `leo-lsp`) that need the bare message
195    /// text rather than the formatted report. The returned slice borrows from
196    /// the same allocation as the rest of the diagnostic and therefore stays
197    /// valid for the lifetime of the `Formatted` value.
198    pub fn message(&self) -> &str {
199        &self.inner.message
200    }
201
202    /// Return the diagnostic's optional help text, if any.
203    ///
204    /// `leo-lsp` appends this to the LSP diagnostic message so editor clients
205    /// see the same hint that the CLI report would render.
206    pub fn help(&self) -> Option<&str> {
207        self.inner.help.as_deref()
208    }
209
210    /// Return the diagnostic's optional follow-up note text, if any.
211    ///
212    /// `leo-lsp` appends this to the LSP diagnostic message for parity with
213    /// CLI-rendered reports.
214    pub fn note(&self) -> Option<&str> {
215        self.inner.note.as_deref()
216    }
217
218    /// Return whether this diagnostic was raised as an error rather than a
219    /// warning.
220    ///
221    /// LSP severity mapping depends on this flag instead of inspecting the
222    /// rendered `Error`/`Warning` prefix in the formatted message.
223    pub fn is_error(&self) -> bool {
224        self.inner.error
225    }
226
227    /// Return the diagnostic's primary span.
228    ///
229    /// Callers must resolve this span against `leo_span` session globals to
230    /// recover the originating source file before the surrounding session is
231    /// torn down.
232    pub fn span(&self) -> Span {
233        self.inner.span
234    }
235
236    /// Iterate the diagnostic's secondary labels in declaration order.
237    ///
238    /// Labels carry their own span and human-readable message, which `leo-lsp`
239    /// surfaces as `Diagnostic.relatedInformation` when the client supports it.
240    pub fn labels(&self) -> impl Iterator<Item = &Label> {
241        self.inner.labels.iter()
242    }
243
244    /// Borrow this diagnostic as a plain structured view.
245    ///
246    /// The returned [`DiagnosticView`] exposes the same fields that are used
247    /// when rendering the ariadne report, without round-tripping through a
248    /// formatted string. Consumers like `leo-lsp` use the view to build LSP
249    /// `Diagnostic` payloads without parsing rendered output.
250    pub fn diagnostic_view(&self) -> DiagnosticView<'_> {
251        let code = if self.inner.error { self.error_code() } else { self.warning_code() };
252        let labels = self
253            .inner
254            .labels
255            .iter()
256            .map(|label| DiagnosticLabelView { message: label.message().to_owned(), span: label.span() })
257            .collect();
258        DiagnosticView {
259            message: &self.inner.message,
260            help: self.inner.help.as_deref(),
261            note: self.inner.note.as_deref(),
262            code,
263            is_error: self.inner.error,
264            span: Some(self.inner.span),
265            labels,
266        }
267    }
268
269    /// Resolve a Leo `Span` to an `AriadneSpan` using the source map.
270    fn resolve_span(span: Span, source_map: &leo_span::source_map::SourceMap) -> AriadneSpan {
271        let file_start_index = source_map.find_source_file(span.lo).unwrap().absolute_start;
272        AriadneSpan { file_start_index, span }
273    }
274
275    /// Build an ariadne Report from the stored fields.
276    fn build_report(&self) -> Report<'_, AriadneSpan> {
277        use leo_span::with_session_globals;
278
279        let primary_color = if self.inner.error { Color::Red } else { Color::Yellow };
280
281        with_session_globals(|s| {
282            let primary_span = Self::resolve_span(self.inner.span, &s.source_map);
283
284            // Ariadne only renders source lines for a multi-line label when its message is `Some(_)`
285            // (see `multi_labels_with_message` in ariadne's write.rs). For single-line spans we skip
286            // `with_message` to avoid the dangling `─┬─` / `╰──` decorations under the caret.
287            let primary_is_multiline = s.source_map.find_source_file(self.inner.span.lo).is_some_and(|f| {
288                let lo = (self.inner.span.lo - f.absolute_start) as usize;
289                let hi = (self.inner.span.hi - f.absolute_start) as usize;
290                f.src.as_bytes().get(lo..hi).is_some_and(|b| b.contains(&b'\n'))
291            });
292            let mut primary = ariadne::Label::new(primary_span.clone()).with_color(primary_color);
293            if primary_is_multiline {
294                primary = primary.with_message("");
295            }
296            let primary_label = std::iter::once(primary);
297
298            let extra_labels: Vec<_> = self
299                .inner
300                .labels
301                .iter()
302                .map(|l| {
303                    ariadne::Label::new(Self::resolve_span(l.span, &s.source_map))
304                        .with_message(&l.msg)
305                        .with_color(l.color)
306                })
307                .collect();
308
309            let mut report = Report::build(
310                if self.inner.error { ariadne::ReportKind::Error } else { ariadne::ReportKind::Warning },
311                primary_span,
312            )
313            .with_config(ariadne::Config::default().with_color(is_color()).with_index_type(IndexType::Byte))
314            .with_message(&self.inner.message)
315            .with_code(if self.inner.error { self.error_code() } else { self.warning_code() })
316            .with_labels(primary_label.chain(extra_labels));
317
318            if let Some(help) = &self.inner.help {
319                report = report.with_help(help);
320            }
321
322            if let Some(note) = &self.inner.note {
323                report = report.with_note(note);
324            }
325
326            report.finish()
327        })
328    }
329}
330
331impl fmt::Display for Formatted {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        if SESSION_GLOBALS.is_set() {
334            let report = self.build_report();
335            let mut cache = LeoSourceCache::new();
336            let mut buf = Vec::new();
337            report.write(&mut cache, &mut buf).map_err(|_| fmt::Error)?;
338            let output = String::from_utf8(buf).map_err(|_| fmt::Error)?;
339            write!(f, "{output}")
340        } else {
341            // Fallback when session globals are unavailable (e.g. tests).
342            let (kind, code) =
343                if self.inner.error { ("Error", self.error_code()) } else { ("Warning", self.warning_code()) };
344            write!(f, "{kind} [{code}]: {}", self.inner.message)?;
345            if let Some(help) = &self.inner.help {
346                write!(f, "\n    = help: {help}")?;
347            }
348            if let Some(note) = &self.inner.note {
349                write!(f, "\n    = note: {note}")?;
350            }
351            Ok(())
352        }
353    }
354}
355
356impl std::error::Error for Formatted {
357    fn description(&self) -> &str {
358        &self.inner.message
359    }
360}
361
362/// LSP-agnostic structured view of a compiler diagnostic.
363///
364/// Exposed so editor tooling — currently `leo-lsp` — can lower errors and
365/// warnings into editor-facing diagnostics without parsing rendered ariadne
366/// output. The view borrows from the originating [`Formatted`] for cheap
367/// strings while owning a small per-label `Vec`, which is the smallest shape
368/// that keeps secondary-label messages alive across an `extract_errs` call.
369#[derive(Debug, Clone)]
370pub struct DiagnosticView<'a> {
371    /// Primary human-readable message.
372    pub message: &'a str,
373    /// Optional help hint shown beneath the diagnostic on the CLI.
374    pub help: Option<&'a str>,
375    /// Optional follow-up note shown beneath the help line on the CLI.
376    pub note: Option<&'a str>,
377    /// Fully formatted code identifier (e.g. `EPAR0001` or `WTYC0001`).
378    pub code: String,
379    /// Whether the diagnostic is an error (`true`) or a warning (`false`).
380    pub is_error: bool,
381    /// Primary span, when the diagnostic ties to a concrete source location.
382    pub span: Option<Span>,
383    /// Secondary spans annotated with their own human-readable messages.
384    pub labels: Vec<DiagnosticLabelView>,
385}
386
387/// One secondary label paired with its source span.
388///
389/// `leo-lsp` lowers each label into `Diagnostic.relatedInformation` when the
390/// client advertises support, so it captures both the span and the message.
391#[derive(Debug, Clone)]
392pub struct DiagnosticLabelView {
393    /// Human-readable description for the label.
394    pub message: String,
395    /// Span associated with the label, resolved against session source globals.
396    pub span: Span,
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{Color, Formatted, Label};
402    use leo_span::{Span, create_session_if_not_set_then};
403
404    /// Verifies the structured view round-trips primary message, code, help, and note.
405    #[test]
406    fn diagnostic_view_exposes_primary_fields() {
407        create_session_if_not_set_then(|_| {
408            let span = Span::default();
409            let error = Formatted::error("TST", 1, "boom", span).with_help("try again").with_note("note text");
410
411            let view = error.diagnostic_view();
412            assert_eq!(view.message, "boom");
413            assert_eq!(view.help, Some("try again"));
414            assert_eq!(view.note, Some("note text"));
415            assert_eq!(view.code, error.error_code());
416            assert!(view.is_error);
417            assert_eq!(view.span, Some(span));
418            assert!(view.labels.is_empty());
419        });
420    }
421
422    /// Verifies labels are exposed with their messages and spans intact.
423    #[test]
424    fn diagnostic_view_exposes_secondary_labels() {
425        create_session_if_not_set_then(|_| {
426            let primary = Span::new(0, 4);
427            let label_span = Span::new(5, 10);
428            let error = Formatted::error("TST", 2, "boom", primary)
429                .with_label(Label::new(label_span).with_message("see also").with_color(Color::Blue));
430
431            let view = error.diagnostic_view();
432            assert_eq!(view.labels.len(), 1);
433            assert_eq!(view.labels[0].message, "see also");
434            assert_eq!(view.labels[0].span, label_span);
435        });
436    }
437
438    /// Verifies warnings round-trip through the structured view with severity preserved.
439    #[test]
440    fn diagnostic_view_marks_warnings() {
441        create_session_if_not_set_then(|_| {
442            let warning = Formatted::warning("TST", 3, "watch out", Span::default());
443            let view = warning.diagnostic_view();
444            assert!(!view.is_error);
445            assert_eq!(view.code, warning.warning_code());
446        });
447    }
448}