Skip to main content

rl_utils/
errors.rs

1use std::sync::Arc;
2
3use ariadne::{Color, Label, Report, ReportKind, Source};
4
5use crate::source::SourceFile;
6use crate::span::Span;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[derive(Default)]
10pub enum Severity {
11    #[default]
12    Error,
13    Warning,
14}
15
16
17/// heavy optional fields, heap-allocated so `Error` stays small on the stack
18#[derive(Debug, Clone)]
19struct ErrorDetail {
20    /// primary span (anchor of the ariadne report) and its label text
21    primary: (Span, String),
22    /// secondary spans with labels
23    labels: Vec<(Span, String)>,
24    /// source string for rendering; supplied by the subsystem that built the error
25    source: Option<Arc<String>>,
26    /// source file name shown in the report header
27    source_name: Option<String>,
28    /// optional help/hint line shown after the snippet (e.g. "did you mean foo?")
29    help: Option<String>,
30}
31
32/// represents an Interpreter error with optional line number and error category
33#[derive(Debug, Clone)]
34pub struct Error {
35    /// readable message
36    message: String,
37    /// line number of error in source file (legacy; superseded by `detail.primary.0` when present)
38    line: Option<usize>,
39    /// the category and optional context of the error
40    reason: Option<ErrorReason>,
41    /// boxed span-aware detail; `None` for legacy errors that have no span
42    detail: Option<Box<ErrorDetail>>,
43    /// file name + 1-indexed (line, col), set from a [`crate::line_index::LineIndex`]
44    /// when no source text is available to render an ariadne snippet (e.g.
45    /// errors raised while running compiled `.rlc` bytecode, which embeds a
46    /// `LineIndex` but not the original source). Used by [`Error::fallback_text`]
47    /// to print a `file:line:col` diagnostic instead of a bare message.
48    location: Option<(Arc<str>, usize, usize)>,
49    severity: Severity,
50}
51
52/// provides an error category with optional error context
53#[derive(Debug, Clone)]
54pub struct ErrorReason {
55    /// error category
56    error_type: Reason,
57    /// optional lines of error output
58    data: Option<Vec<String>>,
59}
60
61/// the error category
62#[derive(Clone, Copy, Debug)]
63pub enum Reason {
64    /// error occured during parsing
65    Parse,
66    /// error occured when building the ast
67    AST,
68    /// error occured during lexing
69    Lexer,
70    /// error occured during evaluation
71    Interpreter,
72    /// error orginated from utils
73    Utils,
74    /// error occured during compilation
75    Compile,
76    /// error occured during runtime
77    Runtime,
78}
79
80impl Error {
81    /// builder-style constructor for span-aware errors.
82    /// the `span` becomes the primary anchor of the report.
83    pub fn at(kind: Reason, message: impl Into<String>, span: Span) -> Self {
84        let message = message.into();
85        #[cfg(feature = "debug")]
86        log::debug!("Error: {}", message);
87        Self {
88            message: message.clone(),
89            line: None,
90            reason: Some(ErrorReason::init(kind, None)),
91            detail: Some(Box::new(ErrorDetail {
92                primary: (span, message),
93                labels: Vec::new(),
94                source: None,
95                source_name: None,
96                help: None,
97            })),
98            location: None,
99            severity: Severity::Error,
100        }
101    }
102
103    pub fn as_warning(mut self) -> Self {
104        self.severity = Severity::Warning;
105        self
106    }
107
108    pub fn severity(&self) -> Severity {
109        self.severity
110    }
111
112    /// Attaches a `file:line:col` fallback location, resolved from a
113    /// [`crate::line_index::LineIndex`] against this error's primary span.
114    /// Used when no source text is available to render a full ariadne
115    /// snippet (see [`Error::fallback_text`]); a no-op when full source
116    /// is later attached via [`Error::with_source`] /
117    /// [`Error::with_source_file`], since [`Error::report_to_stderr`]
118    /// prefers the ariadne path whenever source is present.
119    pub fn with_location_from(mut self, index: &crate::line_index::LineIndex) -> Self {
120        if let Some(span) = self.span() {
121            let (line, col) = index.line_col(span.start);
122            self.location = Some((Arc::clone(index.source_name()), line, col));
123        }
124        self
125    }
126
127    /// override the primary label text (defaults to the error message).
128    pub fn with_primary_label(mut self, label: impl Into<String>) -> Self {
129        if let Some(d) = &mut self.detail {
130            d.primary.1 = label.into();
131        }
132        self
133    }
134
135    /// (Re-)anchors the primary span of this report at `span`. Unlike
136    /// [`Error::at`], this works on an error that was built without span
137    /// context (e.g. deep inside generic conversion code with no access to
138    /// the call site) - the caller sets the real location once it's known.
139    pub fn with_span(mut self, span: Span) -> Self {
140        match &mut self.detail {
141            Some(d) => d.primary.0 = span,
142            None => {
143                self.detail = Some(Box::new(ErrorDetail {
144                    primary: (span, self.message.clone()),
145                    labels: Vec::new(),
146                    source: None,
147                    source_name: None,
148                    help: None,
149                }));
150            }
151        }
152        self
153    }
154
155    /// add a secondary label to the report.
156    pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
157        if let Some(d) = &mut self.detail {
158            d.labels.push((span, label.into()));
159        }
160        self
161    }
162
163    /// attach the source string so ariadne can render snippets.
164    pub fn with_source(mut self, source: Arc<String>) -> Self {
165        if let Some(d) = &mut self.detail {
166            d.source = Some(source);
167        }
168        self
169    }
170
171    /// attach a human-readable source name (e.g. file path).
172    pub fn with_source_name(mut self, name: impl Into<String>) -> Self {
173        if let Some(d) = &mut self.detail {
174            d.source_name = Some(name.into());
175        }
176        self
177    }
178
179    /// attach a help/hint line shown beneath the snippet (e.g. "did you mean foo?").
180    pub fn with_help(mut self, help: impl Into<String>) -> Self {
181        if let Some(d) = &mut self.detail {
182            d.help = Some(help.into());
183        }
184        self
185    }
186
187    /// attach both the source text and name from a [`SourceFile`].
188    pub fn with_source_file(mut self, file: &SourceFile) -> Self {
189        if let Some(d) = &mut self.detail {
190            d.source = Some(Arc::clone(&file.text));
191            d.source_name = Some(file.name.to_string());
192        }
193        self
194    }
195
196    /// prints the error and exits via panic so existing call sites and the REPL keep working.
197    ///
198    /// uses ariadne when `source` and a primary span are available; falls back to the legacy
199    /// text format otherwise.
200    pub fn print_error(&self) {
201        self.report_to_stderr();
202        panic!("rl error");
203    }
204
205    /// renders the error to stderr without terminating. used by call sites that already
206    /// own their control flow (e.g. anything returning `Result`).
207    pub fn report_to_stderr(&self) {
208        if let Some(d) = &self.detail
209            && let Some(src) = &d.source
210        {
211            let name: &str = d.source_name.as_deref().unwrap_or("<source>");
212            let (sp, primary_label) = &d.primary;
213            let kind = match self.severity {
214                Severity::Error => ReportKind::Error,
215                Severity::Warning => ReportKind::Warning,
216            };
217            let label_color = match self.severity {
218                Severity::Error => Color::Red,
219                Severity::Warning => Color::Yellow,
220            };
221            let mut builder = Report::build(kind, (name, sp.start..sp.end))
222                .with_message(&self.message)
223                .with_label(
224                    Label::new((name, sp.start..sp.end))
225                        .with_message(primary_label)
226                        .with_color(label_color),
227                );
228            for (lsp, label) in &d.labels {
229                builder = builder.with_label(
230                    Label::new((name, lsp.start..lsp.end))
231                        .with_message(label)
232                        .with_color(Color::Yellow),
233                );
234            }
235            if let Some(help) = &d.help {
236                builder = builder.with_help(help);
237            }
238            let _ = builder.finish().eprint((name, Source::from(src.as_str())));
239            return;
240        }
241
242        self.fallback_text();
243    }
244
245    /// text rendering used when no source is available to render an
246    /// ariadne snippet. Prefers a precise `file:line:col` location
247    /// (set via [`Error::with_location_from`]) over the legacy bare
248    /// `[N) Error: ...]` / `[Error: ...]` format, which is now only a
249    /// fallback for errors that have neither source nor a line index.
250    fn fallback_text(&self) {
251        let prefix = match self.severity {
252            Severity::Error => "Error",
253            Severity::Warning => "Warning",
254        };
255        match (&self.location, &self.line) {
256            (Some((name, line, col)), _) => {
257                println!("{}:{}:{}: [{}: {}]", name, line, col, prefix, self.message)
258            }
259            (None, Some(l)) => println!("[{}) {}: {}]", l, prefix, self.message),
260            (None, None) => println!("[{}: {}]", prefix, self.message),
261        }
262
263        if let Some(r) = &self.reason {
264            match &r.data {
265                Some(d) => {
266                    println!("[{}]", r.get_type_string());
267                    for l in d {
268                        println!("{}", l);
269                    }
270                }
271                _ => println!("[{}]", r.get_type_string()),
272            }
273        }
274    }
275
276    /// Extracts the primary [`Span`] of this error, if one was set.
277    pub fn span(&self) -> Option<crate::span::Span> {
278        self.detail.as_ref().map(|d| d.primary.0)
279    }
280
281    /// The source text this error's span is relative to, if attached.
282    /// Import-checked files attach their own text, so consumers (e.g.
283    /// the LSP) must map offsets with this, not the open document.
284    pub fn source_text(&self) -> Option<&Arc<String>> {
285        self.detail.as_ref().and_then(|d| d.source.as_ref())
286    }
287
288    /// The file name this error belongs to, if attached.
289    pub fn source_name(&self) -> Option<&str> {
290        self.detail
291            .as_ref()
292            .and_then(|d| d.source_name.as_deref())
293    }
294}
295
296impl ErrorReason {
297    /// creates a new [`ErrorReason`] with category type and optional data
298    ///
299    /// # Example
300    ///
301    /// ```rust
302    /// use rl_utils::errors::{ErrorReason, Reason};
303    /// ErrorReason::init(Reason::Lexer, Some(vec!["unknown token `$`".to_string()]));
304    /// ```
305    pub fn init(error_type: Reason, data: Option<Vec<String>>) -> Self {
306        Self { error_type, data }
307    }
308
309    /// returns the display of category type
310    fn get_type_string(&self) -> String {
311        match &self.error_type {
312            Reason::Parse => "Parse Error",
313            Reason::AST => "AST Error",
314            Reason::Lexer => "Lexer Error",
315            Reason::Interpreter => "Interpreter Error",
316            Reason::Utils => "Utils Error",
317            Reason::Compile => "Compile Error",
318            Reason::Runtime => "Runtime Error",
319        }
320        .to_string()
321    }
322}
323
324impl Error {
325    /// Returns the raw error message string.
326    pub fn message(&self) -> &str {
327        &self.message
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use crate::{
334        errors::{ErrorReason, Reason},
335        source::SourceFile,
336        span::Span,
337    };
338
339    use super::Error;
340
341    #[test]
342    fn error_basic() {
343        let span = Span::new(1, 5);
344        let error = Error::at(Reason::Parse, "syntax error", span);
345
346        assert_eq!(error.message(), "syntax error");
347        assert_eq!(error.span(), Some(span));
348    }
349
350    #[test]
351    fn test_error_builders() {
352        let span1 = Span::new(0, 3);
353        let span2 = Span::new(5, 8);
354
355        let err = Error::at(Reason::Compile, "type error", span1)
356            .with_primary_label("expected int")
357            .with_label(span2, "found string")
358            .with_help("try casting")
359            .with_source_name("main.rl");
360
361        assert_eq!(err.message(), "type error");
362        assert_eq!(err.span(), Some(span1));
363    }
364
365    #[test]
366    fn test_error_with_source_file() {
367        let span = Span::new(0, 5);
368        let source_file = SourceFile::new("main.rl", "print(\"foobar\")".to_string());
369
370        let err = Error::at(Reason::Lexer, "bad token", span).with_source_file(&source_file);
371
372        assert_eq!(err.span(), Some(span));
373    }
374
375    #[test]
376    fn test_span_override() {
377        let span_override = Span::new(1, 5);
378        let error =
379            Error::at(Reason::Parse, "syntax error", Span::new(0, 0)).with_span(span_override);
380
381        assert_eq!(error.message(), "syntax error");
382        assert_eq!(error.span(), Some(span_override));
383    }
384
385    #[test]
386    fn test_error_reason_string() {
387        let reason = ErrorReason::init(
388            Reason::Interpreter,
389            Some(vec!["stack overflow".to_string()]),
390        );
391        assert_eq!(reason.get_type_string(), "Interpreter Error");
392    }
393}