Skip to main content

candela_vm/
errors.rs

1// This file is derived from keel (https://github.com/horacehoff/keel),
2// Copyright 2026 Horace Hoff, licensed under the Apache License, Version 2.0.
3// It has been modified by the candela authors. See the NOTICE file.
4use crate::instr::Instr;
5use crate::rt::{DataType, InstrSrc, Source, Span};
6use ariadne::FnCache;
7use ariadne::{Color, Label, Report, ReportKind};
8use smol_strc::{SmolStr, ToSmolStr};
9use std::cell::Cell;
10use std::cell::RefCell;
11use std::hint::unreachable_unchecked;
12use std::io::Write;
13use std::ops::Range;
14use std::sync::Arc;
15
16/// A structured compile/runtime error, produced instead of printing + exiting
17/// whenever a diagnostic sink is installed (see `collect_diagnostic`).
18///
19/// `span` is a byte range into the source named by `filename`.
20/// `code` is a stable, machine-readable error identifier, e.g.
21/// `index_out_of_bounds`, `unexpected_eof`, or `compile_error`.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Diagnostic {
24    pub filename: String,
25    pub span: Range<usize>,
26    pub message: String,
27    pub code: String,
28}
29
30/// Panic payload used to unwind out of compilation/execution once a
31/// diagnostic has been recorded in the active sink.
32struct FatalError;
33
34thread_local! {
35    static DIAGNOSTIC_SINK: RefCell<Option<Diagnostic>> = const { RefCell::new(None) };
36    static SINK_ACTIVE: Cell<bool> = const { Cell::new(false) };
37    static SUPPRESS_PANIC_HOOK: Cell<bool> = const { Cell::new(false) };
38}
39
40type BoxedHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send + 'static>;
41
42/// Process-global bookkeeping for the scoped, silencing panic hook installed by
43/// [`collect_diagnostic`]. `depth` counts how many `collect_diagnostic` calls
44/// are currently active across all threads; `previous` holds the host's own
45/// panic hook, captured when the first collection begins and restored when the
46/// last one ends, so the host's hook is never permanently clobbered.
47///
48/// The panic runtime holds its own hook lock for as long as it runs the
49/// installed hook, and `set_hook`/`take_hook` wait for that same lock while
50/// this mutex is held. Nothing reachable from inside the hook may therefore
51/// take this mutex, or the two lock orders meet and the process wedges. That is
52/// why the hook forwards through its own handle on the host's hook instead of
53/// reading `previous` from here.
54struct HookState {
55    depth: usize,
56    previous: Option<Arc<BoxedHook>>,
57}
58
59static HOOK_STATE: std::sync::Mutex<HookState> = std::sync::Mutex::new(HookState {
60    depth: 0,
61    previous: None,
62});
63
64/// Recover the guarded state even if a panic while it was held poisoned the
65/// mutex; the depth and the saved hook stay usable either way.
66fn lock_hook_state() -> std::sync::MutexGuard<'static, HookState> {
67    HOOK_STATE
68        .lock()
69        .unwrap_or_else(std::sync::PoisonError::into_inner)
70}
71
72/// Installs the silencing hook for the duration of a `collect_diagnostic` scope,
73/// capturing the host's current hook the first time collection becomes active.
74///
75/// The hook is shared by nested/concurrent collections via `depth`; it forwards
76/// every panic to the host's saved hook *except* the internal `FatalError`
77/// unwind on a thread that is actively collecting (which it silences).
78fn enter_silencing_hook() {
79    let mut state = lock_hook_state();
80    if state.depth == 0 {
81        let previous = Arc::new(std::panic::take_hook());
82        let forward = Arc::clone(&previous);
83        std::panic::set_hook(Box::new(move |info| {
84            if SUPPRESS_PANIC_HOOK.with(Cell::get) && info.payload().is::<FatalError>() {
85                return;
86            }
87            forward(info);
88        }));
89        state.previous = Some(previous);
90    }
91    state.depth += 1;
92}
93
94/// Undoes one `enter_silencing_hook`; when the last active collection ends, the
95/// host's original hook is put back so the process is left exactly as we found
96/// it.
97fn leave_silencing_hook() {
98    let mut state = lock_hook_state();
99    state.depth -= 1;
100    if state.depth == 0
101        && let Some(previous) = state.previous.take()
102    {
103        // Removing our hook drops the handle it forwards through, and
104        // `take_hook` waits for any hook call still running, so the host's own
105        // box is the only handle left and goes back unwrapped.
106        drop(std::panic::take_hook());
107        if let Some(previous) = Arc::into_inner(previous) {
108            std::panic::set_hook(previous);
109        }
110    }
111}
112
113/// Whether errors should be recorded as a `Diagnostic` instead of being
114/// printed and aborting the process. This is what keeps the CLI behavior
115/// byte-for-byte identical: without an active sink, every error path behaves
116/// exactly as before.
117#[inline]
118pub fn diagnostics_enabled() -> bool {
119    SINK_ACTIVE.with(Cell::get)
120}
121
122/// Records `Diagnostic` data in the active sink and unwinds to the enclosing
123/// `collect_diagnostic` call. Must only be called when `diagnostics_enabled()`.
124///
125/// The three error funnels are fatal-on-first-error (they return `!`), so a
126/// single slot is enough: the first error to fire is the one reported.
127#[cold]
128#[inline(never)]
129pub fn emit_diagnostic(filename: &str, span: Range<usize>, message: String, code: &str) -> ! {
130    DIAGNOSTIC_SINK.with(|sink| {
131        *sink.borrow_mut() = Some(Diagnostic {
132            filename: filename.to_owned(),
133            span,
134            message,
135            code: code.to_owned(),
136        });
137    });
138    std::panic::panic_any(FatalError);
139}
140
141/// Runs `f` with a diagnostic sink installed.
142///
143/// An error that would normally print a report and exit/panic instead records a
144/// [`Diagnostic`] and unwinds back here. Nested calls are supported (the
145/// previous state is restored), and panics that signal bugs are not
146/// swallowed; they propagate through unchanged.
147///
148/// # Requires
149///
150/// **An unwinding panic strategy (`panic = "unwind"`).** This function catches
151/// the internal unwind used to carry a diagnostic out of the error funnels via
152/// [`std::panic::catch_unwind`]. Under `panic = "abort"` that unwind cannot be
153/// caught: the process aborts on the first error instead of this returning
154/// `Err`. Every profile candela ships unwinds, `[profile.release]` included,
155/// because `candela build` recovers this way too; an embedder that picks its
156/// own profile has to keep `panic = "unwind"` to use this.
157///
158/// While a collection is active this installs a process-global panic hook that
159/// silences only the internal diagnostic unwind and forwards every other panic
160/// to the host's own hook. The host's hook is captured on entry and restored
161/// once the outermost collection finishes, so it is never permanently replaced.
162///
163/// # Errors
164///
165/// Returns the [`Diagnostic`] recorded by the first error funnel that fired
166/// while `f` was running (parser, compiler or runtime).
167pub fn collect_diagnostic<R>(f: impl FnOnce() -> R) -> Result<R, Diagnostic> {
168    enter_silencing_hook();
169    let previous_active = SINK_ACTIVE.replace(true);
170    let previous_sink = DIAGNOSTIC_SINK.with(|sink| sink.borrow_mut().take());
171    let previous_suppress = SUPPRESS_PANIC_HOOK.replace(true);
172    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
173    let recorded = DIAGNOSTIC_SINK.with(|sink| sink.borrow_mut().take());
174    DIAGNOSTIC_SINK.with(|sink| *sink.borrow_mut() = previous_sink);
175    SINK_ACTIVE.set(previous_active);
176    SUPPRESS_PANIC_HOOK.set(previous_suppress);
177    leave_silencing_hook();
178    match outcome {
179        Ok(value) => Ok(value),
180        Err(payload) => {
181            if payload.is::<FatalError>() {
182                Err(recorded.unwrap_or_else(|| Diagnostic {
183                    filename: String::new(),
184                    span: 0..0,
185                    message: String::from("unknown error"),
186                    code: String::from("unknown"),
187                }))
188            } else {
189                std::panic::resume_unwind(payload);
190            }
191        }
192    }
193}
194
195/// Removes ANSI escape sequences (colors/bold) from an error message so that
196/// structured diagnostics carry plain text.
197pub fn strip_ansi(s: &str) -> String {
198    let mut out = String::with_capacity(s.len());
199    let mut chars = s.chars();
200    while let Some(c) = chars.next() {
201        if c == '\x1B' {
202            // Skip a CSI sequence: ESC '[' ... final byte in '@'..='~'
203            if chars.next() == Some('[') {
204                for f in chars.by_ref() {
205                    if ('@'..='~').contains(&f) {
206                        break;
207                    }
208                }
209            }
210        } else {
211            out.push(c);
212        }
213    }
214    out
215}
216
217pub const BLUE: &str = "\x1B[94m";
218pub fn blue<F: std::fmt::Display>(t: F) -> String {
219    format!("{BLUE}{t}{RESET}")
220}
221pub const RED: &str = "\x1B[31m";
222pub fn red<F: std::fmt::Display>(t: F) -> String {
223    format!("{RED}{t}{RESET}")
224}
225pub const BOLD: &str = "\x1B[1m";
226pub fn bold<F: std::fmt::Display>(t: F) -> String {
227    format!("{BOLD}{t}{RESET}")
228}
229pub const GREEN: &str = "\x1B[32m";
230pub fn green<F: std::fmt::Display>(t: F) -> String {
231    format!("{GREEN}{t}{RESET}")
232}
233pub const RESET: &str = "\x1B[0m\x1B[39m";
234
235/// What a runtime error report is built from: the source span of each
236/// instruction and the text of every source file. It borrows both from the
237/// program that runs, so starting a run costs no copy of either.
238#[derive(Clone, Copy)]
239pub struct ErrorCtx<'a> {
240    pub instr_src: &'a [InstrSrc],
241    pub sources: &'a [Source],
242}
243
244/// The name a call site wrote for the function it calls: its source text up to
245/// the arguments. The VM holds no table of function names, so an error about a
246/// call reads the name back out of the source the call was compiled from.
247#[cold]
248#[inline(never)]
249#[must_use]
250pub fn call_site_name<'a>(ctx: &ErrorCtx<'a>, instr: Instr) -> &'a str {
251    let name = ctx
252        .instr_src
253        .iter()
254        .find(|s| s.instr == instr)
255        .and_then(|src| {
256            let contents = &ctx.sources.get(src.file_id as usize)?.contents;
257            contents.get((src.span.start as usize)..(src.span.end as usize))
258        })
259        .and_then(|text| text.split(['(', '<']).next())
260        .map(str::trim)
261        .unwrap_or_default();
262    if name.is_empty() { "a function" } else { name }
263}
264
265impl From<std::io::ErrorKind> for ErrType<'_> {
266    fn from(value: std::io::ErrorKind) -> Self {
267        match value {
268            std::io::ErrorKind::AlreadyExists => ErrType::FsAlreadyExists,
269            std::io::ErrorKind::Deadlock => ErrType::FsDeadlock,
270            std::io::ErrorKind::FileTooLarge => ErrType::FsFileTooLarge,
271            std::io::ErrorKind::Interrupted => ErrType::FsInterrupted,
272            std::io::ErrorKind::InvalidData => ErrType::FsInvalidData,
273            std::io::ErrorKind::InvalidFilename => ErrType::FsInvalidFilename,
274            std::io::ErrorKind::IsADirectory => ErrType::FsIsADirectory,
275            std::io::ErrorKind::NotADirectory => ErrType::FsNotADirectory,
276            std::io::ErrorKind::NotFound => ErrType::FsNotFound,
277            std::io::ErrorKind::PermissionDenied => ErrType::FsPermissionDenied,
278            std::io::ErrorKind::OutOfMemory => ErrType::FsOutOfMemory,
279            std::io::ErrorKind::ReadOnlyFilesystem => ErrType::FsReadOnlyFilesystem,
280            std::io::ErrorKind::StorageFull => ErrType::FsStorageFull,
281            std::io::ErrorKind::TimedOut => ErrType::FsTimedOut,
282            _ => unsafe { unreachable_unchecked() },
283        }
284    }
285}
286
287impl From<std::num::IntErrorKind> for ErrType<'_> {
288    fn from(value: std::num::IntErrorKind) -> Self {
289        match value {
290            std::num::IntErrorKind::Empty
291            | std::num::IntErrorKind::InvalidDigit
292            | std::num::IntErrorKind::NegOverflow
293            | std::num::IntErrorKind::PosOverflow => ErrType::InvalidInt,
294            _ => unsafe { unreachable_unchecked() },
295        }
296    }
297}
298
299/// Error types, largely borrowed from Rust
300pub enum ErrType<'a> {
301    Custom(&'a str),
302    // FS ERRORS
303    FsAlreadyExists,
304    FsDeadlock,
305    FsFileTooLarge,
306    FsInterrupted,
307    FsInvalidData,
308    FsInvalidFilename,
309    /// When a file was expected...
310    FsIsADirectory,
311    /// When a directory was expected...
312    FsNotADirectory,
313    FsNotFound,
314    FsPermissionDenied,
315    FsOutOfMemory,
316    FsReadOnlyFilesystem,
317    FsStorageFull,
318    FsTimedOut,
319    // NUMBER PARSING ERRORS
320    InvalidInt,
321    InvalidFloat,
322    // BOOL PARSING ERRORS
323    InvalidBool,
324    /// IndexOutOfBounds(length, index)
325    IndexOutOfBounds(usize, i64),
326    /// SliceOutOfBounds(length, idx_start, idx_end)
327    SliceOutOfBounds(usize, i64, i64),
328    UnknownMapKey(&'a str),
329    NullByteInString,
330    CArrayReturnTypeNotSupported,
331    InvalidReturnType(&'a DataType),
332    DivisionByZero,
333    ModuloByZero,
334    /// An `int` raised to a negative `int` power, which has no `int` result.
335    /// The parser rejects the all-literal form; this is the run-time one.
336    NegativeExponent(i64),
337    /// An `int` shifted by a negative count, or by 64 or more, which has no
338    /// `int` result. The parser and the compiler reject a count written as a
339    /// literal; this is the run-time one. Carries the count.
340    ShiftCountOutOfRange(i64),
341    /// A json string could not be parsed. Carries a short static reason.
342    JsonParse(&'static str),
343    /// A downcast of an `any` value found a different runtime type than the
344    /// requested one. Carries (requested, found) type names.
345    BadDowncast(&'a str, &'static str),
346    /// A registered host function returned an error. Carries the name the
347    /// script calls it by and the message the host reported.
348    HostFn(&'a str, &'a str),
349    /// A string operation, a join or an ordering comparison, reached a value
350    /// that is not a string, which only happens when a value arrived typed as
351    /// a string and is not one. Carries the type name it turned out to have.
352    NotAString(&'static str),
353    /// A call that would stand one frame past the call-depth limit. Carries the
354    /// name the call site wrote and the limit it reached.
355    CallDepthExceeded(&'a str, usize),
356}
357
358impl From<ErrType<'_>> for SmolStr {
359    fn from(value: ErrType) -> Self {
360        match value {
361            ErrType::Custom(m) => m.to_smolstr(),
362            ErrType::InvalidFloat => "Invalid float".into(),
363            ErrType::IndexOutOfBounds(length, index) => format_args!("Tried to get index {RED}{BOLD}{index}{RESET} but the length is {BLUE}{BOLD}{length}{RESET}").to_smolstr(),
364            ErrType::SliceOutOfBounds(length, idx_start, idx_end) => format_args!("Invalid range {RED}{BOLD}{idx_start}{RESET}..{RED}{BOLD}{idx_end}{RESET} for collection with length {BLUE}{BOLD}{length}{RESET}").to_smolstr(),
365            ErrType::InvalidBool => "The string could not be parsed into a boolean".into(),
366            ErrType::InvalidInt => "Invalid integer".into(),
367            ErrType::FsAlreadyExists => "The entity (directory, file, ...) already exists".into(),
368            ErrType::FsDeadlock => "This operation would result in a deadlock".into(),
369            ErrType::FsFileTooLarge => "The file is too large".into(),
370            ErrType::FsInterrupted => "This operation was interrupted".into(),
371            ErrType::FsInvalidData => "Malformed or invalid data were encountered".into(),
372            ErrType::FsInvalidFilename => "The filename is invalid or too long".into(),
373            ErrType::FsIsADirectory => "This operation encountered a directory, when a non-directory was expected".into(),
374            ErrType::FsNotADirectory => "This operation encountered a non-directory, when a directory was expected".into(),
375            ErrType::FsNotFound => "The entity (directory, file, ...) was not found".into(),
376            ErrType::FsPermissionDenied => "This operation lacked the necessary privileges to complete".into(),
377            ErrType::FsOutOfMemory => "This operation could not be completed, because it failed to allocate enough memory".into(),
378            ErrType::FsReadOnlyFilesystem => "The filesystem or storage medium is read-only, but a write operation was attempted".into(),
379            ErrType::FsStorageFull => "Storage is full".into(),
380            ErrType::FsTimedOut => "This operation timed out".into(),
381            ErrType::DivisionByZero => "Division by zero. I'm sorry Dave, I'm afraid I can't do that.".into(),
382            ErrType::ModuloByZero => "Modulo by zero. I'm sorry Dave, I'm afraid I can't do that.".into(),
383            ErrType::NegativeExponent(exp) => format_args!("An integer cannot be raised to the negative power {RED}{BOLD}{exp}{RESET}. Use floats for a fractional result").to_smolstr(),
384            ErrType::ShiftCountOutOfRange(count) => format_args!("An {BLUE}{BOLD}int{RESET} is 64 bits wide, so it cannot be shifted by {RED}{BOLD}{count}{RESET}. The count must be between 0 and 63").to_smolstr(),
385            ErrType::NullByteInString => "String passed to dynamic library function contains an interior null byte".into(),
386            ErrType::InvalidReturnType(t) => format_args!("Invalid return type: {RED}{BOLD}{t}{RESET}").to_smolstr(),
387            ErrType::CArrayReturnTypeNotSupported => "Array return types are not supported: C does not convey the length of a returned array".into(),
388            ErrType::UnknownMapKey(key) => format_args!("Unknown key {RED}{BOLD}{key}{RESET}").to_smolstr(),
389            ErrType::JsonParse(reason) => format_args!("Invalid JSON: {RED}{BOLD}{reason}{RESET}").to_smolstr(),
390            ErrType::BadDowncast(want, got) => format_args!("Cannot read this {RED}{BOLD}{got}{RESET} value as {BLUE}{BOLD}{want}{RESET}").to_smolstr(),
391            ErrType::HostFn(function, message) => format_args!("Host function {BLUE}{BOLD}{function}{RESET} failed: {RED}{BOLD}{message}{RESET}").to_smolstr(),
392            ErrType::NotAString(got) => format_args!("Cannot use this {RED}{BOLD}{got}{RESET} value as a {BLUE}{BOLD}string{RESET}").to_smolstr(),
393            ErrType::CallDepthExceeded(name, limit) => format_args!("The call to {RED}{BOLD}{name}{RESET} stands {BLUE}{BOLD}{limit}{RESET} calls deep, which is the call depth limit").to_smolstr(),
394        }
395    }
396}
397
398/// Every kind a runtime error the language raises itself can carry: what
399/// [`ErrType::kind`] answers for each variant but `Custom`, whose kind is the
400/// one a `throw` names. Keep the two in step.
401pub const BUILTIN_KINDS: &[&str] = &[
402    "fs_already_exists",
403    "fs_deadlock",
404    "fs_file_too_large",
405    "fs_interrupted",
406    "fs_invalid_data",
407    "fs_invalid_filename",
408    "fs_is_a_directory",
409    "fs_not_a_directory",
410    "fs_not_found",
411    "fs_permission_denied",
412    "fs_out_of_memory",
413    "fs_read_only_filesystem",
414    "fs_storage_full",
415    "fs_timed_out",
416    "invalid_int",
417    "invalid_float",
418    "invalid_bool",
419    "index_out_of_bounds",
420    "slice_out_of_bounds",
421    "division_by_zero",
422    "modulo_by_zero",
423    "negative_exponent",
424    "shift_count_out_of_range",
425    "null_byte_in_string",
426    "c_array_return_type_not_supported",
427    "invalid_return_type",
428    "unknown_map_key",
429    "json_parse_error",
430    "bad_downcast",
431    "host_fn_error",
432    "not_a_string",
433    "call_depth_exceeded",
434];
435
436impl ErrType<'_> {
437    pub const fn kind(&self) -> &str {
438        match self {
439            ErrType::Custom(e) => e,
440            ErrType::FsAlreadyExists => "fs_already_exists",
441            ErrType::FsDeadlock => "fs_deadlock",
442            ErrType::FsFileTooLarge => "fs_file_too_large",
443            ErrType::FsInterrupted => "fs_interrupted",
444            ErrType::FsInvalidData => "fs_invalid_data",
445            ErrType::FsInvalidFilename => "fs_invalid_filename",
446            ErrType::FsIsADirectory => "fs_is_a_directory",
447            ErrType::FsNotADirectory => "fs_not_a_directory",
448            ErrType::FsNotFound => "fs_not_found",
449            ErrType::FsPermissionDenied => "fs_permission_denied",
450            ErrType::FsOutOfMemory => "fs_out_of_memory",
451            ErrType::FsReadOnlyFilesystem => "fs_read_only_filesystem",
452            ErrType::FsStorageFull => "fs_storage_full",
453            ErrType::FsTimedOut => "fs_timed_out",
454            ErrType::InvalidInt => "invalid_int",
455            ErrType::InvalidFloat => "invalid_float",
456            ErrType::InvalidBool => "invalid_bool",
457            ErrType::IndexOutOfBounds(_, _) => "index_out_of_bounds",
458            ErrType::SliceOutOfBounds(_, _, _) => "slice_out_of_bounds",
459            ErrType::DivisionByZero => "division_by_zero",
460            ErrType::ModuloByZero => "modulo_by_zero",
461            ErrType::NegativeExponent(_) => "negative_exponent",
462            ErrType::ShiftCountOutOfRange(_) => "shift_count_out_of_range",
463            ErrType::NullByteInString => "null_byte_in_string",
464            ErrType::CArrayReturnTypeNotSupported => "c_array_return_type_not_supported",
465            ErrType::InvalidReturnType(_) => "invalid_return_type",
466            ErrType::UnknownMapKey(_) => "unknown_map_key",
467            ErrType::JsonParse(_) => "json_parse_error",
468            ErrType::BadDowncast(_, _) => "bad_downcast",
469            ErrType::HostFn(_, _) => "host_fn_error",
470            ErrType::NotAString(_) => "not_a_string",
471            ErrType::CallDepthExceeded(_, _) => "call_depth_exceeded",
472        }
473    }
474}
475
476/// A run-time error no `try` caught, as the VM hands it back to whoever ran
477/// the program.
478///
479/// It records the instruction that failed, the error's name and its message.
480/// Pair it with the program's [`ErrorCtx`] to place it in the source: turn it
481/// into a [`Diagnostic`] with [`Self::diagnostic`], or print the report and end
482/// the program with [`Self::report`].
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct RuntimeError {
485    pub instr: Instr,
486    /// The error's name, such as `index_out_of_bounds`, or the name a
487    /// `throw` gave it.
488    pub code: SmolStr,
489    /// The message, with the terminal colors the printed report shows.
490    pub message: SmolStr,
491}
492
493impl RuntimeError {
494    #[cold]
495    #[inline(never)]
496    #[must_use]
497    pub fn new(instr: Instr, t: ErrType) -> Self {
498        let code = SmolStr::from(t.kind());
499        Self {
500            instr,
501            code,
502            message: t.into(),
503        }
504    }
505
506    /// The source span the failing instruction was compiled from, and the file
507    /// it is in.
508    fn place<'a>(&self, ctx: &ErrorCtx<'a>) -> (&'a Source, Range<usize>) {
509        let InstrSrc {
510            instr: _,
511            span: Span { start, end },
512            file_id,
513        } = ctx
514            .instr_src
515            .iter()
516            .find(|s| s.instr == self.instr)
517            .unwrap_or(&InstrSrc {
518                instr: Instr::Halt(1),
519                span: Span { start: 0, end: 0 },
520                file_id: 0,
521            });
522        (
523            &ctx.sources[*file_id as usize],
524            (*start as usize)..(*end as usize),
525        )
526    }
527
528    /// The error as a [`Diagnostic`], its message without terminal colors.
529    #[cold]
530    #[inline(never)]
531    #[must_use]
532    pub fn diagnostic(&self, ctx: &ErrorCtx<'_>) -> Diagnostic {
533        let (src, span) = self.place(ctx);
534        Diagnostic {
535            filename: src.filename.to_string(),
536            span,
537            message: strip_ansi(self.message.as_str()),
538            code: self.code.to_string(),
539        }
540    }
541
542    /// Reports the error the way a program that stops on it does: prints the
543    /// report to stderr and ends the process, or, inside
544    /// [`collect_diagnostic`], hands the error to it as a [`Diagnostic`].
545    #[cold]
546    #[inline(never)]
547    pub fn report(&self, ctx: &ErrorCtx<'_>) -> ! {
548        let (src, span) = self.place(ctx);
549        if diagnostics_enabled() {
550            emit_diagnostic(
551                src.filename.as_str(),
552                span,
553                strip_ansi(self.message.as_str()),
554                &self.code,
555            );
556        }
557        let mut out = crate::captured_output::stderr();
558        let _ = writeln!(out, "{RED}CANDELA ERROR{RESET}");
559        let report = Report::build(ReportKind::Error, (src.filename.as_str(), span.clone()))
560            .with_label(
561                Label::new((src.filename.as_str(), span))
562                    .with_message(self.message.as_str())
563                    .with_color(Color::Red),
564            )
565            .finish();
566
567        // A stderr that refuses the report costs the report. Raising here would
568        // replace the error the program hit with an unrelated one about the
569        // stream.
570        let _ = report.write(
571            (
572                src.filename.as_str(),
573                ariadne::Source::from(src.contents.as_str()),
574            ),
575            &mut out,
576        );
577
578        crash();
579    }
580}
581
582#[cold]
583#[inline(never)]
584#[cfg(target_arch = "wasm32")]
585pub fn wasm_error(msg: &str) -> ! {
586    crate::captured_output::print(&format!("CANDELA ERROR\n{msg}\n"));
587    wasm_bindgen::throw_str("candela error");
588}
589
590#[cold]
591#[inline(never)]
592pub fn throw_compiler_error<'a>(
593    report: &dyn Fn() -> Report<'a, (&'a str, core::ops::Range<usize>)>,
594    sources: &'a [Source],
595    file_idx: u16,
596    span: Span,
597    message: &str,
598    code: &'static str,
599) -> ! {
600    if diagnostics_enabled() {
601        emit_diagnostic(
602            sources[file_idx as usize].filename.as_str(),
603            (span.start as usize)..(span.end as usize),
604            strip_ansi(message),
605            code,
606        );
607    }
608    let report = report();
609
610    let _ = report.write(
611        FnCache::new((move |id| Err(format!("Failed to fetch source {id}"))) as fn(&_) -> _)
612            .with_sources(
613                sources
614                    .iter()
615                    .map(|Source { filename, contents }| {
616                        (filename.as_str(), ariadne::Source::from(contents.as_str()))
617                    })
618                    .collect(),
619            ),
620        crate::captured_output::stderr(),
621    );
622
623    crash();
624}
625
626#[cold]
627#[inline(never)]
628pub fn crash() -> ! {
629    #[cfg(debug_assertions)]
630    panic!();
631
632    #[cfg(not(any(debug_assertions, target_arch = "wasm32", feature = "embed")))]
633    std::process::exit(1);
634
635    #[cfg(target_arch = "wasm32")]
636    wasm_bindgen::throw_str("candela_error");
637
638    #[cfg(all(feature = "embed", not(debug_assertions)))]
639    panic!();
640}