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
457/// Structured fields of a `UnicodeDecodeError` / `UnicodeEncodeError`,
458/// mirroring CPython's `encoding` / `object` / `start` / `end` / `reason`
459/// exception attributes.
460///
461/// Monty exceptions are otherwise message-only; unicode errors additionally
462/// carry these fields so host bindings (e.g. `pydantic_monty`) can construct
463/// real `UnicodeDecodeError` / `UnicodeEncodeError` instances instead of
464/// falling back to a plain `ValueError`. The payload is omitted when the
465/// offending object is larger than [`UnicodeErrorData::MAX_OBJECT_LEN`] —
466/// exceptions can be stored and copied outside the sandbox's resource
467/// tracker, so an unbounded payload would let huge inputs evade memory
468/// limits. Sandboxed code never sees these fields (in-sandbox exceptions
469/// expose only `args`).
470#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
471pub struct UnicodeErrorData {
472    /// The codec name as CPython reports it, e.g. `"utf-8"`, `"ascii"`.
473    pub encoding: String,
474    /// The full input that failed to encode/decode (`str` for encode errors,
475    /// `bytes` for decode errors), matching CPython's `exc.object`.
476    pub object: UnicodeErrorObject,
477    /// Start of the failing range: a character index for encode errors, a
478    /// byte offset for decode errors.
479    pub start: usize,
480    /// Exclusive end of the failing range, in the same units as `start`.
481    pub end: usize,
482    /// CPython's reason wording, e.g. `"ordinal not in range(128)"`.
483    pub reason: String,
484}
485
486/// The `object` attribute of a unicode error: the input being converted.
487#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
488pub enum UnicodeErrorObject {
489    /// A decode error's input `bytes`.
490    Bytes(Vec<u8>),
491    /// An encode error's input `str`.
492    Str(String),
493}
494
495impl UnicodeErrorData {
496    /// Payload size cap: unicode errors on objects larger than this carry no
497    /// structured data (hosts fall back to the message-only `ValueError`).
498    /// Exception payloads are copied into the host once they escape the worker,
499    /// so the cap bounds how much host memory a single raise can pin.
500    pub const MAX_OBJECT_LEN: usize = 64 * 1024;
501
502    /// Builds the payload for an encode error on `object`, or
503    /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
504    #[must_use]
505    pub fn encode(encoding: &str, object: &str, start: usize, end: usize, reason: &str) -> ExcData {
506        if object.len() <= Self::MAX_OBJECT_LEN {
507            ExcData::Unicode(Box::new(Self {
508                encoding: encoding.to_owned(),
509                object: UnicodeErrorObject::Str(object.to_owned()),
510                start,
511                end,
512                reason: reason.to_owned(),
513            }))
514        } else {
515            ExcData::None
516        }
517    }
518
519    /// Builds the payload for a decode error on `object`, or
520    /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
521    /// Public so `monty-fs` can build the payload for text-mode file reads.
522    #[must_use]
523    pub fn decode(encoding: &str, object: &[u8], start: usize, end: usize, reason: &str) -> ExcData {
524        if object.len() <= Self::MAX_OBJECT_LEN {
525            ExcData::Unicode(Box::new(Self {
526                encoding: encoding.to_owned(),
527                object: UnicodeErrorObject::Bytes(object.to_vec()),
528                start,
529                end,
530                reason: reason.to_owned(),
531            }))
532        } else {
533            ExcData::None
534        }
535    }
536}
537
538/// Structured fields of a `json.JSONDecodeError`, mirroring CPython's `msg` /
539/// `doc` / `pos` / `lineno` / `colno` exception attributes.
540///
541/// As with [`UnicodeErrorData`], the payload exists so host bindings can
542/// construct a real `json.JSONDecodeError` instead of falling back to a plain
543/// `ValueError`; sandboxed code never sees these fields. `lineno`/`colno` are
544/// carried explicitly rather than recomputed from `doc` because `doc` may be
545/// absent (see [`JsonErrorData::MAX_DOC_LEN`]).
546#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
547pub struct JsonErrorData {
548    /// The bare error message, without the `: line N column M (char K)`
549    /// suffix the formatted exception message carries.
550    pub msg: String,
551    /// The document being parsed, matching CPython's `exc.doc`. `None` when
552    /// the document exceeds [`JsonErrorData::MAX_DOC_LEN`] or is not valid
553    /// UTF-8 (`json.loads` on `bytes` input).
554    pub doc: Option<String>,
555    /// Character index of the error in `doc`, matching CPython's `exc.pos`.
556    pub pos: usize,
557    /// 1-based line of the error, matching CPython's `exc.lineno`.
558    pub lineno: usize,
559    /// 1-based column of the error, matching CPython's `exc.colno`.
560    pub colno: usize,
561}
562
563impl JsonErrorData {
564    /// Document size cap, mirroring [`UnicodeErrorData::MAX_OBJECT_LEN`]:
565    /// exception payloads are copied into the host once they escape the worker,
566    /// so `doc` is dropped (not truncated — a partial
567    /// document would misplace `pos`) for larger inputs.
568    pub const MAX_DOC_LEN: usize = 64 * 1024;
569
570    /// Builds the payload for a decode error on `doc`, omitting the document
571    /// when it exceeds [`Self::MAX_DOC_LEN`] or is not valid UTF-8.
572    #[must_use]
573    pub fn build(msg: &str, doc: &[u8], pos: usize, lineno: usize, colno: usize) -> ExcData {
574        let doc = if doc.len() <= Self::MAX_DOC_LEN {
575            str::from_utf8(doc).ok().map(ToOwned::to_owned)
576        } else {
577            None
578        };
579        ExcData::Json(Box::new(Self {
580            msg: msg.to_owned(),
581            doc,
582            pos,
583            lineno,
584            colno,
585        }))
586    }
587}
588
589/// A single frame in a Python traceback.
590///
591/// Contains all the information needed to display a traceback line:
592/// the file location, function name, and optional source code preview.
593///
594/// # Caret Markers
595///
596/// Monty uses only `~` characters for caret markers in tracebacks, unlike CPython 3.11+
597/// which uses `~` for the function name and `^` for arguments (e.g., `~~~~~~~~~~~^^^^^^^^^^^`).
598/// This simplification is intentional - Monty marks the entire expression span uniformly.
599#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
600pub struct StackFrame {
601    /// The filename where the code is located.
602    pub filename: String,
603    /// Start position in the source code.
604    pub start: CodeLoc,
605    /// End position in the source code.
606    pub end: CodeLoc,
607    /// The name of the frame (function name, or None for module-level code).
608    pub frame_name: Option<String>,
609    /// The source code line for preview in the traceback.
610    ///
611    /// Stored as `Arc<str>` rather than `String` so that consecutive frames
612    /// referencing the same source line — typical of recursion and tight
613    /// helper-function loops — share a single allocation. Without sharing, a
614    /// 1000-deep recursive call into code on a long line would clone the
615    /// entire line into each frame and amplify memory usage by the call
616    /// depth. Serialization roundtrips lose the sharing (each frame gets
617    /// its own `Arc`), but that is bounded by the wire size of the
618    /// traceback so does not regress the amplification.
619    pub preview_line: Option<Arc<str>>,
620    /// Whether to hide the caret marker in the traceback for this frame.
621    ///
622    /// Set to `true` for:
623    /// - `raise` statements (CPython doesn't show carets for raise)
624    /// - `AttributeError` on attribute access (CPython doesn't show carets for these)
625    pub hide_caret: bool,
626    /// Whether to hide the `, in <name>` part of the frame line.
627    ///
628    /// Set to `true` for `SyntaxError` where CPython doesn't show the frame name.
629    /// CPython's SyntaxError format: `  File "...", line N`
630    /// vs runtime error format: `  File "...", line N, in <module>`
631    pub hide_frame_name: bool,
632}
633
634impl fmt::Display for StackFrame {
635    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
636        // SyntaxError format: `  File "...", line N`
637        // Runtime error format: `  File "...", line N, in <module>`
638        if self.hide_frame_name {
639            write!(f, r#"  File "{}", line {}"#, self.filename, self.start.line)?;
640        } else {
641            write!(f, r#"  File "{}", line {}, in "#, self.filename, self.start.line)?;
642            if let Some(frame_name) = &self.frame_name {
643                f.write_str(frame_name)?;
644            } else {
645                f.write_str("<module>")?;
646            }
647        }
648
649        if let Some(line) = &self.preview_line {
650            if self.start.line != self.end.line {
651                // Multi-line statement range: `preview_line` holds a
652                // pre-rendered, dedented block (see `SourceMap::multiline_preview`).
653                // CPython prints each line at the 4-space frame indent with no
654                // caret markers.
655                f.write_char('\n')?;
656                for block_line in line.lines() {
657                    writeln!(f, "    {block_line}")?;
658                }
659                return Ok(());
660            }
661            // Strip leading whitespace like CPython does
662            let trimmed = line.trim_start();
663            writeln!(f, "\n    {trimmed}")?;
664
665            // Hide caret for raise statements, AttributeError, etc.
666            if !self.hide_caret {
667                let leading_spaces = line.len() - trimmed.len();
668                // Calculate caret position relative to the trimmed line
669                // Column is 1-indexed, so subtract 1, then subtract leading spaces we stripped
670                let caret_start = if self.start.column as usize > leading_spaces {
671                    4 + self.start.column as usize - leading_spaces - 1
672                } else {
673                    4
674                };
675                f.write_str(&" ".repeat(caret_start))?;
676                // Always render at least one caret, even for zero-length ranges
677                // (e.g. a SyntaxError pointing just past the end of a truncated token).
678                let caret_len = (self.end.column - self.start.column).max(1) as usize;
679                writeln!(f, "{}", "~".repeat(caret_len))?;
680            }
681        } else {
682            f.write_char('\n')?;
683        }
684        Ok(())
685    }
686}
687
688/// A line and column position in source code.
689///
690/// Uses 1-based indexing for both line and column to match Python's conventions.
691///
692/// `u32` matches `ruff_text_size::TextSize`, which underpins all source ranges
693/// returned by the parser, so conversions between the two are zero-cost.
694#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
695pub struct CodeLoc {
696    /// Line number (1-based).
697    pub line: u32,
698    /// Column number (1-based), counted in characters (not bytes).
699    pub column: u32,
700}
701
702impl Default for CodeLoc {
703    fn default() -> Self {
704        Self { line: 1, column: 1 }
705    }
706}
707
708impl CodeLoc {
709    /// Creates a new CodeLoc from 0-based values.
710    ///
711    /// Lines and columns numbers are 1-indexed for display, hence `+ 1`.
712    /// Saturates at `u32::MAX` rather than panicking — overflow here is
713    /// already unreachable for any source ruff will accept (it caps source
714    /// size at 4 GiB), and saturation keeps the parser panic-free even if
715    /// that ever changes.
716    #[must_use]
717    pub fn new(line: u32, column: u32) -> Self {
718        Self {
719            line: line.saturating_add(1),
720            column: column.saturating_add(1),
721        }
722    }
723}
724
725/// Formats the message for a `UnicodeDecodeError` covering the byte range
726/// `start..end`: CPython's single-byte form (`byte 0x{first_byte:02x} in
727/// position {start}`) when the range is one byte, otherwise the range form
728/// (`bytes in position {start}-{end - 1}`).
729///
730/// A free function (rather than folded into `ExcType::unicode_decode_error`),
731/// public and re-exported at the crate root, so `monty-fs` can produce the
732/// identical wording when converting a `MountError::InvalidUtf8` from a
733/// text-mode file read into an exception.
734#[must_use]
735pub fn unicode_decode_error_msg(codec: &str, first_byte: u8, start: usize, end: usize, reason: &str) -> String {
736    // Callers must pass a non-empty range; checked in debug builds only so a
737    // wrong caller can't panic the VM in release (it gets a garbled message
738    // position instead, which is harmless).
739    debug_assert!(
740        end > start,
741        "unicode_decode_error_msg: end ({end}) must be > start ({start})"
742    );
743    if end - start == 1 {
744        format!("'{codec}' codec can't decode byte 0x{first_byte:02x} in position {start}: {reason}")
745    } else {
746        let last = end - 1;
747        format!("'{codec}' codec can't decode bytes in position {start}-{last}: {reason}")
748    }
749}