Skip to main content

relon_eval_api/
error.rs

1use miette::Diagnostic;
2use relon_parser::TokenRange;
3use thiserror::Error;
4
5/// Render a chain of identifiers/paths joined by `→`. Used by the
6/// `CircularReference` and `CircularImport` `Display` impls so the error
7/// message reads naturally instead of dumping a debug-formatted `Vec`.
8fn format_chain(chain: &[String]) -> String {
9    chain.join(" \u{2192} ")
10}
11
12#[derive(Error, Debug, Diagnostic, Clone)]
13pub enum RuntimeError {
14    #[error("Variable not found: {0}")]
15    #[diagnostic(
16        code(relon::eval::variable_not_found),
17        help("Check that the name is spelled correctly and is in scope at this point.")
18    )]
19    VariableNotFound(String, #[label("undefined")] TokenRange),
20
21    #[error("Type mismatch: expected {expected}, found {found}")]
22    #[diagnostic(code(relon::eval::type_mismatch))]
23    TypeMismatch {
24        expected: String,
25        found: String,
26        #[label("expected {expected}, got {found}")]
27        range: TokenRange,
28    },
29
30    #[error("Validation failed: {0}")]
31    #[diagnostic(code(relon::eval::validation_failed))]
32    ValidationError(String, #[label("validation failed here")] TokenRange),
33
34    #[error("Division by zero")]
35    #[diagnostic(
36        code(relon::eval::division_by_zero),
37        help("The right-hand operand of `/` or `%` evaluated to 0.")
38    )]
39    DivisionByZero(#[label("divisor is zero")] TokenRange),
40
41    #[error("Function not found: {0}")]
42    #[diagnostic(code(relon::eval::function_not_found))]
43    FunctionNotFound(String, #[label("called here")] TokenRange),
44
45    #[error("Circular reference detected: {}", format_chain(.cycle))]
46    #[diagnostic(
47        code(relon::eval::circular_reference),
48        help("Each entry depends on a later one in the cycle. Break the loop or replace one of the references with a literal value.")
49    )]
50    CircularReference {
51        /// Path segments that form the cycle, in declaration order.
52        cycle: Vec<String>,
53        #[label("triggers the cycle")]
54        range: TokenRange,
55    },
56
57    #[error("Unsupported operator {0:?}")]
58    #[diagnostic(code(relon::eval::unsupported_operator))]
59    UnsupportedOperator(String, #[label("not supported here")] TokenRange),
60
61    #[error("Invalid identifier: {0}")]
62    #[diagnostic(
63        code(relon::eval::invalid_identifier),
64        help("Function/decorator names must start with a letter or underscore and contain only alphanumeric characters or underscores.")
65    )]
66    InvalidIdentifier(String, #[label("invalid identifier")] TokenRange),
67
68    #[error("IO error: {0}")]
69    #[diagnostic(code(relon::eval::io_error))]
70    IoError(String),
71
72    #[error("Module not found at path: {0}")]
73    #[diagnostic(
74        code(relon::eval::module_not_found),
75        help("Check the path is relative to the importing file (or absolute) and that the file exists.")
76    )]
77    ModuleNotFound(String, #[label("import target missing")] miette::SourceSpan),
78
79    #[error("Parse error in module {path}: {message}")]
80    #[diagnostic(code(relon::eval::module_parse_error))]
81    ModuleParseError {
82        path: String,
83        message: String,
84        #[label("imported here")]
85        range: miette::SourceSpan,
86    },
87
88    #[error("Circular import detected: {}", format_chain(.0))]
89    #[diagnostic(
90        code(relon::eval::circular_import),
91        help("Two or more modules import each other. Restructure so the dependency is one-way.")
92    )]
93    CircularImport(
94        Vec<String>,
95        #[label("import that closes the cycle")] miette::SourceSpan,
96    ),
97
98    #[error("Numeric overflow")]
99    #[diagnostic(code(relon::eval::numeric_overflow))]
100    NumericOverflow(#[label("overflowed here")] TokenRange),
101
102    /// Step / resource budget exhausted. The tree-walker fills `limit`
103    /// with the configured `max_steps`; the compiled backends
104    /// trap with the numeric tag only and leave `limit` as `None`.
105    #[error("Step limit exceeded")]
106    #[diagnostic(
107        code(relon::eval::step_limit_exceeded),
108        help("The script ran longer than the configured `max_steps` / deadline budget. Raise `Capabilities::max_steps` or refactor recursive / iterative work.")
109    )]
110    StepLimitExceeded {
111        /// The `max_steps` budget that was crossed, when the denying
112        /// backend carries it (tree-walk). `None` on the compiled trap
113        /// path, which only knows that the budget was exceeded.
114        limit: Option<u64>,
115        #[label("budget exhausted here")]
116        range: TokenRange,
117    },
118
119    #[error("Recursion limit exceeded ({limit} levels)")]
120    #[diagnostic(
121        code(relon::eval::recursion_limit_exceeded),
122        help("A type-check or schema-validation pass nested deeper than the runtime's safety bound. Restructure the recursive type or value so it doesn't self-reference past this depth.")
123    )]
124    RecursionLimitExceeded {
125        limit: usize,
126        #[label("depth limit reached here")]
127        range: TokenRange,
128    },
129
130    #[error("Value too large: {actual} elements exceeds limit of {limit}")]
131    #[diagnostic(
132        code(relon::eval::value_too_large),
133        help("A list/tuple/dict grew past `Capabilities::max_value_elements`. Raise the limit or shrink the value.")
134    )]
135    ValueTooLarge {
136        limit: usize,
137        actual: usize,
138        #[label("constructed here")]
139        range: TokenRange,
140    },
141
142    /// Phase 4.c-2: an index / range operation walked off the end of
143    /// a String / List receiver. Both backends share this variant —
144    /// the tree-walker raises it from `xs[i]` style accessors, the
145    /// wasm AOT path raises it from `substring` / similar stdlib
146    /// builders when the caller-supplied bounds exceed the
147    /// receiver's length.
148    #[error("Index out of bounds")]
149    #[diagnostic(
150        code(relon::eval::index_out_of_bounds),
151        help("Inspect the receiver's length before indexing, or clamp the offset / length arguments so the slice stays inside the value.")
152    )]
153    IndexOutOfBounds {
154        #[label("index walked past the receiver length")]
155        range: TokenRange,
156    },
157
158    /// Phase 4.c-2: a reducer that requires at least one element
159    /// (`list_int_max`, future `head` / `last`, ...) was called on
160    /// an empty list. Carries the call-site source range so the
161    /// diagnostic points at the offending expression rather than at
162    /// the stdlib body itself.
163    #[error("Operation on empty list has no defined result")]
164    #[diagnostic(
165        code(relon::eval::empty_list),
166        help("Reducers like `list_int_max` need at least one element. Check the list isn't empty before calling, or supply an explicit fallback value.")
167    )]
168    EmptyList {
169        #[label("called on an empty list here")]
170        range: TokenRange,
171    },
172
173    /// A guarded native-fn / `#import` was denied because the host did
174    /// not grant a required capability. Produced by every backend: the
175    /// tree-walker fills a descriptive `reason` (and the bit, when it
176    /// has one); the compiled trap paths carry
177    /// only the numeric `cap_bit` and a generic `reason`.
178    #[error("Capability denied: {reason}")]
179    #[diagnostic(
180        code(relon::eval::capability_denied),
181        help("This Context is sandboxed. Grant the capability declared on the fn's gate (e.g. `caps.reads_fs = true`) to permit it.")
182    )]
183    CapabilityDenied {
184        /// Capability bit index that was denied, when the denying
185        /// backend carries it (compiled trap path; tree-walk native-fn
186        /// dispatch). `None` for FS-resolver denials that map to no
187        /// single bit, or when the compiled trap lost the bit.
188        cap_bit: Option<u32>,
189        /// Human-readable reason. Tree-walk fills the native-fn /
190        /// import detail; compiled backends fill "host-fn requires
191        /// capability bit N".
192        reason: String,
193        #[label("call rejected by sandbox")]
194        range: TokenRange,
195    },
196
197    #[error("file has no `#main(...)` signature; cannot run as entry program")]
198    #[diagnostic(
199        code(relon::eval::no_main_signature),
200        help(
201            "Add `#main(Type arg, ...)` to declare the file as an entry program, or evaluate it as a static config via `eval_root` instead of `run_main`."
202        )
203    )]
204    NoMainSignature {
205        #[label("no #main here")]
206        range: TokenRange,
207    },
208
209    #[error("missing argument `{name}` for `#main(...)`")]
210    #[diagnostic(
211        code(relon::eval::missing_main_arg),
212        help("The host must push a value for every parameter declared by `#main(...)`.")
213    )]
214    MissingMainArg {
215        name: String,
216        #[label("expected here")]
217        range: TokenRange,
218    },
219
220    #[error("unexpected argument `{name}`: not declared by `#main(...)`")]
221    #[diagnostic(
222        code(relon::eval::unexpected_main_arg),
223        help("Only parameters listed in `#main(...)` may be pushed; remove the extra entry or add it to the signature.")
224    )]
225    UnexpectedMainArg {
226        name: String,
227        #[label("not in signature")]
228        range: TokenRange,
229    },
230
231    #[error("type mismatch for `#main` arg `{name}`: expected {expected}, found {found}")]
232    #[diagnostic(code(relon::eval::main_arg_type_mismatch))]
233    MainArgTypeMismatch {
234        name: String,
235        expected: String,
236        found: String,
237        #[label("type mismatch")]
238        range: TokenRange,
239    },
240
241    #[error("type mismatch for `#main` return value: expected {expected}, found {found}")]
242    #[diagnostic(code(relon::eval::main_return_type_mismatch))]
243    MainReturnTypeMismatch {
244        expected: String,
245        found: String,
246        #[label("declared here")]
247        range: TokenRange,
248    },
249
250    /// Phase 8: the active backend cannot satisfy the requested
251    /// `Evaluator` method. The wasm-AOT backend uses this to refuse
252    /// `eval` / `eval_root` / `force_thunk` / `invoke_closure` because
253    /// its AST is consumed at compile time and the runtime only knows
254    /// how to drive the precompiled `run_main` entry. Host-side hooks
255    /// that depend on lazy / first-class-closure semantics need to
256    /// either switch to the tree-walker or be reformulated.
257    #[error("operation not supported by this backend: {reason}")]
258    #[diagnostic(
259        code(relon::eval::unsupported),
260        help("This backend lacks the runtime structures the operation needs. Switch to the tree-walking backend, or restrict the call to `run_main`.")
261    )]
262    Unsupported {
263        /// Human-readable explanation of why the backend cannot
264        /// honour the call. Free-form so each backend can describe
265        /// its own constraint (e.g. "wasm-aot has no AST at runtime").
266        reason: String,
267    },
268
269    /// v3+ a-3: remote `#import "https://..."` resolved an URL but the
270    /// HTTP fetch (DNS / connect / TLS / non-2xx status / body read)
271    /// failed. The payload is boxed so the variant does not bloat the
272    /// `RuntimeError` enum past clippy's `result_large_err` threshold —
273    /// callers should use the `url()` / `cause()` accessors below, or
274    /// destructure `*payload`.
275    #[error("remote import {}: {}", payload.url, payload.cause)]
276    #[diagnostic(
277        code(relon::eval::remote_import_failed),
278        help("The host could not retrieve the remote module. Check connectivity, the URL, and that the server returns a 2xx response with a Relon source body.")
279    )]
280    RemoteImportFailed {
281        payload: Box<RemoteImportFailure>,
282        #[label("remote import failed")]
283        range: TokenRange,
284    },
285
286    /// v3+ a-3: remote `#import "https://..."` was rejected before the
287    /// fetch ran because the active sandbox forbids network egress
288    /// (no `--trust` / no `Capabilities::network`).
289    #[error("remote import {} denied: {}", payload.url, payload.reason)]
290    #[diagnostic(
291        code(relon::eval::remote_import_denied),
292        help("Remote `#import` is a network operation. Run the host with `--trust` (CLI) or grant `Capabilities::network` to allow it.")
293    )]
294    RemoteImportDenied {
295        payload: Box<RemoteImportDenial>,
296        #[label("remote import rejected by sandbox")]
297        range: TokenRange,
298    },
299
300    /// v3+ a-3: an explicit integrity hash was supplied alongside a
301    /// remote `#import`, and the fetched body's sha256 did not match.
302    /// The pinning syntax itself is **not** wired in this phase, but
303    /// the variant ships so future syntax work (or an out-of-band
304    /// lockfile) can reuse the error surface without churning the
305    /// enum.
306    #[error(
307        "remote import {} hash mismatch: expected {}, got {}",
308        payload.url,
309        payload.expected,
310        payload.got
311    )]
312    #[diagnostic(
313        code(relon::eval::remote_import_hash_mismatch),
314        help("The remote source's sha256 differs from the pinned hash. Either update the pin or refuse to load the module.")
315    )]
316    RemoteImportHashMismatch {
317        payload: Box<RemoteImportHashMismatchDetail>,
318        #[label("hash mismatch on remote import")]
319        range: TokenRange,
320    },
321
322    /// review-improvement-174 (v3++ b-2 fix): the evaluator's `#import`
323    /// path computed the loaded module body's digest and it did not match
324    /// the inline `sha256:"..."` integrity pin written on the directive.
325    ///
326    /// Distinct from [`Self::RemoteImportHashMismatch`] so operators can
327    /// tell apart "remote fetch produced an unexpected body" (caught by
328    /// `RemoteHttpResolver` / analyzer) from "evaluator was handed a
329    /// pre-resolved module body that disagrees with its pin" — the latter
330    /// is the analyzer-bypass attack vector this fix closes.
331    #[error(
332        "import {} hash mismatch: expected {}:{}, got {}",
333        payload.path,
334        payload.algorithm,
335        payload.expected,
336        payload.got
337    )]
338    #[diagnostic(
339        code(relon::eval::import_hash_mismatch),
340        help("The module body the evaluator loaded does not match the inline integrity pin on this `#import`. Either update the pin to the new digest or refuse to trust the source.")
341    )]
342    ImportHashMismatch {
343        payload: Box<ImportHashMismatchDetail>,
344        #[label("import body does not match pinned digest")]
345        range: TokenRange,
346    },
347
348    /// review-improvement-174: the inline pin on a `#import` carried an
349    /// algorithm identifier (`<algo>:"..."`) the evaluator does not know
350    /// how to compute. The analyzer surfaces the same condition as a
351    /// `WorkspaceDiagnostic::ImportHashUnknownAlgorithm`; this variant
352    /// mirrors it for the analyzer-bypass path so the evaluator never
353    /// silently treats an unknown algorithm as "no pin".
354    #[error("import {path} pinned with unsupported hash algorithm `{algorithm}`")]
355    #[diagnostic(
356        code(relon::eval::import_hash_unknown_algorithm),
357        help("Use a supported algorithm (currently `sha256:`). The evaluator refuses to load an `#import` it cannot verify against the pin.")
358    )]
359    ImportHashUnknownAlgorithm {
360        path: String,
361        algorithm: String,
362        #[label("unsupported integrity algorithm")]
363        range: TokenRange,
364    },
365
366    /// review-improvement-174: the inline pin hex was malformed (wrong
367    /// length, non-hex character). Mirrors the analyzer's
368    /// `WorkspaceDiagnostic::ImportHashInvalidHex` for the
369    /// evaluator-direct path; a malformed pin is rejected fail-closed
370    /// because we cannot compare against gibberish.
371    #[error(
372        "import {path} pinned with invalid {algorithm} hex (expected {expected_len} chars, got {got_len})"
373    )]
374    #[diagnostic(
375        code(relon::eval::import_hash_invalid_hex),
376        help("The pin's hex digest is not the expected length or contains non-hex characters. Re-encode the digest as lowercase hex.")
377    )]
378    ImportHashInvalidHex {
379        path: String,
380        algorithm: String,
381        expected_len: usize,
382        got_len: usize,
383        #[label("invalid integrity hex")]
384        range: TokenRange,
385    },
386}
387
388/// Boxed payload for [`RuntimeError::RemoteImportFailed`]. Holds the
389/// URL the host attempted to fetch plus a free-form cause string so
390/// the per-host fetch error type does not leak into the enum surface.
391#[derive(Debug, Clone)]
392pub struct RemoteImportFailure {
393    pub url: String,
394    pub cause: String,
395}
396
397/// Boxed payload for [`RuntimeError::RemoteImportDenied`]. Holds the
398/// URL the script attempted to import plus the human-readable reason
399/// the sandbox refused it.
400#[derive(Debug, Clone)]
401pub struct RemoteImportDenial {
402    pub url: String,
403    pub reason: String,
404}
405
406/// Boxed payload for [`RuntimeError::RemoteImportHashMismatch`]. The
407/// hash-pinning syntax is not wired yet (see the variant doc), but
408/// the type ships so the eventual lockfile / inline-pin work can
409/// produce it without churning the error enum's layout.
410#[derive(Debug, Clone)]
411pub struct RemoteImportHashMismatchDetail {
412    pub url: String,
413    pub expected: String,
414    pub got: String,
415}
416
417/// Boxed payload for [`RuntimeError::ImportHashMismatch`]. Carries the
418/// raw `#import` path, the algorithm name, and the expected / actual
419/// digests so error rendering can surface enough context for the
420/// operator to decide whether to update the pin or refuse the load.
421#[derive(Debug, Clone)]
422pub struct ImportHashMismatchDetail {
423    /// `#import "..."` path as written in source (may be a local path,
424    /// `std/...`, or a `https://` URL — the integrity check is
425    /// path-agnostic so the analyzer-bypass attack vector cannot find
426    /// a path shape that skips verification).
427    pub path: String,
428    /// Algorithm identifier as it appears in the pin (e.g. `sha256`).
429    pub algorithm: String,
430    /// Lower-case hex digest the pin asserted.
431    pub expected: String,
432    /// Lower-case hex digest the evaluator computed over the loaded
433    /// module body.
434    pub got: String,
435}