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}