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