Skip to main content

nightjar_lang/
error.rs

1// Copyright 2026 Wayne Hong (h-alice) <contact@halice.art>
2// Nightjar Language Project
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! # Error module
17//!
18//! Unified error types, stable error codes, and source-position spans used
19//! for diagnostics across both the parser and the runtime executor.
20
21use thiserror::Error;
22
23/// Byte-offset span in the source expression
24/// used **exclusively for error tracking and diagnostics**.
25///
26/// `Span` carries source positions so that errors can point back at the
27/// offending token, and offers richer error reporting.
28///
29/// ## Note
30/// Offsets are in **bytes** (not char indices), compatible with Rust's
31/// native `&str` slicing, and safe across Unicode content because the
32/// tokenizer only records positions at char boundaries.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Span {
35    /// Inclusive start byte offset.
36    pub start: usize,
37    /// Exclusive end byte offset.
38    pub end: usize,
39}
40
41impl Span {
42    /// Construct a span covering `[start, end)` in byte offsets.
43    pub const fn new(start: usize, end: usize) -> Self {
44        Self { start, end }
45    }
46
47    /// Construct a zero-width span at a single byte offset, useful for
48    /// errors at EOF or between tokens.
49    pub const fn point(at: usize) -> Self {
50        Self { start: at, end: at }
51    }
52}
53
54/// Stable, machine-readable error codes.
55///
56/// Quick reference:
57/// - E001: ParseError
58/// - E002: TypeError
59/// - E003: ArityError
60/// - E004: SymbolNotFound
61/// - E005: AmbiguousSymbol
62/// - E006: DivisionByZero
63/// - E007: RecursionError
64/// - E008: IndexError
65/// - E009: IntegerOverflow
66/// - E010: ScopeError
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ErrorCode {
69    /// Parse error: expression does not conform to the grammar.
70    E001,
71    /// Type error:operator applied to incompatible types.
72    E002,
73    /// Argument error: wrong operand count for an operator.
74    E003,
75    /// Symbol not found: symbol path is not present in the payload.
76    E004,
77    /// Ambiguous symbol: reserved for a future shorthand-lookup mode.
78    E005,
79    /// Division by zero in `Div` or `Mod`.
80    E006,
81    /// Recursion error: AST nesting exceeded `max_depth`.
82    E007,
83    /// Index error: `Get`/`Head`/`Tail` ran off the end of a list.
84    E008,
85    /// Integer overflow during checked arithmetic.
86    E009,
87    /// Scope error: `@` symbol used outside any quantifier predicate.
88    E010,
89}
90
91/// Unified error type for the entire crate.
92#[derive(Debug, Clone, Error, PartialEq)]
93pub enum NightjarLanguageError {
94    /// Expression does not conform to the grammar (E001).
95    #[error("[{code:?}] Parse error at {span:?}: {message}")]
96    ParseError {
97        /// Source span of the offending token.
98        span: Span,
99        /// Stable error code.
100        code: ErrorCode,
101        /// Human-readable diagnostic.
102        message: String,
103    },
104
105    /// Operator applied to incompatible types (E002).
106    #[error("[{code:?}] Type error at {span:?}: {message}")]
107    TypeError {
108        /// Source span of the offending expression.
109        span: Span,
110        /// Stable error code.
111        code: ErrorCode,
112        /// Human-readable diagnostic.
113        message: String,
114    },
115
116    /// Wrong number of operands for an operator (E003).
117    #[error("[{code:?}] Argument error at {span:?}: {message}")]
118    ArgumentError {
119        /// Source span of the offending call.
120        span: Span,
121        /// Stable error code.
122        code: ErrorCode,
123        /// Human-readable diagnostic.
124        message: String,
125    },
126
127    /// Symbol path not present in the payload (E004).
128    #[error("[{code:?}] Symbol not found at {span:?}: {message}")]
129    SymbolNotFound {
130        /// Source span of the offending symbol reference.
131        span: Span,
132        /// Stable error code.
133        code: ErrorCode,
134        /// Human-readable diagnostic.
135        message: String,
136    },
137
138    /// Reserved for a future shorthand-lookup mode; not raised today (E005).
139    #[error("[{code:?}] Ambiguous symbol at {span:?}: {message}")]
140    AmbiguousSymbol {
141        /// Source span of the offending symbol reference.
142        span: Span,
143        /// Stable error code.
144        code: ErrorCode,
145        /// Human-readable diagnostic.
146        message: String,
147    },
148
149    /// `Div` or `Mod` invoked with a zero divisor (E006).
150    #[error("[{code:?}] Division by zero at {span:?}: {message}")]
151    DivisionByZero {
152        /// Source span of the offending operation.
153        span: Span,
154        /// Stable error code.
155        code: ErrorCode,
156        /// Human-readable diagnostic.
157        message: String,
158    },
159
160    /// AST nesting exceeded the configured `max_depth` (E007).
161    #[error("[{code:?}] Recursion depth limit exceeded at {span:?}: {message}")]
162    RecursionError {
163        /// Source span at which the limit was exceeded.
164        span: Span,
165        /// Stable error code.
166        code: ErrorCode,
167        /// Human-readable diagnostic.
168        message: String,
169    },
170
171    /// `Get`/`Head`/`Tail` ran off the end of a list (E008).
172    #[error("[{code:?}] Index out of bounds at {span:?}: {message}")]
173    IndexError {
174        /// Source span of the offending operation.
175        span: Span,
176        /// Stable error code.
177        code: ErrorCode,
178        /// Human-readable diagnostic.
179        message: String,
180    },
181
182    /// Checked integer arithmetic overflowed (E009).
183    #[error("[{code:?}] Integer overflow at {span:?}: {message}")]
184    IntegerOverflow {
185        /// Source span of the offending operation.
186        span: Span,
187        /// Stable error code.
188        code: ErrorCode,
189        /// Human-readable diagnostic.
190        message: String,
191    },
192
193    /// `@` element-relative symbol used outside any quantifier predicate (E010).
194    #[error("[{code:?}] Scope error at {span:?}: {message}")]
195    ScopeError {
196        /// Source span of the offending symbol reference.
197        span: Span,
198        /// Stable error code.
199        code: ErrorCode,
200        /// Human-readable diagnostic.
201        message: String,
202    },
203}
204
205impl NightjarLanguageError {
206    /// Source span of the error within the original expression text.
207    pub fn span(&self) -> Span {
208        match self {
209            NightjarLanguageError::ParseError { span, .. }
210            | NightjarLanguageError::TypeError { span, .. }
211            | NightjarLanguageError::ArgumentError { span, .. }
212            | NightjarLanguageError::SymbolNotFound { span, .. }
213            | NightjarLanguageError::AmbiguousSymbol { span, .. }
214            | NightjarLanguageError::DivisionByZero { span, .. }
215            | NightjarLanguageError::RecursionError { span, .. }
216            | NightjarLanguageError::IndexError { span, .. }
217            | NightjarLanguageError::IntegerOverflow { span, .. }
218            | NightjarLanguageError::ScopeError { span, .. } => *span,
219        }
220    }
221
222    /// Stable [`ErrorCode`] tag identifying the error variant.
223    pub fn code(&self) -> ErrorCode {
224        match self {
225            NightjarLanguageError::ParseError { code, .. }
226            | NightjarLanguageError::TypeError { code, .. }
227            | NightjarLanguageError::ArgumentError { code, .. }
228            | NightjarLanguageError::SymbolNotFound { code, .. }
229            | NightjarLanguageError::AmbiguousSymbol { code, .. }
230            | NightjarLanguageError::DivisionByZero { code, .. }
231            | NightjarLanguageError::RecursionError { code, .. }
232            | NightjarLanguageError::IndexError { code, .. }
233            | NightjarLanguageError::IntegerOverflow { code, .. }
234            | NightjarLanguageError::ScopeError { code, .. } => *code,
235        }
236    }
237
238    /// Human-readable diagnostic message attached to the error.
239    pub fn message(&self) -> &str {
240        match self {
241            NightjarLanguageError::ParseError { message, .. }
242            | NightjarLanguageError::TypeError { message, .. }
243            | NightjarLanguageError::ArgumentError { message, .. }
244            | NightjarLanguageError::SymbolNotFound { message, .. }
245            | NightjarLanguageError::AmbiguousSymbol { message, .. }
246            | NightjarLanguageError::DivisionByZero { message, .. }
247            | NightjarLanguageError::RecursionError { message, .. }
248            | NightjarLanguageError::IndexError { message, .. }
249            | NightjarLanguageError::IntegerOverflow { message, .. }
250            | NightjarLanguageError::ScopeError { message, .. } => message,
251        }
252    }
253}
254
255// ╭──────────────────────────────────────────────────────────────────╮
256//  ═══════════════════ Internal helper constructors ════════════════════
257// ╰──────────────────────────────────────────────────────────────────╯
258//
259// These are `pub(crate)` so every module can produce spanned errors in a
260// convenient way.
261
262/// Build a `ParseError` (code `E001`).
263///
264/// Used by the tokenizer and the recursive-descent parser when the input
265/// fails a lexical or grammatical rule (unexpected character, missing `)`,
266/// stray token after expression, etc.).
267///
268/// # Example
269///
270/// ```ignore
271/// use crate::error::{parse_error, ErrorCode, Span};
272/// let err = parse_error(Span::new(4, 5), "unexpected token");
273/// assert_eq!(err.code(), ErrorCode::E001);
274/// assert!(err.message().contains("unexpected"));
275/// ```
276pub(crate) fn parse_error(span: Span, message: impl Into<String>) -> NightjarLanguageError {
277    NightjarLanguageError::ParseError {
278        span,
279        code: ErrorCode::E001,
280        message: message.into(),
281    }
282}
283
284/// Build a `TypeError` (code `E002`).
285///
286/// Raised by the executor when operands flow into a function or verifier with
287/// an incompatible runtime type — for example `(GT "a" 1)` or quantifying
288/// over a `Map`.
289///
290/// # Example
291///
292/// ```ignore
293/// use crate::error::{type_error, ErrorCode, Span};
294/// let err = type_error(Span::new(0, 8), "cannot compare String with Int");
295/// assert_eq!(err.code(), ErrorCode::E002);
296/// ```
297pub(crate) fn type_error(span: Span, message: impl Into<String>) -> NightjarLanguageError {
298    NightjarLanguageError::TypeError {
299        span,
300        code: ErrorCode::E002,
301        message: message.into(),
302    }
303}
304
305/// Build an `ArgumentError` (code `E003`).
306///
307/// Raised by the parser when a fixed-arity operator gets the wrong number of
308/// operands, e.g. `(GT 1 2 3)`.
309///
310/// # Example
311///
312/// ```ignore
313/// use crate::error::{argument_error, ErrorCode, Span};
314/// let err = argument_error(Span::new(7, 8), "verifier takes exactly 2 operands");
315/// assert_eq!(err.code(), ErrorCode::E003);
316/// ```
317pub(crate) fn argument_error(span: Span, message: impl Into<String>) -> NightjarLanguageError {
318    NightjarLanguageError::ArgumentError {
319        span,
320        code: ErrorCode::E003,
321        message: message.into(),
322    }
323}
324
325/// Build a `SymbolNotFound` error (code `E004`).
326///
327/// Raised at runtime when a symbol path does not exist in the symbol table,
328/// or when an element-relative `@` path misses a field on the current
329/// iteration element.
330///
331/// # Example
332///
333/// ```ignore
334/// use crate::error::{symbol_not_found, ErrorCode, Span};
335/// let err = symbol_not_found(Span::new(4, 12), ".data.missing");
336/// assert_eq!(err.code(), ErrorCode::E004);
337/// assert!(err.message().contains(".data.missing"));
338/// ```
339pub(crate) fn symbol_not_found(span: Span, path: &str) -> NightjarLanguageError {
340    NightjarLanguageError::SymbolNotFound {
341        span,
342        code: ErrorCode::E004,
343        message: format!("symbol `{}` not found", path),
344    }
345}
346
347/// Build a `DivisionByZero` error (code `E006`).
348///
349/// Raised by `Div` / `Mod` when the divisor reduces to `0` (Int) or `0.0` (Float).
350///
351/// # Example
352///
353/// ```ignore
354/// use crate::error::{division_by_zero, ErrorCode, Span};
355/// let err = division_by_zero(Span::new(5, 12));
356/// assert_eq!(err.code(), ErrorCode::E006);
357/// ```
358pub(crate) fn division_by_zero(span: Span) -> NightjarLanguageError {
359    NightjarLanguageError::DivisionByZero {
360        span,
361        code: ErrorCode::E006,
362        message: "division or modulo by zero".to_string(),
363    }
364}
365
366/// Build a `RecursionError` error (code `E007`).
367///
368/// Raised by the parser when the depth of an expression exceeds
369/// `ParserConfig::max_depth`, guards the host against stack overflow from
370/// adversarial input.
371///
372/// # Example
373///
374/// ```ignore
375/// use crate::error::{recursion_error, ErrorCode, Span};
376/// let err = recursion_error(Span::new(0, 1), 256);
377/// assert_eq!(err.code(), ErrorCode::E007);
378/// assert!(err.message().contains("256"));
379/// ```
380pub(crate) fn recursion_error(span: Span, limit: usize) -> NightjarLanguageError {
381    NightjarLanguageError::RecursionError {
382        span,
383        code: ErrorCode::E007,
384        message: format!("expression recursion depth exceeds limit ({})", limit),
385    }
386}
387
388/// Build an `IndexError` error (code `E008`).
389///
390/// Raised by the `Get` function when a list index lies outside `0..len`.
391///
392/// # Example
393///
394/// ```ignore
395/// use crate::error::{index_error, ErrorCode, Span};
396/// let err = index_error(Span::new(5, 12), 7, 3);
397/// assert_eq!(err.code(), ErrorCode::E008);
398/// assert!(err.message().contains('7') && err.message().contains('3'));
399/// ```
400pub(crate) fn index_error(span: Span, idx: i64, len: usize) -> NightjarLanguageError {
401    NightjarLanguageError::IndexError {
402        span,
403        code: ErrorCode::E008,
404        message: format!("index {} out of bounds for list of length {}", idx, len),
405    }
406}
407
408/// Build an `IntegerOverflow` error (code `E009`).
409///
410/// Raised by arithmetic functions when a `checked_*` operation on `i64`
411/// overflows (e.g. `Add` of `i64::MAX + 1`, `Neg` of `i64::MIN`).
412///
413/// # Example
414///
415/// ```ignore
416/// use crate::error::{integer_overflow, ErrorCode, Span};
417/// let err = integer_overflow(Span::new(0, 10), "Add");
418/// assert_eq!(err.code(), ErrorCode::E009);
419/// assert!(err.message().contains("Add"));
420/// ```
421pub(crate) fn integer_overflow(span: Span, op: &str) -> NightjarLanguageError {
422    NightjarLanguageError::IntegerOverflow {
423        span,
424        code: ErrorCode::E009,
425        message: format!("integer overflow in {}", op),
426    }
427}
428
429/// Build a `ScopeError` (code `E010`). Raised by the post-parse validator
430/// (or, as a defensive fallback, by the executor) when an element-relative
431/// `@` symbol appears outside any enclosing `ForAll` / `Exists` predicate.
432///
433/// # Example
434///
435/// ```ignore
436/// use crate::error::{scope_error, ErrorCode, Span};
437/// let err = scope_error(Span::new(4, 6), "`@` used outside a quantifier");
438/// assert_eq!(err.code(), ErrorCode::E010);
439/// ```
440pub(crate) fn scope_error(span: Span, message: impl Into<String>) -> NightjarLanguageError {
441    NightjarLanguageError::ScopeError {
442        span,
443        code: ErrorCode::E010,
444        message: message.into(),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn span_constructors() {
454        assert_eq!(Span::new(3, 7), Span { start: 3, end: 7 });
455        assert_eq!(Span::point(5), Span { start: 5, end: 5 });
456    }
457
458    #[test]
459    fn span_accessor_roundtrip() {
460        let span = Span::new(10, 20);
461        let err = parse_error(span, "bad token");
462        assert_eq!(err.span(), span);
463    }
464
465    #[test]
466    fn code_accessor_per_variant() {
467        assert_eq!(parse_error(Span::point(0), "x").code(), ErrorCode::E001);
468        assert_eq!(type_error(Span::point(0), "x").code(), ErrorCode::E002);
469        assert_eq!(argument_error(Span::point(0), "x").code(), ErrorCode::E003);
470        assert_eq!(
471            symbol_not_found(Span::point(0), ".foo").code(),
472            ErrorCode::E004
473        );
474        assert_eq!(division_by_zero(Span::point(0)).code(), ErrorCode::E006);
475        assert_eq!(recursion_error(Span::point(0), 256).code(), ErrorCode::E007);
476        assert_eq!(index_error(Span::point(0), 5, 3).code(), ErrorCode::E008);
477        assert_eq!(
478            integer_overflow(Span::point(0), "Add").code(),
479            ErrorCode::E009
480        );
481        assert_eq!(
482            scope_error(Span::point(0), "@ outside quantifier").code(),
483            ErrorCode::E010
484        );
485    }
486
487    #[test]
488    fn message_accessor() {
489        let err = type_error(Span::new(0, 3), "bad types");
490        assert_eq!(err.message(), "bad types");
491    }
492
493    #[test]
494    fn display_formatting_contains_code_and_span() {
495        let err = parse_error(Span::new(4, 9), "unexpected token");
496        let rendered = format!("{}", err);
497        assert!(rendered.contains("E001"));
498        assert!(rendered.contains("4"));
499        assert!(rendered.contains("9"));
500        assert!(rendered.contains("unexpected token"));
501    }
502
503    #[test]
504    fn symbol_not_found_formats_path() {
505        let err = symbol_not_found(Span::point(0), ".data.missing");
506        assert!(err.message().contains(".data.missing"));
507    }
508
509    #[test]
510    fn index_out_of_bounds_formats_idx_and_len() {
511        let err = index_error(Span::point(0), 7, 3);
512        assert!(err.message().contains('7'));
513        assert!(err.message().contains('3'));
514    }
515}