Skip to main content

elly_core/eval/
error.rs

1//! Errors at run time: [`Raised`], a value unwinding through the evaluator's
2//! single error channel, and what a raised error leaves behind as it unwinds.
3//!
4//! ## Unwinding
5//!
6//! A raised error leaves behind the calls it unwound through, and for a
7//! refutation, what refuted and the value it refuted.
8//!
9//! The evaluator recurses in Rust, so the calls are recorded on the way out,
10//! on the error path alone, in the realm rather than in [`Raised`], which
11//! keeps its size. What refuted which value is recorded only once a
12//! refutation escapes the parameter, binding, case or `__match` that refuted:
13//! one refuted on the way to an arm that matches records nothing. A host
14//! takes both with [`Ctx::take_trace`]. They hold ids; names and spans are
15//! found when the trace is taken, and the source text when it is reported. See `docs/done/2026-10-11_elly-ctx-chunks.md`, step 4c.
16
17use core::cell::{Cell, RefCell};
18
19use alloc::collections::VecDeque;
20use alloc::format;
21use alloc::string::String;
22use alloc::vec::Vec;
23
24use crate::code::{ChunkId, Node, NodeId, PatId, StaticChunk};
25use crate::source::{report, CtxSpan, DocSource, Location};
26use crate::sym::Sym;
27use crate::{Ctx, Text, Value};
28
29// ---------------------------------------------------------------------------
30// Raised errors
31// ---------------------------------------------------------------------------
32
33/// Value unwinding through the single error channel (see `docs/elly-spec.md`).
34///
35/// - `Error(v)`: Genuine failure (host abort or `__Err.raise v`). Propagates
36///   past `__match`.
37/// - `NoMatch(v)`: Pattern refutation from `match_pattern`. Only `__match` catches
38///   this, falling through to its fallback. Other boundaries treat it as `Error(v)`,
39///   surfacing the original error `v`.
40///
41/// Kinds are distinguished by construction; user code can only raise `Error`,
42/// preventing forged `NoMatch` signals.
43#[derive(Debug, Clone)]
44pub enum Raised {
45    /// A genuine error, propagated by `__match`.
46    Error(Value),
47    /// A pattern refutation carrying the original error it decays to when uncaught.
48    NoMatch(Value),
49}
50
51impl Raised {
52    /// A refutation carrying `tag` (stored without its dot) as the *original* error it stands for.
53    /// `__match` catches it and falls through; uncaught, it decays to `.tag`.
54    pub(crate) fn no_match(tag: Sym) -> Raised {
55        Raised::NoMatch(Value::Symbol(tag))
56    }
57
58    /// Raise one of the host-failure symbol tags (stored without its leading dot) as
59    /// a genuine `Error`. The tag is one of the fixed symbols every realm shares.
60    pub(crate) fn symbol(tag: Sym) -> Raised {
61        Raised::Error(Value::Symbol(tag))
62    }
63
64    /// Returns the carried payload regardless of the `Raised` kind.
65    pub fn value(&self) -> &Value {
66        match self {
67            Raised::Error(v) | Raised::NoMatch(v) => v,
68        }
69    }
70
71    /// Consumes the `Raised` value into its payload.
72    pub fn into_value(self) -> Value {
73        match self {
74            Raised::Error(v) | Raised::NoMatch(v) => v,
75        }
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Unwinding
81// ---------------------------------------------------------------------------
82
83/// How many frames a trace keeps: half from the failing end, half from the
84/// outermost. The frames between are counted.
85const FRAMES_CAP: usize = 128;
86
87/// The realm's unwinding record, empty outside an unwinding error.
88#[derive(Default)]
89pub(crate) struct Unwind {
90    /// Whether `state` holds anything.
91    dirty: Cell<bool>,
92    state: RefCell<State>,
93}
94
95#[derive(Default)]
96struct State {
97    frames: Frames,
98    /// What refuted which value, once a refutation escaped the pattern, case
99    /// or `__match` that refuted.
100    refuted: Option<Refuted>,
101}
102
103/// The calls unwound through, the failing one first; repeats of a call are
104/// counted in its frame.
105#[derive(Default)]
106struct Frames {
107    head: Vec<Frame>,
108    tail: VecDeque<Frame>,
109    /// How many frames were dropped between `head` and `tail`.
110    omitted: u32,
111}
112
113/// An application node an error unwound through, and how many more times
114/// in a row.
115#[derive(Clone, Copy)]
116struct Frame {
117    chunk: ChunkId,
118    call: NodeId,
119    repeat: u32,
120}
121
122/// What refuted a value, in chunk `chunk`, and the value.
123struct Refuted {
124    chunk: ChunkId,
125    site: Site,
126    value: Value,
127}
128
129/// What refuted: the outermost pattern a refutation escaped, a case none of
130/// whose arms matched, by its subject, or a `__match` none of whose clauses
131/// did.
132#[derive(Clone, Copy)]
133enum Site {
134    Pat(PatId),
135    Case(NodeId),
136    Match,
137}
138
139impl Frames {
140    fn push(&mut self, chunk: ChunkId, call: NodeId) {
141        let prev = match self.tail.back_mut() {
142            Some(prev) => Some(prev),
143            None => self.head.last_mut(),
144        };
145        if let Some(prev) = prev {
146            if prev.chunk == chunk && prev.call == call {
147                prev.repeat += 1;
148                return;
149            }
150        }
151        let frame = Frame {
152            chunk,
153            call,
154            repeat: 0,
155        };
156        if self.head.len() < FRAMES_CAP / 2 {
157            self.head.push(frame);
158            return;
159        }
160        if self.tail.len() == FRAMES_CAP / 2 {
161            let dropped = self.tail.pop_front().expect("the tail is full");
162            self.omitted = self
163                .omitted
164                .saturating_add(dropped.repeat.saturating_add(1));
165        }
166        self.tail.push_back(frame);
167    }
168
169    fn clear(&mut self) {
170        if !self.head.is_empty() {
171            self.head.clear();
172            self.tail.clear();
173            self.omitted = 0;
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Recording, from the evaluator
180// ---------------------------------------------------------------------------
181
182impl Ctx {
183    /// An error is unwinding through application node `call` of chunk `at`,
184    /// unless `call` is [`NodeId::NONE`].
185    #[cold]
186    #[inline(never)]
187    pub(crate) fn unwound(&self, at: ChunkId, call: NodeId) {
188        if call == NodeId::NONE {
189            return;
190        }
191        self.unwind.dirty.set(true);
192        self.unwind.state.borrow_mut().frames.push(at, call);
193    }
194
195    /// `raised` escaped pattern `pat` of chunk `at`, which it refuted
196    /// matching `value`: if it is a refutation, and none escaped deeper, it
197    /// is what refuted.
198    #[cold]
199    #[inline(never)]
200    pub(crate) fn escaped(&self, raised: Raised, at: ChunkId, pat: PatId, value: &Value) -> Raised {
201        if let Raised::NoMatch(_) = raised {
202            let mut state = self.unwind.state.borrow_mut();
203            if state.refuted.is_none() {
204                state.refuted = Some(Refuted {
205                    chunk: at,
206                    site: Site::Pat(pat),
207                    value: value.clone(),
208                });
209                self.unwind.dirty.set(true);
210            }
211        }
212        raised
213    }
214
215    /// No arm of the case with subject node `subject` of chunk `at` matched
216    /// `value`.
217    #[cold]
218    #[inline(never)]
219    pub(crate) fn no_arm_matched(&self, at: ChunkId, subject: NodeId, value: &Value) -> Raised {
220        self.refuted(at, Site::Case(subject), value)
221    }
222
223    /// No clause of a `__match` matched `value`.
224    #[cold]
225    #[inline(never)]
226    pub(crate) fn no_clause_matched(&self, value: &Value) -> Raised {
227        self.refuted(ChunkId::NONE, Site::Match, value)
228    }
229
230    fn refuted(&self, chunk: ChunkId, site: Site, value: &Value) -> Raised {
231        self.unwind.state.borrow_mut().refuted = Some(Refuted {
232            chunk,
233            site,
234            value: value.clone(),
235        });
236        self.unwind.dirty.set(true);
237        Raised::no_match(Sym::NO_MATCH)
238    }
239
240    /// An error was caught, by a catch or by a case arm, `__match` clause or
241    /// or-pattern branch that refuted: what it left is dropped.
242    #[inline(never)]
243    pub(crate) fn caught(&self) {
244        if self.unwind.dirty.get() {
245            self.clear_state();
246        }
247    }
248
249    #[cold]
250    #[inline(never)]
251    fn clear_state(&self) {
252        self.unwind.dirty.set(false);
253        let mut state = self.unwind.state.borrow_mut();
254        state.frames.clear();
255        state.refuted = None;
256    }
257
258    /// Forgets what an error left, as an evaluation starts.
259    pub(crate) fn clear_unwind(&self) {
260        self.caught();
261    }
262}
263
264// ---------------------------------------------------------------------------
265// The trace, for a host
266// ---------------------------------------------------------------------------
267
268/// What an error unwound through on its way to the top: see
269/// [`Ctx::take_trace`].
270#[derive(Debug, Clone, Default, PartialEq)]
271pub struct Trace {
272    /// The calls it unwound through, the failing call first.
273    pub frames: Vec<TraceFrame>,
274    /// How many frames were dropped, and where among `frames` they were, if
275    /// the trace was too long to keep.
276    pub omitted: Option<(usize, u32)>,
277    /// For a `.no_match`, what refuted which value.
278    pub refuted: Option<Refutation>,
279}
280
281/// A call an error unwound through.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct TraceFrame {
284    /// The call's span.
285    pub call: Option<CtxSpan>,
286    /// How many more times in a row the same call was unwound through.
287    pub repeat: u32,
288    /// The function the call is in.
289    pub within: FrameName,
290}
291
292/// The function a call is in.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum FrameName {
295    /// A member, a function or a value of a module or a `recur` group.
296    Member(Text),
297    /// A module's `__main`, or the function it is.
298    Main,
299    /// A lambda that no member names, with its span.
300    Closure(Option<CtxSpan>),
301    /// An expression evaluated on its own.
302    Top,
303}
304
305/// How [`Ctx::report_raised`] shows each place in a trace.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
307pub enum ReportStyle {
308    /// At its line, with the span marked, as [`report`](crate::source::report)
309    /// shows it.
310    #[default]
311    Snippets,
312    /// On one line, `name:line:column: message`, indented by two spaces.
313    Lines,
314}
315
316/// A document as a host shows it: its name, and its text if the host has
317/// it (see [`Ctx::report_raised`]).
318pub type DocText = (String, Option<String>);
319
320/// What refuted a value: the parameter or binding pattern a refutation
321/// escaped, with its whole value, however deep inside it the refuting
322/// pattern was; or a case or a `__match` none of whose arms matched.
323#[derive(Debug, Clone, PartialEq)]
324pub struct Refutation {
325    pub by: RefutedBy,
326    /// The pattern's span, or the case's subject's. A `__match` has none:
327    /// it is the call the trace starts at.
328    pub at: Option<CtxSpan>,
329    /// The value refuted.
330    pub value: Value,
331}
332
333/// What a [`Refutation`] is by.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum RefutedBy {
336    Pattern,
337    Case,
338    Match,
339}
340
341impl Ctx {
342    /// The trace of `raised`, which reached the top of an evaluation of this
343    /// realm, taken out of the realm, and for a `.no_match`, what refuted
344    /// which value.
345    pub fn take_trace(&self, raised: &Raised) -> Trace {
346        self.unwind.dirty.set(false);
347        let State { frames, refuted } = core::mem::take(&mut *self.unwind.state.borrow_mut());
348        let frame = |f: &Frame| TraceFrame {
349            call: self.node_span(f.chunk, f.call),
350            repeat: f.repeat,
351            within: self.frame_name(f.chunk, f.call),
352        };
353        let mut trace = Trace {
354            frames: frames.head.iter().map(frame).collect(),
355            ..Trace::default()
356        };
357        if frames.omitted > 0 {
358            trace.omitted = Some((trace.frames.len(), frames.omitted));
359        }
360        trace.frames.extend(frames.tail.iter().map(frame));
361        if let (Raised::NoMatch(_), Some(r)) = (raised, refuted) {
362            let (by, at) = match r.site {
363                Site::Pat(pat) => (RefutedBy::Pattern, self.pat_span(r.chunk, pat)),
364                Site::Case(subject) => (RefutedBy::Case, self.node_span(r.chunk, subject)),
365                Site::Match => (RefutedBy::Match, None),
366            };
367            trace.refuted = Some(Refutation {
368                by,
369                at,
370                value: r.value,
371            });
372        }
373        trace
374    }
375
376    /// The function that application node `call` of chunk `at` is in: the
377    /// innermost lambda around it, named by the member it is the body of, or
378    /// else the innermost member, a `__main` or a top-level expression.
379    fn frame_name(&self, at: ChunkId, call: NodeId) -> FrameName {
380        let code = self.chunk(at);
381        let chunk: &StaticChunk = &code;
382        let Some(span) = chunk.node_span(call) else {
383            return FrameName::Top;
384        };
385        // An expression's chunk has a made name, and its body is no `__main`.
386        let expression = chunk.name().as_str().starts_with("__eval_");
387        // The innermost lambda around the call.
388        let lambda = chunk
389            .lambda_ids()
390            .filter_map(|l| Some((l, chunk.lambda_span(l)?)))
391            .filter(|(_, s)| s.contains(span))
392            .min_by_key(|(_, s)| s.1);
393        let members = chunk.module_ids().flat_map(|m| {
394            (0..chunk.member_len(m)).filter_map(move |i| Some((m, i, chunk.member(m, i)?)))
395        });
396        if let Some((l, lspan)) = lambda {
397            let named = members
398                .clone()
399                .find(|(_, _, member)| {
400                    member.node.get().is_some() && chunk.node(member.node) == Node::Abs(l)
401                })
402                .map(|(_, _, member)| member.name);
403            let main = || {
404                let body = chunk.module(chunk.top()).body;
405                body.get().is_some() && chunk.node(body) == Node::Abs(l) && !expression
406            };
407            return match named {
408                Some(name) => FrameName::Member(self.text(name)),
409                None if main() => FrameName::Main,
410                None => FrameName::Closure(self.ctx_span(at, lspan)),
411            };
412        }
413        let member = members
414            .filter_map(|(m, i, member)| Some((member, chunk.member_span(m, i)?)))
415            .filter(|(_, s)| s.contains(span))
416            .min_by_key(|(_, s)| s.1);
417        match member {
418            Some((member, _)) => FrameName::Member(self.text(member.name)),
419            None if expression => FrameName::Top,
420            None => FrameName::Main,
421        }
422    }
423
424    /// Reports `raised`, which reached the top with `trace`: an `uncaught`
425    /// line, then what refuted which value and the calls it unwound through,
426    /// each shown in `style`. `doc` gives the name and, if it has it, the
427    /// text of a document, by its source and the name of its chunk; an
428    /// anonymous document is known by the chunk's.
429    pub fn report_raised(
430        &self,
431        raised: &Raised,
432        trace: &Trace,
433        doc: &dyn Fn(&DocSource, &str) -> DocText,
434        style: ReportStyle,
435    ) -> String {
436        let indent = match style {
437            ReportStyle::Snippets => "",
438            ReportStyle::Lines => "  ",
439        };
440        let mut docs: Vec<(ChunkId, u32, String, Option<String>)> = Vec::new();
441        let mut at = |span: Option<CtxSpan>, message: &str| -> String {
442            let Some((location, span)) = span.and_then(|s| Some((self.locate(s.0)?, s))) else {
443                return format!("{indent}<unknown>: {message}");
444            };
445            let known = docs
446                .iter()
447                .position(|d| (d.0, d.1) == (location.chunk, location.doc));
448            let i = match known {
449                Some(i) => i,
450                None => {
451                    let chunk = self.chunk(location.chunk);
452                    let source = chunk.documents()[location.doc as usize].source();
453                    let (name, text) = doc(source, chunk.name().as_str());
454                    docs.push((location.chunk, location.doc, name, text));
455                    docs.len() - 1
456                }
457            };
458            let (_, _, name, text) = &docs[i];
459            match style {
460                ReportStyle::Snippets => shown(name, text.as_deref(), &location, span, message),
461                ReportStyle::Lines => {
462                    let line = location.line_num;
463                    match text.as_deref().and_then(|t| location.column(t)) {
464                        Some(column) => format!("{indent}{name}:{line}:{column}: {message}"),
465                        None => format!("{indent}{name}:{line}: {message}"),
466                    }
467                }
468            }
469        };
470        let mut out = format!("uncaught {}", raised.value().to_display(self));
471        if let Some(r) = &trace.refuted {
472            let what = match r.by {
473                RefutedBy::Pattern => "refuted",
474                RefutedBy::Case => "no arm matched",
475                RefutedBy::Match => "no clause matched",
476            };
477            let span = r.at.or_else(|| trace.frames.first()?.call);
478            out.push('\n');
479            out.push_str(&at(span, &format!("{what} {}", r.value.to_display(self))));
480        }
481        for (i, frame) in trace.frames.iter().enumerate() {
482            if let Some((j, n)) = trace.omitted {
483                if i == j {
484                    out.push_str(&format!("\n{indent}[{n} frames omitted]"));
485                }
486            }
487            let message = match &frame.within {
488                FrameName::Member(name) => format!("in {name}"),
489                FrameName::Main => String::from("in __main"),
490                FrameName::Closure(span) => match span.and_then(|s| self.locate(s.0)) {
491                    Some(l) => format!("in the closure at line {}", l.line_num),
492                    None => String::from("in a closure"),
493                },
494                FrameName::Top => String::from("in the expression"),
495            };
496            out.push('\n');
497            out.push_str(&at(frame.call, &message));
498            if frame.repeat > 0 {
499                out.push_str(&format!(
500                    "\n{indent}[the call above repeated {} more times]",
501                    frame.repeat
502                ));
503            }
504        }
505        out
506    }
507}
508
509/// `message` at `span`, which is at `location` of the document `name`: with
510/// the line and the span marked if the text is known, or else the line alone.
511fn shown(
512    name: &str,
513    text: Option<&str>,
514    location: &Location,
515    span: CtxSpan,
516    message: &str,
517) -> String {
518    match text {
519        Some(text) => report(
520            name,
521            text,
522            muon::Span {
523                offset: location.pos,
524                length: span.1,
525            },
526            message,
527        ),
528        None => format!("{name}:{}: {message}", location.line_num),
529    }
530}