Skip to main content

python_ast/
result.rs

1//! Error and result types for the `python-ast` crate.
2//!
3//! This module re-exports [`anyhow_tracing::Error`] / [`anyhow_tracing::Result`] as the
4//! crate-wide [`Error`] / [`Result`] types so that errors carry **structured fields**
5//! (location, feature, expected, found, …) instead of pre-formatted `String`s, which
6//! allows tracing/logging consumers — and downstream code that wants to recover the
7//! typed payload via [`anyhow_tracing::Error::downcast_ref`] — to inspect the original
8//! data without having to re-parse a stringified message.
9//!
10//! The concrete error *kinds* (e.g. [`ParseError`], [`UnsupportedFeature`],
11//! [`BinOpNotYetImplemented`]) are exposed as small `thiserror`-derived structs that
12//! implement [`std::error::Error`]. They are wrapped into [`anyhow_tracing::Error`] at
13//! the construction site (see the helper constructors on this module's `Error`-shim
14//! re-exports below: [`parsing_error`], [`codegen_error`], [`unsupported_feature`],
15//! [`type_error`], [`syntax_error`]). The wrapping carries named fields so structured
16//! tracing subscribers see fields rather than a stringified blob, while downcasts
17//! still recover the typed payload.
18
19use pyo3::PyErr;
20use thiserror::Error as ThisError;
21
22use crate::{BinOp, BoolOp, Compare, Expr, ExprType, PositionInfo, StatementType, UnaryOp};
23
24// Re-export the anyhow-tracing types and macros so the rest of the crate has a
25// single import path: `crate::{Error, Result, Context, anyhow, bail, ensure}`.
26pub use anyhow_tracing::{Context, Error, Result};
27// Macros are exported at the root by `#[macro_export]`; re-export them at this
28// path for ergonomic use as `crate::result::{anyhow, bail, ensure}` if desired.
29pub use anyhow_tracing::{anyhow, bail, ensure};
30
31/// Location information for error reporting.
32#[derive(Debug, Clone, PartialEq)]
33pub struct SourceLocation {
34    pub filename: String,
35    pub line: Option<usize>,
36    pub column: Option<usize>,
37    pub end_line: Option<usize>,
38    pub end_column: Option<usize>,
39}
40
41impl SourceLocation {
42    pub fn new(filename: impl Into<String>) -> Self {
43        Self {
44            filename: filename.into(),
45            line: None,
46            column: None,
47            end_line: None,
48            end_column: None,
49        }
50    }
51
52    pub fn with_position(
53        filename: impl Into<String>,
54        line: Option<usize>,
55        column: Option<usize>,
56    ) -> Self {
57        Self {
58            filename: filename.into(),
59            line,
60            column,
61            end_line: None,
62            end_column: None,
63        }
64    }
65
66    pub fn with_span(
67        filename: impl Into<String>,
68        line: Option<usize>,
69        column: Option<usize>,
70        end_line: Option<usize>,
71        end_column: Option<usize>,
72    ) -> Self {
73        Self {
74            filename: filename.into(),
75            line,
76            column,
77            end_line,
78            end_column,
79        }
80    }
81
82    /// Create a SourceLocation from an AST node that implements PositionInfo.
83    pub fn from_node(filename: impl Into<String>, node: &dyn PositionInfo) -> Self {
84        let (line, column, end_line, end_column) = node.position_info();
85        Self {
86            filename: filename.into(),
87            line,
88            column,
89            end_line,
90            end_column,
91        }
92    }
93}
94
95impl std::fmt::Display for SourceLocation {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match (self.line, self.column) {
98            (Some(line), Some(col)) => {
99                if let (Some(end_line), Some(end_col)) = (self.end_line, self.end_column) {
100                    if line == end_line {
101                        write!(f, "{}:{}:{}-{}", self.filename, line, col, end_col)
102                    } else {
103                        write!(
104                            f,
105                            "{}:{}:{}-{}:{}",
106                            self.filename, line, col, end_line, end_col
107                        )
108                    }
109                } else {
110                    write!(f, "{}:{}:{}", self.filename, line, col)
111                }
112            }
113            (Some(line), None) => write!(f, "{}:{}", self.filename, line),
114            _ => write!(f, "{}", self.filename),
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Concrete (downcast-able) error kinds.
121//
122// These hold typed payloads so callers can recover them via
123// `err.downcast_ref::<ParseError>()` etc. The original String-typed `message`,
124// `help`, `expected`, `found` payloads are *removed* from the typed payload —
125// they are now carried as named fields on the [`anyhow_tracing::Error`] wrapper
126// (see the constructor helpers below). This is the core of the migration: no
127// premature String-ification of structured data.
128// ---------------------------------------------------------------------------
129
130/// Python parsing failed.
131#[derive(Debug, ThisError)]
132#[error("parsing error at {location}")]
133pub struct ParseError {
134    pub location: SourceLocation,
135}
136
137/// Code generation failed.
138#[derive(Debug, ThisError)]
139#[error("code generation error at {location}")]
140pub struct CodeGenError {
141    pub location: SourceLocation,
142}
143
144/// A Python feature is not yet implemented in the transpiler.
145#[derive(Debug, ThisError)]
146#[error("unsupported feature `{feature}` at {location}")]
147pub struct UnsupportedFeature {
148    pub location: SourceLocation,
149    pub feature: String,
150}
151
152/// A type mismatch was detected during code generation.
153#[derive(Debug, ThisError)]
154#[error("type error at {location}")]
155pub struct TypeError {
156    pub location: SourceLocation,
157}
158
159/// The Python source contained invalid syntax.
160#[derive(Debug, ThisError)]
161#[error("syntax error at {location}")]
162pub struct SyntaxError {
163    pub location: SourceLocation,
164}
165
166/// A binary operator the transpiler does not yet handle. Carries the original
167/// `BinOp` AST node so callers can inspect it without having to re-parse a
168/// stringified message.
169#[derive(Debug, ThisError)]
170#[error("BinOp type not yet implemented: {0:?}")]
171pub struct BinOpNotYetImplemented(pub BinOp);
172
173#[derive(Debug, ThisError)]
174#[error("BoolOp type not yet implemented: {0:?}")]
175pub struct BoolOpNotYetImplemented(pub BoolOp);
176
177#[derive(Debug, ThisError)]
178#[error("Compare type not yet implemented: {0:?}")]
179pub struct CompareNotYetImplemented(pub Compare);
180
181#[derive(Debug, ThisError)]
182#[error("Expr type not yet implemented: {0:?}")]
183pub struct ExprNotYetImplemented(pub Expr);
184
185#[derive(Debug, ThisError)]
186#[error("ExprType type not yet implemented: {0:?}")]
187pub struct ExprTypeNotYetImplemented(pub ExprType);
188
189#[derive(Debug, ThisError)]
190#[error("Statement type not yet implemented: {0:?}")]
191pub struct StatementNotYetImplemented(pub StatementType);
192
193#[derive(Debug, ThisError)]
194#[error("UnaryOp type not yet implemented: {0:?}")]
195pub struct UnaryOpNotYetImplemented(pub UnaryOp);
196
197#[derive(Debug, ThisError)]
198#[error("Unknown type {0}")]
199pub struct UnknownType(pub String);
200
201// ---------------------------------------------------------------------------
202// Construction helpers that build a structured `anyhow_tracing::Error`.
203//
204// Each helper:
205//   1. Builds the typed payload struct so it can be recovered by `downcast_ref`,
206//   2. Wraps it into an `anyhow_tracing::Error`,
207//   3. Attaches the previously-stringified data as **named fields** (location,
208//      help, expected, found, …) so structured logging sees them as fields.
209// ---------------------------------------------------------------------------
210
211/// Wrap a typed error struct into a structured [`Error`], preserving the
212/// typed payload as the source so callers can recover it via `downcast_ref`.
213fn into_error<E>(typed: E) -> Error
214where
215    E: std::error::Error + Send + Sync + 'static,
216{
217    Error::from(anyhow::Error::from(typed))
218}
219
220/// Create a parsing error with location and helpful guidance.
221pub fn parsing_error(
222    location: SourceLocation,
223    message: impl std::fmt::Display,
224    help: impl std::fmt::Display,
225) -> Error {
226    let location_str = location.to_string();
227    into_error(ParseError { location })
228        .with_field("location", location_str)
229        .with_field("message", message)
230        .with_field("help", help)
231}
232
233/// Create a code generation error with location and helpful guidance.
234pub fn codegen_error(
235    location: SourceLocation,
236    message: impl std::fmt::Display,
237    help: impl std::fmt::Display,
238) -> Error {
239    let location_str = location.to_string();
240    into_error(CodeGenError { location })
241        .with_field("location", location_str)
242        .with_field("message", message)
243        .with_field("help", help)
244}
245
246/// Create an unsupported-feature error with location and helpful guidance.
247pub fn unsupported_feature(
248    location: SourceLocation,
249    feature: impl Into<String>,
250    help: impl std::fmt::Display,
251) -> Error {
252    let feature = feature.into();
253    let location_str = location.to_string();
254    into_error(UnsupportedFeature {
255        location,
256        feature: feature.clone(),
257    })
258    .with_field("location", location_str)
259    .with_field("feature", feature)
260    .with_field("help", help)
261}
262
263/// Create a type error with location and helpful guidance.
264pub fn type_error(
265    location: SourceLocation,
266    message: impl std::fmt::Display,
267    expected: impl std::fmt::Display,
268    found: impl std::fmt::Display,
269    help: impl std::fmt::Display,
270) -> Error {
271    let location_str = location.to_string();
272    into_error(TypeError { location })
273        .with_field("location", location_str)
274        .with_field("message", message)
275        .with_field("expected", expected)
276        .with_field("found", found)
277        .with_field("help", help)
278}
279
280/// Create a syntax error with location and helpful guidance.
281pub fn syntax_error(
282    location: SourceLocation,
283    message: impl std::fmt::Display,
284    help: impl std::fmt::Display,
285) -> Error {
286    let location_str = location.to_string();
287    into_error(SyntaxError { location })
288        .with_field("location", location_str)
289        .with_field("message", message)
290        .with_field("help", help)
291}
292
293// ---------------------------------------------------------------------------
294// Boundary adapters.
295//
296// `anyhow_tracing::Error` does not have a blanket `From<E: std::error::Error>`
297// impl, so we provide a small extension trait that converts any
298// `Result<T, E: std::error::Error>` into our crate-wide [`Result<T>`]. This
299// keeps `?` ergonomic at the boundary where foreign errors (e.g. `PyErr`,
300// `syn::Error`, `std::io::Error`) flow into a function returning [`Result`].
301//
302// Use `.into_err()` instead of `?` directly when the source error doesn't have
303// a `From` impl into [`Error`]:
304//
305//     let dumped = dump(&ob, None).into_err()?; // PyErr -> Error
306// ---------------------------------------------------------------------------
307
308/// Converts foreign error results into the crate's structured [`Result`] type.
309pub trait IntoErr<T> {
310    /// Convert the error into [`Error`], preserving the original error as the
311    /// source so callers can still downcast to the concrete type.
312    fn into_err(self) -> Result<T>;
313}
314
315impl<T, E> IntoErr<T> for std::result::Result<T, E>
316where
317    E: std::error::Error + Send + Sync + 'static,
318{
319    fn into_err(self) -> Result<T> {
320        self.map_err(|e| Error::from(anyhow::Error::from(e)))
321    }
322}
323
324/// Convert any `std::error::Error` into the crate-wide [`Error`].
325pub fn err_from<E>(e: E) -> Error
326where
327    E: std::error::Error + Send + Sync + 'static,
328{
329    Error::from(anyhow::Error::from(e))
330}
331
332/// Render an error and its full `source()` chain as a single string, so the
333/// complete causal context survives type-erased `Box<dyn Error>` boundaries
334/// (whose `Display` only shows the outermost message).
335pub fn format_error_chain(e: &dyn std::error::Error) -> String {
336    let mut message = e.to_string();
337    let mut source = e.source();
338    while let Some(cause) = source {
339        message.push_str(&format!(": {}", cause));
340        source = cause.source();
341    }
342    message
343}
344
345// ---------------------------------------------------------------------------
346// PyO3 boundary conversion.
347//
348// We can't `impl From<Error> for PyErr` because of orphan rules (both types
349// are foreign). Instead, expose a free function `error_to_pyerr` that callers
350// invoke explicitly at the FFI boundary, and which performs structural
351// downcasting to pick an appropriate Python exception class. The named fields
352// on the error are appended to the message so the Python caller still sees
353// the structured context (this is the only place we stringify).
354// ---------------------------------------------------------------------------
355
356/// Convert a crate [`Error`] into a [`PyErr`] suitable for returning across
357/// the PyO3 boundary. Performs structural downcasting so e.g.
358/// [`SyntaxError`] becomes `PySyntaxError` and [`UnsupportedFeature`] becomes
359/// `PyNotImplementedError`.
360pub fn error_to_pyerr(err: Error) -> PyErr {
361    use pyo3::exceptions::*;
362
363    let display = format!("{:#}", err);
364
365    if err.is::<ParseError>() || err.is::<SyntaxError>() {
366        PySyntaxError::new_err(display)
367    } else if err.is::<TypeError>() {
368        PyTypeError::new_err(display)
369    } else if err.is::<UnsupportedFeature>() {
370        PyNotImplementedError::new_err(display)
371    } else if err.is::<CodeGenError>() {
372        PyRuntimeError::new_err(display)
373    } else {
374        PyRuntimeError::new_err(display)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_unknown_type_downcast() {
384        let err: Error = err_from(UnknownType("SomeUnknownType".to_string()));
385        let display = format!("{}", err);
386        assert!(display.contains("SomeUnknownType"));
387        let inner = err.downcast_ref::<UnknownType>().expect("should downcast");
388        assert_eq!(inner.0, "SomeUnknownType");
389    }
390
391    #[test]
392    fn test_parsing_error_carries_named_fields() {
393        let loc = SourceLocation::new("foo.py");
394        let err = parsing_error(loc.clone(), "boom", "fix it");
395
396        // Typed payload is downcast-able.
397        let typed = err.downcast_ref::<ParseError>().expect("downcast");
398        assert_eq!(typed.location, loc);
399
400        // Named fields are preserved (no premature String-ification of the
401        // logical fields — they're stored as separate keys).
402        assert_eq!(err.get_field("message"), Some("boom"));
403        assert_eq!(err.get_field("help"), Some("fix it"));
404        assert!(err.get_field("location").is_some());
405    }
406
407    #[test]
408    fn test_unsupported_feature_named_fields() {
409        let loc = SourceLocation::new("bar.py");
410        let err = unsupported_feature(loc, "match-statement", "use if/elif chains");
411        assert_eq!(err.get_field("feature"), Some("match-statement"));
412        assert_eq!(err.get_field("help"), Some("use if/elif chains"));
413        let typed = err.downcast_ref::<UnsupportedFeature>().expect("downcast");
414        assert_eq!(typed.feature, "match-statement");
415    }
416
417    #[test]
418    fn test_type_error_carries_expected_and_found() {
419        let loc = SourceLocation::new("baz.py");
420        let err = type_error(loc, "mismatch", "int", "str", "convert it");
421        assert_eq!(err.get_field("expected"), Some("int"));
422        assert_eq!(err.get_field("found"), Some("str"));
423        assert!(err.downcast_ref::<TypeError>().is_some());
424    }
425
426    #[test]
427    fn test_into_err_adapter() {
428        let pyresult: std::result::Result<(), std::io::Error> =
429            Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"));
430        let r: Result<()> = IntoErr::into_err(pyresult);
431        let e = r.expect_err("must be err");
432        let display = format!("{}", e);
433        assert!(display.contains("missing"));
434    }
435
436    #[test]
437    fn test_result_ok_passthrough() {
438        let r: Result<i32> = Ok(42);
439        assert_eq!(r.unwrap(), 42);
440    }
441}