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}