Skip to main content

qala_compiler/
errors.rs

1//! the compiler's error type. one enum, [`QalaError`], returned everywhere as
2//! `Result<T, QalaError>`. every variant carries a [`Span`] so a diagnostic can
3//! point at the exact source text; rich rendering (the source line plus an
4//! underline) is built in a later phase on top of that span.
5//!
6//! the lexer's and the parser's variants exist now. type-checker and runtime
7//! variants follow the same `{ span, message }` shape and are sketched in
8//! comments below so later phases extend this enum rather than restructure it.
9
10use crate::ast::BinOp;
11use crate::span::Span;
12use crate::token::TokenKind;
13
14/// every way the compiler can reject a program, with the source span of the
15/// fault. `PartialEq` so tests can compare errors directly; no `Eq` and no
16/// `serde` derives, because the parse variants carry [`TokenKind`], which holds
17/// an `f64` and is therefore neither `Eq` nor (currently) `serde`-derivable.
18/// the diagnostics layer (Phase 3) builds its own structured editor form from
19/// `span()` and `message()` rather than serializing this enum directly.
20#[derive(Debug, Clone, PartialEq)]
21pub enum QalaError {
22    // ---- lex ----
23    /// a string literal reached a raw newline or end of file before its closing
24    /// `"`. span = the opening quote, since that is where the reader's attention
25    /// belongs, not the place the scanner ran out of input.
26    UnterminatedString { span: Span },
27
28    /// an interpolation `{` inside a string was never closed by a `}` before the
29    /// string ended or the file ended. span = the unmatched `{`.
30    UnterminatedInterpolation { span: Span },
31
32    /// a backslash escape sequence that the language does not define (anything
33    /// other than `\n \t \r \0 \\ \" \{ \}` or a well-formed `\u{...}`). span =
34    /// the backslash, not the character after it.
35    InvalidEscape { span: Span, message: String },
36
37    /// a byte that cannot begin any token here: a non-ASCII byte outside a
38    /// string or comment, or a lone `&` / `|`. span = the offending byte (the
39    /// whole UTF-8 sequence for a non-ASCII character). `ch` is the decoded
40    /// character, for the message.
41    UnexpectedChar { span: Span, ch: char },
42
43    /// an integer literal whose magnitude does not fit in `i64`. span = the
44    /// digits.
45    IntOverflow { span: Span },
46
47    /// a numeric literal with a malformed shape: a misplaced digit separator
48    /// (`1_`, `1__0`, `1_.0`), an empty or invalid radix body (`0x`, `0xG`,
49    /// `0b2`), an exponent with no digits (`1e`). span = the literal.
50    MalformedNumber { span: Span, message: String },
51
52    /// a byte literal that is not exactly one ASCII character or one one-byte
53    /// escape between `b'` and `'` (`b''`, `b'ab'`, `b'\x'`, a non-ASCII byte
54    /// inside). span = the literal.
55    BadByteLiteral { span: Span, message: String },
56
57    // ---- parse ----
58    /// the parser found a token that is not legal at this position. span = the
59    /// offending token, or, when `found` is [`TokenKind::Eof`], a zero-width
60    /// point just after the last real token. `expected` is the set of token
61    /// kinds the parser could have accepted here.
62    UnexpectedToken {
63        span: Span,
64        expected: Vec<TokenKind>,
65        found: TokenKind,
66    },
67
68    /// an opening delimiter (`(` / `[` / `{`) was closed by the wrong delimiter
69    /// or never closed at all. span = the *opening* delimiter, not the surprising
70    /// closer, because the opener is where the reader needs to look. `found` is
71    /// the wrong closer, or [`TokenKind::Eof`] if the input ran out first.
72    UnclosedDelimiter {
73        span: Span,
74        opener: TokenKind,
75        found: TokenKind,
76    },
77
78    /// the input ended while the parser was still expecting more. span = a
79    /// zero-width point immediately after the last valid token (offset = end of
80    /// that token, length 0), not `src.len()` unless that is where the last
81    /// token ends. `expected` is what would have been legal next.
82    UnexpectedEof {
83        span: Span,
84        expected: Vec<TokenKind>,
85    },
86
87    /// a parse failure that does not fit the structured variants above: the
88    /// recursion-depth limit ("expression nests too deeply"), a malformed
89    /// pipeline right-hand side, and similar. span = wherever the failure was
90    /// detected; `message` is the human description.
91    Parse { span: Span, message: String },
92
93    // ---- type / effect ----
94    /// an expression's actual type does not match the type required by its
95    /// context (an argument vs a parameter, an initializer vs an annotation, an
96    /// arm body vs the first arm's type, ...). span = the offending
97    /// sub-expression. `expected` and `found` are rendered via
98    /// `QalaType::display()` so the wording is always the canonical form (`i64`,
99    /// `Result<i64, str>`, `?` for the poison type, and so on).
100    TypeMismatch {
101        span: Span,
102        expected: String,
103        found: String,
104    },
105
106    /// a function declared to return a non-`void` type fell off the end of its
107    /// body without producing a value. span = the last expression in the body,
108    /// or the closing brace of an empty body, because that is where the missing
109    /// value should appear. `expected` is the canonical form of the declared
110    /// return type.
111    MissingReturn {
112        span: Span,
113        fn_name: String,
114        expected: String,
115    },
116
117    /// a name is used that the type checker cannot resolve to a local, a
118    /// parameter, a top-level function, or a stdlib entry. span = the
119    /// identifier. one variant covers free identifiers and unknown function
120    /// calls together; the message is the same either way.
121    UndefinedName { span: Span, name: String },
122
123    /// a type name is used (in a parameter annotation, a struct field, an enum
124    /// variant data position, a `let` annotation, a generic argument) that no
125    /// `struct` / `enum` / `interface` declaration nor any built-in primitive
126    /// matches. span = the type expression's source position.
127    UnknownType { span: Span, name: String },
128
129    /// a struct contains itself by value, directly or transitively through
130    /// other by-value compounds. span = the declaration site of the
131    /// lexicographically smallest struct in the cycle (the "head" -- chosen so
132    /// the message is deterministic). `path` lists the cycle's struct names
133    /// starting and ending with the head, so the cycle reads as a closed loop
134    /// when joined with arrows.
135    RecursiveStructByValue { span: Span, path: Vec<String> },
136
137    /// a `match` does not cover every variant of its scrutinee's enum and has
138    /// no `_` wildcard. span = the `match` keyword (where the reader's
139    /// attention belongs). `missing` is alphabetically sorted by the type
140    /// checker before this variant is constructed; the message stores the
141    /// already-sorted list verbatim so the output is deterministic.
142    NonExhaustiveMatch {
143        span: Span,
144        enum_name: String,
145        missing: Vec<String>,
146    },
147
148    /// a named type used in an interface position does not satisfy the
149    /// interface: one or more required methods are missing, and one or more
150    /// methods that do exist have the wrong signature. span = the use site (the
151    /// place where the type was demanded to satisfy the interface). `missing`
152    /// and `mismatched` are sorted by the type checker; the diagnostics layer
153    /// turns them into per-method `note:` lines. each `mismatched` tuple is
154    /// `(method_name, expected_signature, found_signature)`.
155    InterfaceNotSatisfied {
156        span: Span,
157        ty: String,
158        interface: String,
159        missing: Vec<String>,
160        mismatched: Vec<(String, String, String)>,
161    },
162
163    /// a function with one effect set is calling a function with effects it has
164    /// not declared. span = the call site. `caller_effect` / `callee_effect`
165    /// are the lowercase effect words (`pure`, `io`, `alloc`, `panic`, or a
166    /// comma-joined combo like `io, alloc`) produced by
167    /// `EffectSet::display()`. the message reads "{caller_effect} function
168    /// `{caller}` calls {callee_effect} function `{callee}`".
169    EffectViolation {
170        span: Span,
171        caller: String,
172        caller_effect: String,
173        callee: String,
174        callee_effect: String,
175    },
176
177    /// the `?` operator was used somewhere it cannot be: outside a
178    /// `Result`/`Option`-returning function, or on a value whose type is not
179    /// `Result<_, _>` / `Option<_>`, or where the operand's error type does
180    /// not match the enclosing function's error type. span = the `?` token.
181    /// the type checker constructs the specific human wording so this variant
182    /// stores the message structurally, mirroring the existing `Parse`
183    /// fallback.
184    RedundantQuestionOperator { span: Span, message: String },
185
186    /// a type-level fault that does not fit any structured variant above: a
187    /// literal pattern matched against an enum value, a variant name that is
188    /// not a member of the enum, a `?` operand whose type is not `Result` /
189    /// `Option`, and so on. span = wherever the fault was detected;
190    /// `message` is the human description. mirrors the existing `Parse`
191    /// fallback so later passes can extend the type-error vocabulary without
192    /// reshaping the enum.
193    Type { span: Span, message: String },
194
195    // ---- codegen / comptime ----
196    /// an arithmetic operation that would overflow `i64` if computed at
197    /// compile time. emitted by codegen's inline constant folder before the
198    /// operation is materialised in bytecode. span = the outer binary
199    /// operator's full span (the `Binary { span, .. }` node, not the
200    /// operator-token span). `op` is the offending operator; `lhs` and `rhs`
201    /// are the literal operands at fold time, used to render a precise
202    /// message like `integer overflow: 9223372036854775807 * 2 does not fit
203    /// in i64`. comparison and logical [`BinOp`] variants (`Eq`, `Lt`, `&&`,
204    /// and the rest) never reach this variant -- those folds cannot overflow.
205    IntegerOverflow {
206        span: Span,
207        op: BinOp,
208        lhs: i64,
209        rhs: i64,
210    },
211
212    /// the comptime interpreter exhausted its 100000-instruction budget.
213    /// emitted at the originating `comptime { ... }` block, not at the
214    /// runaway instruction inside it -- the reader's attention belongs at
215    /// the block declaration so they can shrink the work. the budget is a
216    /// hard limit; raising it is a v2 concern.
217    ComptimeBudgetExceeded { span: Span },
218
219    /// defense-in-depth: the comptime interpreter dispatched a CALL whose
220    /// callee is not pure. phase 3's effect checker should have caught this;
221    /// emitting at codegen prevents the comptime interpreter from running
222    /// an IO/alloc/panic body if the typechecker missed something. span =
223    /// the comptime block's span (NOT the call site, since by codegen time
224    /// the call is buried inside the throwaway chunk); `fn_name` is the
225    /// callee name; `effect` is `EffectSet::display()` of the callee's
226    /// effect (one of `pure`, `io`, `alloc`, `panic`, or a comma-joined
227    /// combo like `io, alloc`).
228    ComptimeEffectViolation {
229        span: Span,
230        fn_name: String,
231        effect: String,
232    },
233
234    /// the comptime block evaluated successfully but its result is not
235    /// representable in the constant pool: an array, a struct, an
236    /// enum-variant payload, or any heap-allocated compound. span = the
237    /// comptime block; `type_name` is `QalaType::display()` of the result's
238    /// type. v1 keeps the constant pool primitives-and-strings-only;
239    /// relaxing this is a future enhancement.
240    ComptimeResultNotConstable { span: Span, type_name: String },
241
242    // ---- runtime ----
243    /// a fault the bytecode VM hit while executing a program: division or
244    /// modulo by zero, an array index out of bounds, a call-frame stack
245    /// overflow from deep recursion, a value-stack overflow, or a malformed
246    /// bytecode stream (an undecodable opcode byte, a truncated operand, a
247    /// jump offset out of range). the VM never panics on any of these -- it
248    /// constructs this variant and unwinds. `span` covers the offending
249    /// source line: the VM derives it from `chunk.source_lines[ip]` via
250    /// [`crate::span::LineIndex`] and stores a span covering that line, so the
251    /// diagnostics renderer formats a runtime fault exactly like a type error.
252    /// `message` is the human description. mirrors the `Type` and `Parse`
253    /// fallback variants -- one structured `{ span, message }` shape.
254    Runtime { span: Span, message: String },
255}
256
257impl QalaError {
258    /// the source span this error points at.
259    ///
260    /// this match is exhaustive over every variant; that is the real guarantee
261    /// that "every error carries a span", and the compiler enforces it. a unit
262    /// test below documents the intent for a reader, but the type is the proof.
263    pub fn span(&self) -> Span {
264        match self {
265            QalaError::UnterminatedString { span }
266            | QalaError::UnterminatedInterpolation { span }
267            | QalaError::InvalidEscape { span, .. }
268            | QalaError::UnexpectedChar { span, .. }
269            | QalaError::IntOverflow { span }
270            | QalaError::MalformedNumber { span, .. }
271            | QalaError::BadByteLiteral { span, .. }
272            | QalaError::UnexpectedToken { span, .. }
273            | QalaError::UnclosedDelimiter { span, .. }
274            | QalaError::UnexpectedEof { span, .. }
275            | QalaError::Parse { span, .. }
276            | QalaError::TypeMismatch { span, .. }
277            | QalaError::MissingReturn { span, .. }
278            | QalaError::UndefinedName { span, .. }
279            | QalaError::UnknownType { span, .. }
280            | QalaError::RecursiveStructByValue { span, .. }
281            | QalaError::NonExhaustiveMatch { span, .. }
282            | QalaError::InterfaceNotSatisfied { span, .. }
283            | QalaError::EffectViolation { span, .. }
284            | QalaError::RedundantQuestionOperator { span, .. }
285            | QalaError::Type { span, .. }
286            | QalaError::IntegerOverflow { span, .. }
287            | QalaError::ComptimeBudgetExceeded { span }
288            | QalaError::ComptimeEffectViolation { span, .. }
289            | QalaError::ComptimeResultNotConstable { span, .. }
290            | QalaError::Runtime { span, .. } => *span,
291        }
292    }
293
294    /// a plain one-line description of the fault.
295    ///
296    /// no source snippet, no underline, no color: that formatting is a later
297    /// phase's job and reads the span this carries. these strings contain no
298    /// host paths and no secrets; there are none in scope at this layer.
299    pub fn message(&self) -> String {
300        match self {
301            QalaError::UnterminatedString { .. } => "unterminated string literal".to_string(),
302            QalaError::UnterminatedInterpolation { .. } => {
303                "unterminated interpolation: missing `}`".to_string()
304            }
305            QalaError::InvalidEscape { message, .. } => {
306                format!("invalid escape sequence: {message}")
307            }
308            QalaError::UnexpectedChar { ch, .. } => {
309                format!("unexpected character {ch:?}")
310            }
311            QalaError::IntOverflow { .. } => "integer literal is too large for i64".to_string(),
312            QalaError::MalformedNumber { message, .. } => {
313                format!("malformed number literal: {message}")
314            }
315            QalaError::BadByteLiteral { message, .. } => {
316                format!("malformed byte literal: {message}")
317            }
318            QalaError::UnexpectedToken {
319                expected, found, ..
320            } => {
321                format!(
322                    "expected {}, found {}",
323                    expected_list(expected),
324                    display_kind(found)
325                )
326            }
327            QalaError::UnclosedDelimiter { opener, found, .. } => {
328                format!(
329                    "unclosed {} -- found {}",
330                    display_kind(opener),
331                    display_kind(found)
332                )
333            }
334            QalaError::UnexpectedEof { expected, .. } => {
335                format!(
336                    "unexpected end of input, expected {}",
337                    expected_list(expected)
338                )
339            }
340            QalaError::Parse { message, .. } => message.clone(),
341            QalaError::TypeMismatch {
342                expected, found, ..
343            } => {
344                format!("expected {expected}, found {found}")
345            }
346            QalaError::MissingReturn {
347                fn_name, expected, ..
348            } => {
349                format!(
350                    "function `{fn_name}` is declared to return {expected} but its body has no value"
351                )
352            }
353            QalaError::UndefinedName { name, .. } => {
354                format!("undefined name `{name}`")
355            }
356            QalaError::UnknownType { name, .. } => {
357                format!("unknown type `{name}`")
358            }
359            QalaError::RecursiveStructByValue { path, .. } => {
360                format!("recursive struct: {}", path.join(" -> "))
361            }
362            QalaError::NonExhaustiveMatch {
363                enum_name, missing, ..
364            } => {
365                format!(
366                    "non-exhaustive match on enum `{enum_name}`: missing variants: {}",
367                    missing.join(", ")
368                )
369            }
370            QalaError::InterfaceNotSatisfied { ty, interface, .. } => {
371                format!("type `{ty}` does not satisfy interface `{interface}`")
372            }
373            QalaError::EffectViolation {
374                caller,
375                caller_effect,
376                callee,
377                callee_effect,
378                ..
379            } => {
380                format!(
381                    "{caller_effect} function `{caller}` calls {callee_effect} function `{callee}`"
382                )
383            }
384            QalaError::RedundantQuestionOperator { message, .. } => message.clone(),
385            QalaError::Type { message, .. } => message.clone(),
386            QalaError::IntegerOverflow { op, lhs, rhs, .. } => {
387                format!(
388                    "integer overflow: {lhs} {} {rhs} does not fit in i64",
389                    op_symbol(op)
390                )
391            }
392            QalaError::ComptimeBudgetExceeded { .. } => {
393                "comptime evaluation exceeded 100000-instruction budget".to_string()
394            }
395            QalaError::ComptimeEffectViolation {
396                fn_name, effect, ..
397            } => {
398                format!("comptime block calls {effect} function `{fn_name}`")
399            }
400            QalaError::ComptimeResultNotConstable { type_name, .. } => {
401                format!(
402                    "comptime result of type `{type_name}` is not representable as a constant (only primitives and strings)"
403                )
404            }
405            QalaError::Runtime { message, .. } => message.clone(),
406        }
407    }
408}
409
410/// the human spelling of a token kind, for error messages: `)` not `RParen`,
411/// `end of input` for [`TokenKind::Eof`], a category name for the
412/// payload-carrying kinds (`an identifier`, `an integer literal`).
413///
414/// this is the one place the mapping lives, so a new token kind is one line
415/// here. the strings are quoted where the token is a literal symbol so a
416/// message reads "expected `,` or `)`", not "expected , or )".
417pub fn display_kind(kind: &TokenKind) -> &'static str {
418    use TokenKind::*;
419    match kind {
420        // literals and identifiers: a category, not the value.
421        Int(_) => "an integer literal",
422        Float(_) => "a float literal",
423        Byte(_) => "a byte literal",
424        Str(_) => "a string literal",
425        StrStart(_) => "the start of a string",
426        StrMid(_) => "more string text",
427        StrEnd(_) => "the end of a string",
428        InterpStart => "`{` (interpolation start)",
429        InterpEnd => "`}` (interpolation end)",
430        Ident(_) => "an identifier",
431        // keywords.
432        Fn => "`fn`",
433        Let => "`let`",
434        Mut => "`mut`",
435        If => "`if`",
436        Else => "`else`",
437        While => "`while`",
438        For => "`for`",
439        In => "`in`",
440        Return => "`return`",
441        Break => "`break`",
442        Continue => "`continue`",
443        Defer => "`defer`",
444        Match => "`match`",
445        Struct => "`struct`",
446        Enum => "`enum`",
447        Interface => "`interface`",
448        Comptime => "`comptime`",
449        Is => "`is`",
450        Pure => "`pure`",
451        Io => "`io`",
452        Alloc => "`alloc`",
453        Panic => "`panic`",
454        Or => "`or`",
455        SelfKw => "`self`",
456        True => "`true`",
457        False => "`false`",
458        // primitive type names.
459        I64Ty => "`i64`",
460        F64Ty => "`f64`",
461        BoolTy => "`bool`",
462        StrTy => "`str`",
463        ByteTy => "`byte`",
464        VoidTy => "`void`",
465        // operators and punctuation.
466        Plus => "`+`",
467        Minus => "`-`",
468        Star => "`*`",
469        Slash => "`/`",
470        Percent => "`%`",
471        EqEq => "`==`",
472        BangEq => "`!=`",
473        Lt => "`<`",
474        LtEq => "`<=`",
475        Gt => "`>`",
476        GtEq => "`>=`",
477        AmpAmp => "`&&`",
478        PipePipe => "`||`",
479        Bang => "`!`",
480        Eq => "`=`",
481        Dot => "`.`",
482        Comma => "`,`",
483        Colon => "`:`",
484        Semi => "`;`",
485        LParen => "`(`",
486        RParen => "`)`",
487        LBracket => "`[`",
488        RBracket => "`]`",
489        LBrace => "`{`",
490        RBrace => "`}`",
491        Arrow => "`->`",
492        FatArrow => "`=>`",
493        PipeGt => "`|>`",
494        Question => "`?`",
495        DotDot => "`..`",
496        DotDotEq => "`..=`",
497        Eof => "end of input",
498    }
499}
500
501/// render an expected-token set for a message: a single item bare, two items
502/// joined with `or`, three or more as a comma list ending in `or`. an empty set
503/// (which should not happen in practice) reads "something else".
504fn expected_list(expected: &[TokenKind]) -> String {
505    let names: Vec<&'static str> = expected.iter().map(display_kind).collect();
506    match names.as_slice() {
507        [] => "something else".to_string(),
508        [only] => only.to_string(),
509        [a, b] => format!("{a} or {b}"),
510        [head @ .., last] => format!("{}, or {}", head.join(", "), last),
511    }
512}
513
514/// the operator symbol for an [`QalaError::IntegerOverflow`] message.
515///
516/// arithmetic ops return their symbol; non-arithmetic [`BinOp`] variants
517/// (`Eq`/`Ne`/`Lt`/`Le`/`Gt`/`Ge`/`And`/`Or`) cannot overflow under constant
518/// folding so the fallback `?` is never reached in practice -- but returning a
519/// fallback rather than panicking keeps the WASM build crash-free per the
520/// project convention. takes `&BinOp` because [`BinOp`] is not `Copy`.
521fn op_symbol(op: &BinOp) -> &'static str {
522    match op {
523        BinOp::Add => "+",
524        BinOp::Sub => "-",
525        BinOp::Mul => "*",
526        BinOp::Div => "/",
527        BinOp::Rem => "%",
528        BinOp::Eq
529        | BinOp::Ne
530        | BinOp::Lt
531        | BinOp::Le
532        | BinOp::Gt
533        | BinOp::Ge
534        | BinOp::And
535        | BinOp::Or => "?",
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    // a distinct sample span per variant, so a wrong span() arm would be caught.
544    fn sample(n: usize) -> Span {
545        Span::new(n, n + 1)
546    }
547
548    #[test]
549    fn span_returns_the_carried_span_for_every_lex_variant() {
550        let cases: Vec<(QalaError, Span)> = vec![
551            (QalaError::UnterminatedString { span: sample(1) }, sample(1)),
552            (
553                QalaError::UnterminatedInterpolation { span: sample(2) },
554                sample(2),
555            ),
556            (
557                QalaError::InvalidEscape {
558                    span: sample(3),
559                    message: "\\q".to_string(),
560                },
561                sample(3),
562            ),
563            (
564                QalaError::UnexpectedChar {
565                    span: sample(4),
566                    ch: '\u{00e9}',
567                },
568                sample(4),
569            ),
570            (QalaError::IntOverflow { span: sample(5) }, sample(5)),
571            (
572                QalaError::MalformedNumber {
573                    span: sample(6),
574                    message: "1_".to_string(),
575                },
576                sample(6),
577            ),
578            (
579                QalaError::BadByteLiteral {
580                    span: sample(7),
581                    message: "b''".to_string(),
582                },
583                sample(7),
584            ),
585        ];
586        // the exhaustive match in span() is the real guarantee; this loop just
587        // documents that each variant round-trips its span.
588        for (err, expected) in cases {
589            assert_eq!(err.span(), expected, "span() mismatch for {err:?}");
590        }
591    }
592
593    #[test]
594    fn span_returns_the_carried_span_for_every_parse_variant() {
595        let cases: Vec<(QalaError, Span)> = vec![
596            (
597                QalaError::UnexpectedToken {
598                    span: sample(1),
599                    expected: vec![TokenKind::RParen],
600                    found: TokenKind::RBracket,
601                },
602                sample(1),
603            ),
604            (
605                QalaError::UnclosedDelimiter {
606                    span: sample(2),
607                    opener: TokenKind::LParen,
608                    found: TokenKind::RBrace,
609                },
610                sample(2),
611            ),
612            (
613                QalaError::UnexpectedEof {
614                    span: sample(3),
615                    expected: vec![TokenKind::Semi],
616                },
617                sample(3),
618            ),
619            (
620                QalaError::Parse {
621                    span: sample(4),
622                    message: "expression nests too deeply".to_string(),
623                },
624                sample(4),
625            ),
626        ];
627        for (err, expected) in cases {
628            assert_eq!(err.span(), expected, "span() mismatch for {err:?}");
629        }
630    }
631
632    #[test]
633    fn span_returns_the_carried_span_for_every_type_variant() {
634        let cases: Vec<(QalaError, Span)> = vec![
635            (
636                QalaError::TypeMismatch {
637                    span: sample(1),
638                    expected: "i64".to_string(),
639                    found: "str".to_string(),
640                },
641                sample(1),
642            ),
643            (
644                QalaError::MissingReturn {
645                    span: sample(2),
646                    fn_name: "f".to_string(),
647                    expected: "i64".to_string(),
648                },
649                sample(2),
650            ),
651            (
652                QalaError::UndefinedName {
653                    span: sample(3),
654                    name: "x".to_string(),
655                },
656                sample(3),
657            ),
658            (
659                QalaError::UnknownType {
660                    span: sample(4),
661                    name: "Shape".to_string(),
662                },
663                sample(4),
664            ),
665            (
666                QalaError::RecursiveStructByValue {
667                    span: sample(5),
668                    path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
669                },
670                sample(5),
671            ),
672            (
673                QalaError::NonExhaustiveMatch {
674                    span: sample(6),
675                    enum_name: "Dir".to_string(),
676                    missing: vec!["Bar".to_string(), "Foo".to_string()],
677                },
678                sample(6),
679            ),
680            (
681                QalaError::InterfaceNotSatisfied {
682                    span: sample(7),
683                    ty: "Point".to_string(),
684                    interface: "Printable".to_string(),
685                    missing: vec!["to_string".to_string()],
686                    mismatched: vec![],
687                },
688                sample(7),
689            ),
690            (
691                QalaError::EffectViolation {
692                    span: sample(8),
693                    caller: "compute".to_string(),
694                    caller_effect: "pure".to_string(),
695                    callee: "println".to_string(),
696                    callee_effect: "io".to_string(),
697                },
698                sample(8),
699            ),
700            (
701                QalaError::RedundantQuestionOperator {
702                    span: sample(9),
703                    message: "`?` outside a Result-returning function".to_string(),
704                },
705                sample(9),
706            ),
707            (
708                QalaError::Type {
709                    span: sample(10),
710                    message: "literal pattern cannot match an enum value".to_string(),
711                },
712                sample(10),
713            ),
714        ];
715        for (err, expected) in cases {
716            assert_eq!(err.span(), expected, "span() mismatch for {err:?}");
717        }
718    }
719
720    #[test]
721    fn span_returns_the_carried_span_for_the_runtime_variant() {
722        // the VM constructs Runtime with a span covering the offending source
723        // line; span() must hand that span straight back.
724        let e = QalaError::Runtime {
725            span: sample(7),
726            message: "division by zero".to_string(),
727        };
728        assert_eq!(e.span(), sample(7), "span() mismatch for {e:?}");
729    }
730
731    #[test]
732    fn runtime_message_returns_the_carried_message_verbatim() {
733        // like the Type and Parse fallback arms, message() is the stored
734        // string with no template wrapping.
735        let e = QalaError::Runtime {
736            span: sample(0),
737            message: "index 5 out of bounds for length 3".to_string(),
738        };
739        assert_eq!(e.message(), "index 5 out of bounds for length 3");
740    }
741
742    #[test]
743    fn errors_are_comparable_and_clonable() {
744        let a = QalaError::IntOverflow { span: sample(0) };
745        let b = a.clone();
746        assert_eq!(a, b);
747        let c = QalaError::IntOverflow { span: sample(1) };
748        assert_ne!(a, c);
749    }
750
751    #[test]
752    fn message_is_a_plain_nonempty_line_per_variant() {
753        let errs = [
754            QalaError::UnterminatedString { span: sample(0) },
755            QalaError::UnterminatedInterpolation { span: sample(0) },
756            QalaError::InvalidEscape {
757                span: sample(0),
758                message: "\\q".to_string(),
759            },
760            QalaError::UnexpectedChar {
761                span: sample(0),
762                ch: '@',
763            },
764            QalaError::IntOverflow { span: sample(0) },
765            QalaError::MalformedNumber {
766                span: sample(0),
767                message: "1e".to_string(),
768            },
769            QalaError::BadByteLiteral {
770                span: sample(0),
771                message: "b''".to_string(),
772            },
773            QalaError::UnexpectedToken {
774                span: sample(0),
775                expected: vec![TokenKind::Comma, TokenKind::RParen],
776                found: TokenKind::RBracket,
777            },
778            QalaError::UnclosedDelimiter {
779                span: sample(0),
780                opener: TokenKind::LParen,
781                found: TokenKind::Eof,
782            },
783            QalaError::UnexpectedEof {
784                span: sample(0),
785                expected: vec![TokenKind::Ident(String::new())],
786            },
787            QalaError::Parse {
788                span: sample(0),
789                message: "expression nests too deeply".to_string(),
790            },
791            QalaError::TypeMismatch {
792                span: sample(0),
793                expected: "i64".to_string(),
794                found: "str".to_string(),
795            },
796            QalaError::MissingReturn {
797                span: sample(0),
798                fn_name: "f".to_string(),
799                expected: "i64".to_string(),
800            },
801            QalaError::UndefinedName {
802                span: sample(0),
803                name: "x".to_string(),
804            },
805            QalaError::UnknownType {
806                span: sample(0),
807                name: "Shape".to_string(),
808            },
809            QalaError::RecursiveStructByValue {
810                span: sample(0),
811                path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
812            },
813            QalaError::NonExhaustiveMatch {
814                span: sample(0),
815                enum_name: "Dir".to_string(),
816                missing: vec!["Bar".to_string(), "Foo".to_string()],
817            },
818            QalaError::InterfaceNotSatisfied {
819                span: sample(0),
820                ty: "Point".to_string(),
821                interface: "Printable".to_string(),
822                missing: vec!["to_string".to_string()],
823                mismatched: vec![],
824            },
825            QalaError::EffectViolation {
826                span: sample(0),
827                caller: "compute".to_string(),
828                caller_effect: "pure".to_string(),
829                callee: "println".to_string(),
830                callee_effect: "io".to_string(),
831            },
832            QalaError::RedundantQuestionOperator {
833                span: sample(0),
834                message: "`?` outside a Result-returning function".to_string(),
835            },
836            QalaError::Type {
837                span: sample(0),
838                message: "variant `Square` is not part of enum `Shape`".to_string(),
839            },
840            QalaError::IntegerOverflow {
841                span: sample(0),
842                op: BinOp::Mul,
843                lhs: i64::MAX,
844                rhs: 2,
845            },
846            QalaError::ComptimeBudgetExceeded { span: sample(0) },
847            QalaError::ComptimeEffectViolation {
848                span: sample(0),
849                fn_name: "println".to_string(),
850                effect: "io".to_string(),
851            },
852            QalaError::ComptimeResultNotConstable {
853                span: sample(0),
854                type_name: "[i64; 3]".to_string(),
855            },
856            QalaError::Runtime {
857                span: sample(0),
858                message: "division by zero".to_string(),
859            },
860        ];
861        for e in &errs {
862            let m = e.message();
863            assert!(!m.is_empty());
864            assert!(!m.contains('\n'), "message should be one line: {m:?}");
865        }
866    }
867
868    #[test]
869    fn type_mismatch_message_uses_expected_vs_found_template() {
870        let e = QalaError::TypeMismatch {
871            span: sample(0),
872            expected: "i64".to_string(),
873            found: "str".to_string(),
874        };
875        assert_eq!(e.message(), "expected i64, found str");
876    }
877
878    #[test]
879    fn effect_violation_message_uses_locked_template() {
880        let e = QalaError::EffectViolation {
881            span: sample(0),
882            caller: "compute".to_string(),
883            caller_effect: "pure".to_string(),
884            callee: "println".to_string(),
885            callee_effect: "io".to_string(),
886        };
887        let m = e.message();
888        // exact substring required by the diagnostics renderer's wording-drift test.
889        assert!(
890            m.contains("pure function `compute` calls io function `println`"),
891            "effect violation message drift: {m:?}"
892        );
893    }
894
895    #[test]
896    fn recursive_struct_message_joins_path_with_arrows() {
897        let e = QalaError::RecursiveStructByValue {
898            span: sample(0),
899            path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
900        };
901        let m = e.message();
902        assert!(
903            m.contains("A -> B -> A"),
904            "missing arrow-joined path: {m:?}"
905        );
906        // the literal "recursive struct" prefix is the locked wording.
907        assert!(m.starts_with("recursive struct: "), "missing prefix: {m:?}");
908    }
909
910    #[test]
911    fn non_exhaustive_match_message_lists_missing_variants() {
912        // the type checker pre-sorts `missing`; the variant stores it verbatim.
913        let e = QalaError::NonExhaustiveMatch {
914            span: sample(0),
915            enum_name: "Dir".to_string(),
916            missing: vec!["Bar".to_string(), "Foo".to_string()],
917        };
918        let m = e.message();
919        assert!(m.contains("missing variants: Bar, Foo"), "{m:?}");
920        assert!(m.contains("`Dir`"), "missing enum name: {m:?}");
921    }
922
923    #[test]
924    fn interface_not_satisfied_message_names_type_and_interface() {
925        let e = QalaError::InterfaceNotSatisfied {
926            span: sample(0),
927            ty: "Point".to_string(),
928            interface: "Printable".to_string(),
929            missing: vec!["to_string".to_string()],
930            mismatched: vec![(
931                "render".to_string(),
932                "fn(self) -> str".to_string(),
933                "fn(self) -> i64".to_string(),
934            )],
935        };
936        let m = e.message();
937        assert!(m.contains("`Point`"), "missing type name: {m:?}");
938        assert!(m.contains("`Printable`"), "missing interface name: {m:?}");
939        // the per-method details flow into the diagnostic `notes` list, not the
940        // message, so the message stays one line.
941        assert!(!m.contains('\n'), "message should be one line: {m:?}");
942        // the carried `missing` and `mismatched` lists round-trip verbatim
943        // (the type checker pre-sorts; nothing here re-orders them).
944        match &e {
945            QalaError::InterfaceNotSatisfied {
946                missing,
947                mismatched,
948                ..
949            } => {
950                assert_eq!(missing, &vec!["to_string".to_string()]);
951                assert_eq!(mismatched.len(), 1);
952                assert_eq!(mismatched[0].0, "render");
953                assert_eq!(mismatched[0].1, "fn(self) -> str");
954                assert_eq!(mismatched[0].2, "fn(self) -> i64");
955            }
956            _ => unreachable!(),
957        }
958    }
959
960    #[test]
961    fn unexpected_token_message_lists_every_expected_kind_and_the_found_one() {
962        let e = QalaError::UnexpectedToken {
963            span: sample(0),
964            expected: vec![TokenKind::Comma, TokenKind::RParen],
965            found: TokenKind::RBracket,
966        };
967        let m = e.message();
968        // both human spellings appear, and the message says what was actually found.
969        assert!(m.contains("`,`"), "missing first expected kind: {m:?}");
970        assert!(m.contains("`)`"), "missing second expected kind: {m:?}");
971        assert!(m.contains("found"), "missing the word `found`: {m:?}");
972        assert!(m.contains("`]`"), "missing the found kind: {m:?}");
973    }
974
975    #[test]
976    fn unclosed_delimiter_message_names_the_opener_and_the_surprise() {
977        // a wrong closer.
978        let e = QalaError::UnclosedDelimiter {
979            span: sample(0),
980            opener: TokenKind::LParen,
981            found: TokenKind::RBrace,
982        };
983        let m = e.message();
984        assert!(m.contains("`(`"), "missing the opener: {m:?}");
985        assert!(m.contains("`}`"), "missing the surprising token: {m:?}");
986        // running into end of input names it as such, not as `Eof`.
987        let e = QalaError::UnclosedDelimiter {
988            span: sample(0),
989            opener: TokenKind::LBracket,
990            found: TokenKind::Eof,
991        };
992        let m = e.message();
993        assert!(m.contains("`[`"));
994        assert!(m.contains("end of input"));
995        assert!(!m.contains("Eof"));
996    }
997
998    #[test]
999    fn eof_is_named_end_of_input_in_messages() {
1000        let e = QalaError::UnexpectedEof {
1001            span: sample(0),
1002            expected: vec![TokenKind::Semi],
1003        };
1004        let m = e.message();
1005        assert!(m.contains("end of input"), "{m:?}");
1006        assert!(m.contains("`;`"), "{m:?}");
1007    }
1008
1009    #[test]
1010    fn display_kind_spells_symbols_and_categories() {
1011        assert_eq!(display_kind(&TokenKind::RParen), "`)`");
1012        assert_eq!(display_kind(&TokenKind::LBrace), "`{`");
1013        assert_eq!(display_kind(&TokenKind::Plus), "`+`");
1014        assert_eq!(display_kind(&TokenKind::Eof), "end of input");
1015        assert_eq!(
1016            display_kind(&TokenKind::Ident("x".to_string())),
1017            "an identifier"
1018        );
1019        assert_eq!(display_kind(&TokenKind::Int(7)), "an integer literal");
1020        assert_eq!(display_kind(&TokenKind::Fn), "`fn`");
1021    }
1022
1023    #[test]
1024    fn expected_list_joins_one_two_or_many() {
1025        assert_eq!(expected_list(&[TokenKind::RParen]), "`)`");
1026        assert_eq!(
1027            expected_list(&[TokenKind::Comma, TokenKind::RParen]),
1028            "`,` or `)`"
1029        );
1030        assert_eq!(
1031            expected_list(&[TokenKind::Comma, TokenKind::RParen, TokenKind::RBracket]),
1032            "`,`, `)`, or `]`"
1033        );
1034        // an empty set should not occur, but it must not panic.
1035        assert_eq!(expected_list(&[]), "something else");
1036    }
1037
1038    #[test]
1039    fn span_returns_the_carried_span_for_every_codegen_variant() {
1040        let cases: Vec<(QalaError, Span)> = vec![
1041            (
1042                QalaError::IntegerOverflow {
1043                    span: sample(1),
1044                    op: BinOp::Mul,
1045                    lhs: i64::MAX,
1046                    rhs: 2,
1047                },
1048                sample(1),
1049            ),
1050            (
1051                QalaError::ComptimeBudgetExceeded { span: sample(2) },
1052                sample(2),
1053            ),
1054            (
1055                QalaError::ComptimeEffectViolation {
1056                    span: sample(3),
1057                    fn_name: "println".to_string(),
1058                    effect: "io".to_string(),
1059                },
1060                sample(3),
1061            ),
1062            (
1063                QalaError::ComptimeResultNotConstable {
1064                    span: sample(4),
1065                    type_name: "[i64; 3]".to_string(),
1066                },
1067                sample(4),
1068            ),
1069        ];
1070        for (err, expected) in cases {
1071            assert_eq!(err.span(), expected, "span() mismatch for {err:?}");
1072        }
1073    }
1074
1075    #[test]
1076    fn integer_overflow_message_renders_mul_with_locked_wording() {
1077        let e = QalaError::IntegerOverflow {
1078            span: sample(0),
1079            op: BinOp::Mul,
1080            lhs: i64::MAX,
1081            rhs: 2,
1082        };
1083        assert_eq!(
1084            e.message(),
1085            "integer overflow: 9223372036854775807 * 2 does not fit in i64"
1086        );
1087    }
1088
1089    #[test]
1090    fn integer_overflow_message_renders_add_with_locked_wording() {
1091        let e = QalaError::IntegerOverflow {
1092            span: sample(0),
1093            op: BinOp::Add,
1094            lhs: i64::MAX,
1095            rhs: 1,
1096        };
1097        assert_eq!(
1098            e.message(),
1099            "integer overflow: 9223372036854775807 + 1 does not fit in i64"
1100        );
1101    }
1102
1103    #[test]
1104    fn integer_overflow_message_renders_sub_with_locked_wording() {
1105        let e = QalaError::IntegerOverflow {
1106            span: sample(0),
1107            op: BinOp::Sub,
1108            lhs: i64::MIN,
1109            rhs: 1,
1110        };
1111        assert_eq!(
1112            e.message(),
1113            "integer overflow: -9223372036854775808 - 1 does not fit in i64"
1114        );
1115    }
1116
1117    #[test]
1118    fn comptime_budget_exceeded_message_uses_locked_wording() {
1119        let e = QalaError::ComptimeBudgetExceeded { span: sample(0) };
1120        assert_eq!(
1121            e.message(),
1122            "comptime evaluation exceeded 100000-instruction budget"
1123        );
1124    }
1125
1126    #[test]
1127    fn comptime_effect_violation_message_quotes_only_the_fn_name() {
1128        let e = QalaError::ComptimeEffectViolation {
1129            span: sample(0),
1130            fn_name: "println".to_string(),
1131            effect: "io".to_string(),
1132        };
1133        // backticks around the function name, none around the effect word.
1134        assert_eq!(e.message(), "comptime block calls io function `println`");
1135    }
1136
1137    #[test]
1138    fn comptime_result_not_constable_message_quotes_the_type_name() {
1139        let e = QalaError::ComptimeResultNotConstable {
1140            span: sample(0),
1141            type_name: "[i64; 3]".to_string(),
1142        };
1143        assert_eq!(
1144            e.message(),
1145            "comptime result of type `[i64; 3]` is not representable as a constant (only primitives and strings)"
1146        );
1147    }
1148
1149    #[test]
1150    fn op_symbol_maps_arithmetic_binops_and_falls_back_for_others() {
1151        assert_eq!(op_symbol(&BinOp::Add), "+");
1152        assert_eq!(op_symbol(&BinOp::Sub), "-");
1153        assert_eq!(op_symbol(&BinOp::Mul), "*");
1154        assert_eq!(op_symbol(&BinOp::Div), "/");
1155        assert_eq!(op_symbol(&BinOp::Rem), "%");
1156        // every non-arithmetic op shares the `?` fallback; one is enough to lock
1157        // the contract.
1158        assert_eq!(op_symbol(&BinOp::Eq), "?");
1159    }
1160}