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}