Skip to main content

monty_types/
exceptions.rs

1//! Public exception types: [`ExcType`], [`MontyException`] and its
2//! traceback/payload components ([`StackFrame`], [`CodeLoc`], [`ExcData`]).
3
4use std::{
5    error,
6    fmt::{self, Write},
7    mem, str,
8    sync::Arc,
9};
10
11use serde::{Deserialize, Serialize};
12use strum::{Display, EnumString, IntoStaticStr, VariantNames};
13
14use crate::format::StringRepr;
15
16/// Public representation of a Monty exception.
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct MontyException {
19    /// The exception type raised
20    exc_type: ExcType,
21    /// Optional exception message explaining what went wrong
22    message: Option<String>,
23    /// Stack trace of the exception, first is the outermost frame shown first in the traceback
24    traceback: Vec<StackFrame>,
25    /// Structured payload for exception types that carry more than a message.
26    data: ExcData,
27}
28
29/// Number of identical consecutive frames to show before collapsing.
30///
31/// CPython shows 3 identical frames, then "[Previous line repeated N more times]".
32const REPEAT_FRAMES_SHOWN: usize = 3;
33
34/// Display implementation for MontyException should exactly match python traceback format.
35impl fmt::Display for MontyException {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        // Print the traceback header if we have frames
38        if !self.traceback.is_empty() {
39            writeln!(f, "Traceback (most recent call last):")?;
40        }
41
42        // Print frames, collapsing consecutive identical frames like CPython does
43        let mut i = 0;
44        while i < self.traceback.len() {
45            let frame = &self.traceback[i];
46
47            // Count consecutive identical frames
48            let mut repeat_count = 1;
49            while i + repeat_count < self.traceback.len()
50                && frames_are_identical(frame, &self.traceback[i + repeat_count])
51            {
52                repeat_count += 1;
53            }
54
55            if repeat_count > REPEAT_FRAMES_SHOWN {
56                // Show first REPEAT_FRAMES_SHOWN frames, then collapse the rest
57                for j in 0..REPEAT_FRAMES_SHOWN {
58                    write!(f, "{}", self.traceback[i + j])?;
59                }
60                let collapsed = repeat_count - REPEAT_FRAMES_SHOWN;
61                writeln!(f, "  [Previous line repeated {collapsed} more times]")?;
62                i += repeat_count;
63            } else {
64                // Show all frames in this group
65                for j in 0..repeat_count {
66                    write!(f, "{}", self.traceback[i + j])?;
67                }
68                i += repeat_count;
69            }
70        }
71
72        if let Some(msg) = &self.message {
73            write!(f, "{}: {}", self.exc_type, msg)
74        } else {
75            write!(f, "{}", self.exc_type)
76        }
77    }
78}
79
80impl error::Error for MontyException {}
81
82impl MontyException {
83    /// Create a new MontyException with the given exception type and message.
84    ///
85    /// You can't provide a traceback here, it's send when raising the exception.
86    #[must_use]
87    pub fn new(exc_type: ExcType, message: Option<String>) -> Self {
88        Self {
89            exc_type,
90            message,
91            traceback: vec![],
92            data: ExcData::None,
93        }
94    }
95
96    /// Creates an exception with an explicit traceback.
97    ///
98    /// Most callers should use [`MontyException::new`] — the traceback is
99    /// normally attached when the exception is raised. This constructor
100    /// exists for boundaries that *reconstruct* an exception that was raised
101    /// elsewhere (e.g. deserializing one received from a `monty subprocess`
102    /// worker) and must preserve its original frames.
103    #[must_use]
104    pub fn with_traceback(exc_type: ExcType, message: Option<String>, traceback: Vec<StackFrame>) -> Self {
105        Self {
106            exc_type,
107            message,
108            traceback,
109            data: ExcData::None,
110        }
111    }
112
113    /// Attaches a structured payload — see [`ExcData`]. Public for
114    /// boundaries that reconstruct an exception raised elsewhere (like
115    /// [`MontyException::with_traceback`]); in-process raises attach the
116    /// payload at the raise site instead.
117    #[must_use]
118    pub fn with_data(mut self, data: ExcData) -> Self {
119        self.data = data;
120        self
121    }
122
123    /// The structured payload, [`ExcData::None`] for most exceptions.
124    #[must_use]
125    pub fn data(&self) -> &ExcData {
126        &self.data
127    }
128
129    /// Structured `UnicodeDecodeError`/`UnicodeEncodeError` fields, present
130    /// only for unicode errors raised by codec operations on objects no
131    /// larger than [`UnicodeErrorData::MAX_OBJECT_LEN`].
132    #[must_use]
133    pub fn unicode_data(&self) -> Option<&UnicodeErrorData> {
134        self.data.unicode()
135    }
136
137    /// Structured `json.JSONDecodeError` fields, present only for decode
138    /// errors raised by `json.loads` (not for manually raised exceptions).
139    #[must_use]
140    pub fn json_data(&self) -> Option<&JsonErrorData> {
141        self.data.json()
142    }
143
144    /// Removes and returns the structured payload, for consumers (like the
145    /// Python bindings) that rebuild the native exception and want the
146    /// payload by value without cloning it.
147    #[must_use]
148    pub fn take_data(&mut self) -> ExcData {
149        mem::take(&mut self.data)
150    }
151
152    /// Appends frames to this exception's traceback.
153    pub fn add_traceback(&mut self, traceback: impl IntoIterator<Item = StackFrame>) {
154        self.traceback.extend(traceback);
155    }
156
157    /// Shorthand for a traceback-free `RuntimeError` wrapping `err`'s display
158    /// output — used at host boundaries (input conversion, REPL feeds) where
159    /// no sandbox stack frames exist.
160    #[must_use]
161    pub fn runtime_error(err: impl fmt::Display) -> Self {
162        Self {
163            exc_type: ExcType::RuntimeError,
164            message: Some(err.to_string()),
165            traceback: vec![],
166            data: ExcData::None,
167        }
168    }
169
170    /// The exception type raised.
171    #[must_use]
172    pub fn exc_type(&self) -> ExcType {
173        self.exc_type
174    }
175
176    /// Optional exception message explaining what went wrong.
177    ///
178    /// Equivalent of python's `exc.args[0]`
179    #[must_use]
180    pub fn message(&self) -> Option<&str> {
181        self.message.as_deref()
182    }
183
184    /// Optional exception message explaining what went wrong.
185    ///
186    /// This takes ownership of the MontyException and returns an owned String.
187    ///
188    /// Equivalent of python's `exc.args[0]`
189    #[must_use]
190    pub fn into_message(self) -> Option<String> {
191        self.message
192    }
193
194    /// Stack trace of the exception, first is the outermost frame shown first in the traceback
195    #[must_use]
196    pub fn traceback(&self) -> &[StackFrame] {
197        &self.traceback
198    }
199
200    /// Returns a compact summary of the exception.
201    ///
202    /// Format: `ExceptionType: message` (e.g., `NotImplementedError: feature not supported`)
203    /// If there's no message, just returns the exception type name.
204    #[must_use]
205    pub fn summary(&self) -> String {
206        if let Some(msg) = &self.message {
207            format!("{}: {}", self.exc_type, msg)
208        } else {
209            self.exc_type.to_string()
210        }
211    }
212
213    /// Returns the exception formatted as Python's repr() would display it.
214    ///
215    /// Format: `ExceptionType('message')` (e.g., `ValueError('invalid value')`)
216    /// Uses appropriate quoting for messages containing quotes.
217    #[must_use]
218    pub fn py_repr(&self) -> String {
219        let type_str: &'static str = self.exc_type.into();
220        if let Some(msg) = &self.message {
221            format!("{}({})", type_str, StringRepr(msg))
222        } else {
223            format!("{type_str}()")
224        }
225    }
226}
227
228/// Check if two stack frames are identical for the purpose of collapsing repeated frames.
229///
230/// Two frames are identical if they have the same filename, line number, and function name.
231fn frames_are_identical(a: &StackFrame, b: &StackFrame) -> bool {
232    a.filename == b.filename && a.start.line == b.start.line && a.frame_name == b.frame_name
233}
234
235/// Python exception types supported by the interpreter.
236///
237/// Uses strum derives for automatic `Display`, `FromStr`, and `Into<&'static str>` implementations.
238/// The string representation matches the variant name exactly (e.g., `ValueError` -> "ValueError"),
239/// except where a `serialize` attribute gives a dotted name to disambiguate a stdlib subclass from
240/// its builtin parent (`binascii.Error` from `ValueError`, say).
241///
242/// `ExcType::VARIANTS` is the canonical list of those names, and the host bridges mirror it:
243/// `PYTHON_EXC_NAMES` in `crates/monty-js/ts/errors.ts` and the `ExcType` literal in
244/// `pydantic_monty/__init__.py`. Both are hand-written, so `monty-proto`'s `exc_type_bridges` test
245/// reads them and pins them here — adding a variant without wiring the bridges fails that test.
246#[derive(
247    Debug,
248    Clone,
249    Copy,
250    Default,
251    PartialEq,
252    Eq,
253    Hash,
254    Display,
255    EnumString,
256    IntoStaticStr,
257    VariantNames,
258    Serialize,
259    Deserialize,
260)]
261pub enum ExcType {
262    /// primary exception class - matches any exception in isinstance checks.
263    ///
264    /// Also the `Default` — required so `Type` (which embeds an `ExcType` in
265    /// its `Exception` variant) can derive `strum::EnumIter`.
266    #[default]
267    Exception,
268
269    /// System exit exceptions
270    BaseException,
271    SystemExit,
272    KeyboardInterrupt,
273
274    // --- ArithmeticError hierarchy ---
275    /// Intermediate class for arithmetic errors.
276    ArithmeticError,
277    /// Subclass of ArithmeticError.
278    OverflowError,
279    /// Subclass of ArithmeticError.
280    ZeroDivisionError,
281
282    // --- LookupError hierarchy ---
283    /// Intermediate class for lookup errors.
284    LookupError,
285    /// Subclass of LookupError.
286    IndexError,
287    /// Subclass of LookupError.
288    KeyError,
289
290    // --- RuntimeError hierarchy ---
291    /// Intermediate class for runtime errors.
292    RuntimeError,
293    /// Subclass of RuntimeError.
294    NotImplementedError,
295    /// Subclass of RuntimeError.
296    RecursionError,
297
298    // --- AttributeError hierarchy ---
299    AttributeError,
300    /// Subclass of AttributeError (from dataclasses module).
301    FrozenInstanceError,
302
303    // --- NameError hierarchy ---
304    NameError,
305    /// Subclass of NameError - for accessing local variable before assignment.
306    UnboundLocalError,
307
308    // --- ValueError hierarchy ---
309    ValueError,
310    /// Subclass of ValueError - for encoding/decoding errors.
311    UnicodeDecodeError,
312    /// Subclass of ValueError - for encoding errors (e.g. `str.encode('ascii')`
313    /// on a string containing non-ASCII characters).
314    UnicodeEncodeError,
315    /// Subclass of ValueError for invalid JSON syntax in `json.loads()`.
316    #[strum(serialize = "json.JSONDecodeError")]
317    JsonDecodeError,
318    /// Subclass of ValueError for invalid TOML in `tomllib.loads()` / `tomllib.load()`.
319    #[strum(serialize = "tomllib.TOMLDecodeError")]
320    TomlDecodeError,
321
322    // --- ImportError hierarchy ---
323    /// Import-related errors (module not found, name not in module).
324    ImportError,
325    /// Subclass of ImportError - for when a module cannot be found.
326    ModuleNotFoundError,
327
328    // --- OSError hierarchy ---
329    /// OS-related errors (file not found, permission denied, etc.)
330    OSError,
331    /// Subclass of OSError - for when a file or directory cannot be found.
332    FileNotFoundError,
333    /// Subclass of OSError - for when a file already exists.
334    FileExistsError,
335    /// Subclass of OSError - for when a path is a directory but a file was expected.
336    IsADirectoryError,
337    /// Subclass of OSError - for when a path is not a directory but one was expected.
338    NotADirectoryError,
339    /// Subclass of OSError - for when an operation is not permitted (e.g., writing
340    /// to a read-only mount, or attempting to access a path outside a mounted directory).
341    PermissionError,
342    /// `io.UnsupportedOperation` - raised by file objects when a requested
343    /// operation isn't allowed by the open mode (e.g. `read()` on `'w'`).
344    ///
345    /// In CPython this inherits from both `OSError` and `ValueError`. Monty's
346    /// `ExcType` enum models single parents, but [`Self::is_subclass_of`]
347    /// matches `UnsupportedOperation` against both `OSError` and `ValueError`
348    /// so `except ValueError:` and `except OSError:` both catch it as in
349    /// CPython.
350    #[strum(serialize = "io.UnsupportedOperation")]
351    UnsupportedOperation,
352    /// Subclass of OSError since Python 3.3 (PEP 3151).
353    TimeoutError,
354
355    // --- Standalone exception types ---
356    AssertionError,
357    MemoryError,
358    StopIteration,
359    SyntaxError,
360    TypeError,
361
362    // --- Module-specific exception types ---
363
364    // --- re module ---
365    /// `re.PatternError` - raised for invalid regex patterns or unsupported regex features.
366    ///
367    /// # Behavior Note
368    ///
369    /// Limited to monty's exception type, `PatternError` does not provide `pattern`, `pos`,
370    /// `lineno` and `colno` attributes.
371    ///
372    /// As per CPython's implementation, it would be hard to convert `fancy-regex`'s error
373    /// representations into the required attributes.
374    #[strum(serialize = "re.PatternError")]
375    RePatternError,
376
377    // --- binascii module ---
378    /// `binascii.Error` - raised by the `base64` codecs for malformed input.
379    ///
380    /// A `ValueError` subclass in CPython, so `except ValueError:` catches it.
381    #[strum(serialize = "binascii.Error")]
382    BinasciiError,
383
384    /// `binascii.Incomplete` - a direct `Exception` subclass, not a `ValueError`.
385    ///
386    /// Nothing raises it: the `a2b_hqx` family it belonged to left CPython in
387    /// 3.11, so the class survives only for `except binascii.Incomplete:` in
388    /// older code. Monty exposes it for the same reason.
389    #[strum(serialize = "binascii.Incomplete")]
390    BinasciiIncomplete,
391
392    // --- csv module ---
393    /// `csv.Error` - raised by the `csv` readers and writers for malformed data
394    /// and unknown dialect names.
395    ///
396    /// A direct `Exception` subclass, named `_csv.Error` as in CPython.
397    #[strum(serialize = "_csv.Error")]
398    CsvError,
399
400    // --- decimal module ---
401    // CPython's decimal signals form a multiple-inheritance hierarchy under
402    // `ArithmeticError`; [`Self::decimal_ancestors`] lists each one's bases.
403    /// `decimal.DecimalException`, the base of every decimal signal.
404    #[strum(serialize = "decimal.DecimalException")]
405    DecimalException,
406    /// `decimal.Clamped`: an exponent was changed to fit the representation.
407    #[strum(serialize = "decimal.Clamped")]
408    DecimalClamped,
409    /// `decimal.InvalidOperation`: an operation with no meaningful result.
410    #[strum(serialize = "decimal.InvalidOperation")]
411    DecimalInvalidOperation,
412    /// `decimal.ConversionSyntax`: a malformed numeric string.
413    #[strum(serialize = "decimal.ConversionSyntax")]
414    DecimalConversionSyntax,
415    /// `decimal.DivisionByZero`: a finite non-zero dividend divided by zero.
416    #[strum(serialize = "decimal.DivisionByZero")]
417    DecimalDivisionByZero,
418    /// `decimal.DivisionImpossible`: an integer quotient too long for the precision.
419    #[strum(serialize = "decimal.DivisionImpossible")]
420    DecimalDivisionImpossible,
421    /// `decimal.DivisionUndefined`: zero divided by zero.
422    #[strum(serialize = "decimal.DivisionUndefined")]
423    DecimalDivisionUndefined,
424    /// `decimal.Inexact`: a result was rounded and lost non-zero digits.
425    #[strum(serialize = "decimal.Inexact")]
426    DecimalInexact,
427    /// `decimal.InvalidContext`: an invalid context setting was detected.
428    #[strum(serialize = "decimal.InvalidContext")]
429    DecimalInvalidContext,
430    /// `decimal.Rounded`: a result was rounded, perhaps without losing digits.
431    #[strum(serialize = "decimal.Rounded")]
432    DecimalRounded,
433    /// `decimal.Subnormal`: a result's exponent fell below `Emin`.
434    #[strum(serialize = "decimal.Subnormal")]
435    DecimalSubnormal,
436    /// `decimal.Overflow`: a result's exponent exceeded `Emax`.
437    #[strum(serialize = "decimal.Overflow")]
438    DecimalOverflow,
439    /// `decimal.Underflow`: a subnormal result was rounded.
440    #[strum(serialize = "decimal.Underflow")]
441    DecimalUnderflow,
442    /// `decimal.FloatOperation`: floats and Decimals were mixed.
443    #[strum(serialize = "decimal.FloatOperation")]
444    DecimalFloatOperation,
445}
446impl ExcType {
447    /// Checks if this exception type is a subclass of another exception type.
448    ///
449    /// Implements Python's exception hierarchy for try/except matching:
450    /// - `Exception` is the base class for all standard exceptions
451    /// - `LookupError` is the base for `KeyError` and `IndexError`
452    /// - `ArithmeticError` is the base for `ZeroDivisionError` and `OverflowError`
453    /// - `RuntimeError` is the base for `RecursionError` and `NotImplementedError`
454    ///
455    /// Returns true if `self` would be caught by `except handler_type:`.
456    #[must_use]
457    pub fn is_subclass_of(self, handler_type: Self) -> bool {
458        if self == handler_type || self.decimal_ancestors().contains(&handler_type) {
459            return true;
460        }
461        match handler_type {
462            // BaseException catches all exceptions
463            Self::BaseException => true,
464            // Exception catches everything except BaseException, and direct subclasses: KeyboardInterrupt, SystemExit
465            Self::Exception => !matches!(self, Self::BaseException | Self::KeyboardInterrupt | Self::SystemExit),
466            // LookupError catches KeyError and IndexError
467            Self::LookupError => matches!(self, Self::KeyError | Self::IndexError),
468            // ArithmeticError catches ZeroDivisionError and OverflowError
469            Self::ArithmeticError => matches!(self, Self::ZeroDivisionError | Self::OverflowError),
470            // RuntimeError catches RecursionError and NotImplementedError
471            Self::RuntimeError => matches!(self, Self::RecursionError | Self::NotImplementedError),
472            // AttributeError catches FrozenInstanceError
473            Self::AttributeError => matches!(self, Self::FrozenInstanceError),
474            // NameError catches UnboundLocalError
475            Self::NameError => matches!(self, Self::UnboundLocalError),
476            // io.UnsupportedOperation is here because CPython gives it dual
477            // OSError + ValueError parentage
478            Self::ValueError => matches!(
479                self,
480                Self::UnicodeDecodeError
481                    | Self::UnicodeEncodeError
482                    | Self::JsonDecodeError
483                    | Self::TomlDecodeError
484                    | Self::BinasciiError
485                    | Self::UnsupportedOperation
486            ),
487            // ImportError catches ModuleNotFoundError
488            Self::ImportError => matches!(self, Self::ModuleNotFoundError),
489            // OSError catches FileNotFoundError, FileExistsError, IsADirectoryError,
490            // NotADirectoryError, PermissionError, io.UnsupportedOperation, and
491            // TimeoutError (an OSError subclass since Python 3.3)
492            Self::OSError => matches!(
493                self,
494                Self::FileNotFoundError
495                    | Self::FileExistsError
496                    | Self::IsADirectoryError
497                    | Self::NotADirectoryError
498                    | Self::PermissionError
499                    | Self::UnsupportedOperation
500                    | Self::TimeoutError
501            ),
502            // All other types only match exactly (handled by self == handler_type above)
503            _ => false,
504        }
505    }
506
507    /// Every base of a `decimal` exception below `Exception`, in CPython's MRO order;
508    /// empty for every other type.
509    ///
510    /// The decimal signals inherit from several classes at once (`DivisionByZero` is
511    /// also a `ZeroDivisionError`, `Underflow` is `Inexact`, `Rounded` and `Subnormal`).
512    #[must_use]
513    pub fn decimal_ancestors(self) -> &'static [Self] {
514        const BASE: &[ExcType] = &[ExcType::DecimalException, ExcType::ArithmeticError];
515        const INVALID: &[ExcType] = &[
516            ExcType::DecimalInvalidOperation,
517            ExcType::DecimalException,
518            ExcType::ArithmeticError,
519        ];
520        match self {
521            Self::DecimalException => &[Self::ArithmeticError],
522            Self::DecimalClamped
523            | Self::DecimalInvalidOperation
524            | Self::DecimalInexact
525            | Self::DecimalRounded
526            | Self::DecimalSubnormal => BASE,
527            Self::DecimalConversionSyntax | Self::DecimalDivisionImpossible | Self::DecimalInvalidContext => INVALID,
528            Self::DecimalDivisionUndefined => &[
529                Self::DecimalInvalidOperation,
530                Self::DecimalException,
531                Self::ZeroDivisionError,
532                Self::ArithmeticError,
533            ],
534            Self::DecimalDivisionByZero => &[Self::DecimalException, Self::ZeroDivisionError, Self::ArithmeticError],
535            Self::DecimalOverflow => &[
536                Self::DecimalInexact,
537                Self::DecimalRounded,
538                Self::DecimalException,
539                Self::ArithmeticError,
540            ],
541            Self::DecimalUnderflow => &[
542                Self::DecimalInexact,
543                Self::DecimalRounded,
544                Self::DecimalSubnormal,
545                Self::DecimalException,
546                Self::ArithmeticError,
547            ],
548            Self::DecimalFloatOperation => &[Self::DecimalException, Self::ArithmeticError, Self::TypeError],
549            _ => &[],
550        }
551    }
552}
553
554/// Structured payload attached to exception types whose CPython counterparts
555/// carry more than a message. Currently unicode and json decode errors have
556/// one; the enum leaves room for future variants (e.g. `OSError`'s
557/// `errno`/`filename`) without another field on every exception.
558#[derive(Debug, Clone, Default, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
559pub enum ExcData {
560    /// No structured payload — every exception type without a variant below.
561    #[default]
562    None,
563    /// `UnicodeDecodeError` / `UnicodeEncodeError` constructor fields.
564    /// Boxed to keep the common `None` case (and every exception embedding
565    /// this enum) small.
566    Unicode(Box<UnicodeErrorData>),
567    /// `json.JSONDecodeError` attribute fields. Boxed like
568    /// [`ExcData::Unicode`] to keep the enum small.
569    Json(Box<JsonErrorData>),
570}
571
572impl ExcData {
573    /// The unicode-error fields, if this is [`ExcData::Unicode`].
574    #[must_use]
575    pub fn unicode(&self) -> Option<&UnicodeErrorData> {
576        match self {
577            Self::Unicode(data) => Some(data),
578            _ => None,
579        }
580    }
581
582    /// The json-error fields, if this is [`ExcData::Json`].
583    #[must_use]
584    pub fn json(&self) -> Option<&JsonErrorData> {
585        match self {
586            Self::Json(data) => Some(data),
587            _ => None,
588        }
589    }
590}
591
592/// Structured fields of a `UnicodeDecodeError` / `UnicodeEncodeError`,
593/// mirroring CPython's `encoding` / `object` / `start` / `end` / `reason`
594/// exception attributes.
595///
596/// Monty exceptions are otherwise message-only; unicode errors additionally
597/// carry these fields so host bindings (e.g. `pydantic_monty`) can construct
598/// real `UnicodeDecodeError` / `UnicodeEncodeError` instances instead of
599/// falling back to a plain `ValueError`. The payload is omitted when the
600/// offending object is larger than [`UnicodeErrorData::MAX_OBJECT_LEN`] —
601/// exceptions can be stored and copied outside the sandbox's resource
602/// tracker, so an unbounded payload would let huge inputs evade memory
603/// limits. Sandboxed code never sees these fields (in-sandbox exceptions
604/// expose only `args`).
605#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
606pub struct UnicodeErrorData {
607    /// The codec name as CPython reports it, e.g. `"utf-8"`, `"ascii"`.
608    pub encoding: String,
609    /// The full input that failed to encode/decode (`str` for encode errors,
610    /// `bytes` for decode errors), matching CPython's `exc.object`.
611    pub object: UnicodeErrorObject,
612    /// Start of the failing range: a character index for encode errors, a
613    /// byte offset for decode errors.
614    pub start: usize,
615    /// Exclusive end of the failing range, in the same units as `start`.
616    pub end: usize,
617    /// CPython's reason wording, e.g. `"ordinal not in range(128)"`.
618    pub reason: String,
619}
620
621/// The `object` attribute of a unicode error: the input being converted.
622#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
623pub enum UnicodeErrorObject {
624    /// A decode error's input `bytes`.
625    Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
626    /// An encode error's input `str`.
627    Str(String),
628}
629
630impl UnicodeErrorData {
631    /// Payload size cap: unicode errors on objects larger than this carry no
632    /// structured data (hosts fall back to the message-only `ValueError`).
633    /// Exception payloads are copied into the host once they escape the worker,
634    /// so the cap bounds how much host memory a single raise can pin.
635    pub const MAX_OBJECT_LEN: usize = 64 * 1024;
636
637    /// Builds the payload for an encode error on `object`, or
638    /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
639    #[must_use]
640    pub fn encode(encoding: &str, object: &str, start: usize, end: usize, reason: &str) -> ExcData {
641        if object.len() <= Self::MAX_OBJECT_LEN {
642            ExcData::Unicode(Box::new(Self {
643                encoding: encoding.to_owned(),
644                object: UnicodeErrorObject::Str(object.to_owned()),
645                start,
646                end,
647                reason: reason.to_owned(),
648            }))
649        } else {
650            ExcData::None
651        }
652    }
653
654    /// Builds the payload for a decode error on `object`, or
655    /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
656    /// Public so `monty-fs` can build the payload for text-mode file reads.
657    #[must_use]
658    pub fn decode(encoding: &str, object: &[u8], start: usize, end: usize, reason: &str) -> ExcData {
659        if object.len() <= Self::MAX_OBJECT_LEN {
660            ExcData::Unicode(Box::new(Self {
661                encoding: encoding.to_owned(),
662                object: UnicodeErrorObject::Bytes(object.to_vec()),
663                start,
664                end,
665                reason: reason.to_owned(),
666            }))
667        } else {
668            ExcData::None
669        }
670    }
671}
672
673/// Structured fields of a `json.JSONDecodeError`, mirroring CPython's `msg` /
674/// `doc` / `pos` / `lineno` / `colno` exception attributes. `tomllib.TOMLDecodeError`
675/// defines the same five attributes, so it carries this payload too.
676///
677/// As with [`UnicodeErrorData`], the payload exists so host bindings can
678/// construct a real `json.JSONDecodeError` instead of falling back to a plain
679/// `ValueError`; sandboxed code never sees these fields. `lineno`/`colno` are
680/// carried explicitly rather than recomputed from `doc` because `doc` may be
681/// absent (see [`JsonErrorData::MAX_DOC_LEN`]).
682#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
683pub struct JsonErrorData {
684    /// The bare error message, without the `: line N column M (char K)`
685    /// suffix the formatted exception message carries.
686    pub msg: String,
687    /// The document being parsed, matching CPython's `exc.doc`. `None` when
688    /// the document exceeds [`JsonErrorData::MAX_DOC_LEN`] or is not valid
689    /// UTF-8 (`json.loads` on `bytes` input).
690    pub doc: Option<String>,
691    /// Character index of the error in `doc`, matching CPython's `exc.pos`.
692    pub pos: usize,
693    /// 1-based line of the error, matching CPython's `exc.lineno`.
694    pub lineno: usize,
695    /// 1-based column of the error, matching CPython's `exc.colno`.
696    pub colno: usize,
697}
698
699impl JsonErrorData {
700    /// Document size cap, mirroring [`UnicodeErrorData::MAX_OBJECT_LEN`]:
701    /// exception payloads are copied into the host once they escape the worker,
702    /// so `doc` is dropped (not truncated — a partial
703    /// document would misplace `pos`) for larger inputs.
704    pub const MAX_DOC_LEN: usize = 64 * 1024;
705
706    /// Builds the payload for a decode error on `doc`, omitting the document
707    /// when it exceeds [`Self::MAX_DOC_LEN`] or is not valid UTF-8.
708    #[must_use]
709    pub fn build(msg: &str, doc: &[u8], pos: usize, lineno: usize, colno: usize) -> ExcData {
710        let doc = if doc.len() <= Self::MAX_DOC_LEN {
711            str::from_utf8(doc).ok().map(ToOwned::to_owned)
712        } else {
713            None
714        };
715        ExcData::Json(Box::new(Self {
716            msg: msg.to_owned(),
717            doc,
718            pos,
719            lineno,
720            colno,
721        }))
722    }
723}
724
725/// A single frame in a Python traceback.
726///
727/// Contains all the information needed to display a traceback line:
728/// the file location, function name, and optional source code preview.
729///
730/// # Caret Markers
731///
732/// Monty uses only `~` characters for caret markers in tracebacks, unlike CPython 3.11+
733/// which uses `~` for the function name and `^` for arguments (e.g., `~~~~~~~~~~~^^^^^^^^^^^`).
734/// This simplification is intentional - Monty marks the entire expression span uniformly.
735#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
736pub struct StackFrame {
737    /// The filename where the code is located.
738    pub filename: String,
739    /// Start position in the source code.
740    pub start: CodeLoc,
741    /// End position in the source code.
742    pub end: CodeLoc,
743    /// The name of the frame (function name, or None for module-level code).
744    pub frame_name: Option<String>,
745    /// The source code line for preview in the traceback.
746    ///
747    /// Stored as `Arc<str>` rather than `String` so that consecutive frames
748    /// referencing the same source line — typical of recursion and tight
749    /// helper-function loops — share a single allocation. Without sharing, a
750    /// 1000-deep recursive call into code on a long line would clone the
751    /// entire line into each frame and amplify memory usage by the call
752    /// depth. Serialization roundtrips lose the sharing (each frame gets
753    /// its own `Arc`), but that is bounded by the wire size of the
754    /// traceback so does not regress the amplification.
755    pub preview_line: Option<Arc<str>>,
756    /// Whether to hide the caret marker in the traceback for this frame.
757    ///
758    /// Set to `true` for:
759    /// - `raise` statements (CPython doesn't show carets for raise)
760    /// - `AttributeError` on attribute access (CPython doesn't show carets for these)
761    pub hide_caret: bool,
762    /// Whether to hide the `, in <name>` part of the frame line.
763    ///
764    /// Set to `true` for `SyntaxError` where CPython doesn't show the frame name.
765    /// CPython's SyntaxError format: `  File "...", line N`
766    /// vs runtime error format: `  File "...", line N, in <module>`
767    pub hide_frame_name: bool,
768}
769
770impl fmt::Display for StackFrame {
771    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772        // SyntaxError format: `  File "...", line N`
773        // Runtime error format: `  File "...", line N, in <module>`
774        if self.hide_frame_name {
775            write!(f, r#"  File "{}", line {}"#, self.filename, self.start.line)?;
776        } else {
777            write!(f, r#"  File "{}", line {}, in "#, self.filename, self.start.line)?;
778            if let Some(frame_name) = &self.frame_name {
779                f.write_str(frame_name)?;
780            } else {
781                f.write_str("<module>")?;
782            }
783        }
784
785        if let Some(line) = &self.preview_line {
786            if self.start.line != self.end.line {
787                // Multi-line statement range: `preview_line` holds a
788                // pre-rendered, dedented block (see `SourceMap::multiline_preview`).
789                // CPython prints each line at the 4-space frame indent with no
790                // caret markers.
791                f.write_char('\n')?;
792                for block_line in line.lines() {
793                    writeln!(f, "    {block_line}")?;
794                }
795                return Ok(());
796            }
797            // Strip leading whitespace like CPython does
798            let trimmed = line.trim_start();
799            writeln!(f, "\n    {trimmed}")?;
800
801            // Hide caret for raise statements, AttributeError, etc.
802            if !self.hide_caret {
803                let leading_spaces = line.len() - trimmed.len();
804                // Calculate caret position relative to the trimmed line
805                // Column is 1-indexed, so subtract 1, then subtract leading spaces we stripped
806                let caret_start = if self.start.column as usize > leading_spaces {
807                    4 + self.start.column as usize - leading_spaces - 1
808                } else {
809                    4
810                };
811                f.write_str(&" ".repeat(caret_start))?;
812                // Always render at least one caret, even for zero-length ranges
813                // (e.g. a SyntaxError pointing just past the end of a truncated token).
814                let caret_len = (self.end.column - self.start.column).max(1) as usize;
815                writeln!(f, "{}", "~".repeat(caret_len))?;
816            }
817        } else {
818            f.write_char('\n')?;
819        }
820        Ok(())
821    }
822}
823
824/// A line and column position in source code.
825///
826/// Uses 1-based indexing for both line and column to match Python's conventions.
827///
828/// `u32` matches `ruff_text_size::TextSize`, which underpins all source ranges
829/// returned by the parser, so conversions between the two are zero-cost.
830#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
831pub struct CodeLoc {
832    /// Line number (1-based).
833    #[serde(rename = "L")]
834    pub line: u32,
835    /// Column number (1-based), counted in characters (not bytes).
836    #[serde(rename = "C")]
837    pub column: u32,
838}
839
840impl Default for CodeLoc {
841    fn default() -> Self {
842        Self { line: 1, column: 1 }
843    }
844}
845
846impl CodeLoc {
847    /// Creates a new CodeLoc from 0-based values.
848    ///
849    /// Lines and columns numbers are 1-indexed for display, hence `+ 1`.
850    /// Saturates at `u32::MAX` rather than panicking — overflow here is
851    /// already unreachable for any source ruff will accept (it caps source
852    /// size at 4 GiB), and saturation keeps the parser panic-free even if
853    /// that ever changes.
854    #[must_use]
855    pub fn new(line: u32, column: u32) -> Self {
856        Self {
857            line: line.saturating_add(1),
858            column: column.saturating_add(1),
859        }
860    }
861}
862
863/// The source range of the expression that suspended execution, carried by
864/// every suspension.
865///
866/// `start` and `end` are UTF-8 byte offsets into the named source, `end`
867/// exclusive: slice the source's bytes (in Python, `source.encode()[start:end]`)
868/// rather than indexing the string. A peer that sends no position reads as
869/// [`Self::unknown`].
870#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
871pub struct SourceRange {
872    /// The source the range indexes, named as a traceback frame names it: an
873    /// in-process one-shot run's script name, `<python-input-N>` for a feed,
874    /// `<string>` in `eval()` / `exec()`.
875    pub filename: String,
876    /// Byte offset where the expression starts.
877    pub start: u32,
878    /// Byte offset where the expression ends (exclusive).
879    pub end: u32,
880}
881
882impl SourceRange {
883    /// Longest `filename` a range carries, in bytes, so a peer cannot make hosts copy a huge name.
884    pub const MAX_FILENAME_LEN: usize = 256;
885
886    /// Builds a range, cutting `filename` on a char boundary.
887    #[must_use]
888    pub fn new(filename: &str, start: u32, end: u32) -> Self {
889        let mut len = filename.len().min(Self::MAX_FILENAME_LEN);
890        while !filename.is_char_boundary(len) {
891            len -= 1;
892        }
893        Self {
894            filename: filename[..len].to_owned(),
895            start,
896            end,
897        }
898    }
899
900    /// The range a host reports when its peer sent no position: an empty
901    /// filename and an empty range at offset zero.
902    #[must_use]
903    pub fn unknown() -> Self {
904        Self {
905            filename: String::new(),
906            start: 0,
907            end: 0,
908        }
909    }
910}
911
912/// Formats the message for a `UnicodeDecodeError` covering the byte range
913/// `start..end`: CPython's single-byte form (`byte 0x{first_byte:02x} in
914/// position {start}`) when the range is one byte, otherwise the range form
915/// (`bytes in position {start}-{end - 1}`).
916///
917/// A free function (rather than folded into `ExcType::unicode_decode_error`),
918/// public and re-exported at the crate root, so `monty-fs` can produce the
919/// identical wording when converting a `MountError::InvalidUtf8` from a
920/// text-mode file read into an exception.
921#[must_use]
922pub fn unicode_decode_error_msg(codec: &str, first_byte: u8, start: usize, end: usize, reason: &str) -> String {
923    // Callers must pass a non-empty range; checked in debug builds only so a
924    // wrong caller can't panic the VM in release (it gets a garbled message
925    // position instead, which is harmless).
926    debug_assert!(
927        end > start,
928        "unicode_decode_error_msg: end ({end}) must be > start ({start})"
929    );
930    if end - start == 1 {
931        format!("'{codec}' codec can't decode byte 0x{first_byte:02x} in position {start}: {reason}")
932    } else {
933        let last = end - 1;
934        format!("'{codec}' codec can't decode bytes in position {start}-{last}: {reason}")
935    }
936}