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