diff options
Diffstat (limited to 'crates/shirabe-php-shim/src/exception.rs')
| -rw-r--r-- | crates/shirabe-php-shim/src/exception.rs | 541 |
1 files changed, 444 insertions, 97 deletions
diff --git a/crates/shirabe-php-shim/src/exception.rs b/crates/shirabe-php-shim/src/exception.rs index c57a3451..803d5b64 100644 --- a/crates/shirabe-php-shim/src/exception.rs +++ b/crates/shirabe-php-shim/src/exception.rs @@ -1,133 +1,416 @@ -use crate::PharException; +use crate::PhpClass; -#[derive(Debug)] -pub struct Exception { - pub message: String, - pub code: i64, +/// The fields a PHP `\Throwable` carries: its message and code, and the exception it wraps. Ported +/// exception types embed this, either directly or through the parent exception they extend. +#[derive(Debug, Clone)] +pub struct ThrowableFields { + message: String, + code: i64, + previous: Option<std::sync::Arc<AnyThrowable>>, } -impl std::fmt::Display for Exception { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) +impl ThrowableFields { + pub fn get_message(&self) -> &str { + &self.message } -} -impl std::error::Error for Exception {} + pub fn get_code(&self) -> i64 { + self.code + } -#[derive(Debug)] -pub struct RuntimeException { - pub message: String, - pub code: i64, + /// PHP's `code` property is protected with no setter; Composer writes it through reflection. + pub fn set_code(&mut self, code: i64) { + self.code = code; + } + + pub fn get_previous(&self) -> Option<&AnyThrowable> { + self.previous.as_deref() + } } -impl std::fmt::Display for RuntimeException { +impl std::fmt::Display for ThrowableFields { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } -impl std::error::Error for RuntimeException {} +crate::impl_php_class!(ThrowableFields, r"Throwable"); -#[derive(Debug)] -pub struct UnexpectedValueException { - pub message: String, - pub code: i64, +/// A ported PHP exception class, seen as the object PHP `throw`s: its [`ThrowableFields`], its +/// concrete Rust type, and the instance of its parent class it embeds. +/// [`impl_php_exception!`] implements this for every ported exception. +/// +/// Every ported exception embeds an instance of the class it extends, so [`Self::parent`] walks +/// exactly PHP's chain of superclasses and bottoms out at the [`ThrowableFields`]. +pub trait Throwable: + PhpClass + std::fmt::Debug + std::fmt::Display + Send + Sync + 'static +{ + fn fields(&self) -> &ThrowableFields; + fn as_any(&self) -> &(dyn std::any::Any + 'static); + fn as_any_mut(&mut self) -> &mut (dyn std::any::Any + 'static); + fn parent(&self) -> Option<&dyn Throwable>; + fn parent_mut(&mut self) -> Option<&mut dyn Throwable>; } -impl std::fmt::Display for UnexpectedValueException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) +impl Throwable for ThrowableFields { + fn fields(&self) -> &ThrowableFields { + self } -} -impl std::error::Error for UnexpectedValueException {} + fn as_any(&self) -> &(dyn std::any::Any + 'static) { + self + } -#[derive(Debug)] -pub struct InvalidArgumentException { - pub message: String, - pub code: i64, -} + fn as_any_mut(&mut self) -> &mut (dyn std::any::Any + 'static) { + self + } -impl std::fmt::Display for InvalidArgumentException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) + fn parent(&self) -> Option<&dyn Throwable> { + None } -} -impl std::error::Error for InvalidArgumentException {} + fn parent_mut(&mut self) -> Option<&mut dyn Throwable> { + None + } +} +/// The form a thrown PHP exception takes while it travels as a Rust error. Ported exception types +/// deliberately do not implement [`std::error::Error`], so this box is the only way one reaches an +/// `anyhow::Error`: an error either carries an `AnyThrowable`, and PHP would see a `\Throwable`, +/// or it does not, and PHP would see nothing catchable. +/// +/// This is what makes `catch (\RuntimeException $e)` portable: [`Catch::catch`] answers over the +/// whole class hierarchy, rather than over the Rust type, which is a leaf of it. #[derive(Debug)] -pub struct TypeError { - pub message: String, - pub code: i64, +pub struct AnyThrowable(Box<dyn Throwable>); + +impl AnyThrowable { + pub fn new(exception: impl Throwable) -> Self { + Self(Box::new(exception)) + } + + /// The exception a Rust error carries, or `None` if it carries none. + // TODO(phase-c): this matches only an error that *is* the exception, where [`Catch`]'s + // `anyhow::Error` impl also sees one behind an `anyhow::Context` layer. Nothing in the port + // adds context to an error yet, so an exception wrapped that way would go silently unseen. + pub fn of<'e>(error: &'e (dyn std::error::Error + 'static)) -> Option<&'e Self> { + error.downcast_ref::<Self>() + } + + /// PHP's `catch (T $e)`: the exception seen as an instance of `T`, or `None` if it is not one. + /// A subclass answers through the instance of `T` it embeds, so `T`'s own state is reachable + /// the way PHP reaches an inherited property. + fn downcast_ref<T: Throwable>(&self) -> Option<&T> { + let mut class: &dyn Throwable = &*self.0; + loop { + if let Some(instance) = class.as_any().downcast_ref::<T>() { + return Some(instance); + } + class = class.parent()?; + } + } + + /// [`AnyThrowable::downcast_ref`] for a caught exception that is about to be mutated, the way + /// PHP writes to a property of the object it caught. + fn downcast_mut<T: Throwable>(&mut self) -> Option<&mut T> { + let mut superclasses = 0; + let mut class: &dyn Throwable = &*self.0; + while !class.as_any().is::<T>() { + class = class.parent()?; + superclasses += 1; + } + + let mut class: &mut dyn Throwable = &mut *self.0; + for _ in 0..superclasses { + class = class.parent_mut().expect("walked immutably just above"); + } + class.as_any_mut().downcast_mut::<T>() + } + + pub fn get_message(&self) -> &str { + self.0.fields().get_message() + } + + pub fn get_code(&self) -> i64 { + self.0.fields().get_code() + } + + pub fn get_previous(&self) -> Option<&AnyThrowable> { + self.0.fields().get_previous() + } } -impl std::fmt::Display for TypeError { +impl std::fmt::Display for AnyThrowable { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) + std::fmt::Display::fmt(&self.0, f) } } -impl std::error::Error for TypeError {} +impl std::error::Error for AnyThrowable { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.get_previous() + .map(|previous| previous as &(dyn std::error::Error + 'static)) + } +} -#[derive(Debug)] -pub struct LogicException { - pub message: String, - pub code: i64, +impl PhpClass for AnyThrowable { + fn php_class_name(&self) -> String { + self.0.php_class_name() + } } -impl std::fmt::Display for LogicException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) +/// PHP's `catch` applied to a Rust error. +pub trait Catch { + /// The exception the error carries, seen as an instance of `T`, or `None` if it carries no + /// exception or one of an unrelated class. + fn catch<T: Throwable>(&self) -> Option<&T>; + + /// [`Catch::catch`] for a caught exception that is about to be mutated, the way PHP writes to + /// a property of the object it caught. + fn catch_mut<T: Throwable>(&mut self) -> Option<&mut T>; + + /// PHP's `$e instanceof T`. + fn is_instanceof<T: Throwable>(&self) -> bool { + self.catch::<T>().is_some() } + + /// PHP's `get_class($e) === T::class`: the class the exception was thrown as, rather than + /// [`Catch::is_instanceof`]'s walk over its superclasses. + fn is_class<T: Throwable>(&self) -> bool; } -impl std::error::Error for LogicException {} +impl Catch for anyhow::Error { + fn catch<T: Throwable>(&self) -> Option<&T> { + self.downcast_ref::<AnyThrowable>()?.downcast_ref::<T>() + } -#[derive(Debug)] -pub struct BadMethodCallException { - pub message: String, - pub code: i64, + fn catch_mut<T: Throwable>(&mut self) -> Option<&mut T> { + self.downcast_mut::<AnyThrowable>()?.downcast_mut::<T>() + } + + fn is_class<T: Throwable>(&self) -> bool { + self.downcast_ref::<AnyThrowable>() + .is_some_and(|e| e.is_class::<T>()) + } } -impl std::fmt::Display for BadMethodCallException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) +impl Catch for AnyThrowable { + fn catch<T: Throwable>(&self) -> Option<&T> { + self.downcast_ref::<T>() + } + + fn catch_mut<T: Throwable>(&mut self) -> Option<&mut T> { + self.downcast_mut::<T>() + } + + fn is_class<T: Throwable>(&self) -> bool { + self.0.as_any().is::<T>() } } -impl std::error::Error for BadMethodCallException {} +/// Implements the `\Throwable` surface for a ported exception type, given the field holding the +/// fields it inherits — a [`ThrowableFields`] for a type that extends a PHP built-in directly, or +/// the embedded parent exception otherwise — and the fully-qualified name of the PHP class. +/// +/// ```ignore +/// impl_php_exception!(SolverBugException, 0, r"Composer\DependencyResolver\SolverBugException"); +/// ``` +/// +/// The type is deliberately left without a [`std::error::Error`] impl, so that the only route from +/// it to an `anyhow::Error` is the [`AnyThrowable`] this generates a conversion to. +#[macro_export] +macro_rules! impl_php_exception { + ($ty:ty, $field:tt, $class_name:expr) => { + $crate::impl_php_exception!(@accessors $ty, $field, $class_name); -#[derive(Debug)] -pub struct OutOfBoundsException { - pub message: String, - pub code: i64, + impl $crate::Throwable for $ty { + fn fields(&self) -> &$crate::ThrowableFields { + $crate::Throwable::fields(&self.$field) + } + + fn as_any(&self) -> &(dyn std::any::Any + 'static) { + self + } + + fn as_any_mut(&mut self) -> &mut (dyn std::any::Any + 'static) { + self + } + + fn parent(&self) -> Option<&dyn $crate::Throwable> { + Some(&self.$field) + } + + fn parent_mut(&mut self) -> Option<&mut dyn $crate::Throwable> { + Some(&mut self.$field) + } + } + + impl From<$ty> for $crate::AnyThrowable { + fn from(exception: $ty) -> Self { + $crate::AnyThrowable::new(exception) + } + } + + impl From<$ty> for ::anyhow::Error { + fn from(exception: $ty) -> Self { + ::anyhow::Error::new($crate::AnyThrowable::new(exception)) + } + } + }; + // For an exception the port cannot let travel as a Rust error, because its state is not + // `Send + Sync`. It gets the accessors but no [`Throwable`], so asking for it in a `catch` + // does not compile, rather than silently never matching. + ($ty:ty, $field:tt, $class_name:expr, !Send) => { + $crate::impl_php_exception!(@accessors $ty, $field, $class_name); + }; + (@accessors $ty:ty, $field:tt, $class_name:expr) => { + impl $ty { + pub fn get_message(&self) -> &str { + self.$field.get_message() + } + + pub fn get_code(&self) -> i64 { + self.$field.get_code() + } + + pub fn set_code(&mut self, code: i64) { + self.$field.set_code(code); + } + + pub fn get_previous(&self) -> Option<&$crate::AnyThrowable> { + self.$field.get_previous() + } + } + + impl std::fmt::Display for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.$field, f) + } + } + + impl $crate::PhpClass for $ty { + fn php_class_name(&self) -> String { + $class_name.to_string() + } + } + }; } -impl std::fmt::Display for OutOfBoundsException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } +/// Defines a PHP built-in exception class as a struct carrying nothing but the instance of the +/// class it extends, or the [`ThrowableFields`] itself for a class that extends nothing. +macro_rules! define_php_exception { + ($ty:ident, ThrowableFields, $class_name:expr) => { + define_php_exception!(@shared $ty, ThrowableFields, $class_name); + + impl $ty { + pub fn with_code_and_previous( + message: String, + code: i64, + previous: Option<std::sync::Arc<AnyThrowable>>, + ) -> Self { + Self { + inner: ThrowableFields { + message, + code, + previous, + }, + } + } + } + }; + ($ty:ident, $parent:ty, $class_name:expr) => { + define_php_exception!(@shared $ty, $parent, $class_name); + + impl $ty { + pub fn with_code_and_previous( + message: String, + code: i64, + previous: Option<std::sync::Arc<AnyThrowable>>, + ) -> Self { + Self { + inner: <$parent>::with_code_and_previous(message, code, previous), + } + } + } + }; + (@shared $ty:ident, $parent:ty, $class_name:expr) => { + #[derive(Debug, Clone)] + pub struct $ty { + inner: $parent, + } + + impl $ty { + pub fn new(message: String) -> Self { + Self::with_code_and_previous(message, 0, None) + } + + pub fn with_code(message: String, code: i64) -> Self { + Self::with_code_and_previous(message, code, None) + } + } + + crate::impl_php_exception!($ty, inner, $class_name); + }; } -impl std::error::Error for OutOfBoundsException {} +define_php_exception!(Exception, ThrowableFields, r"Exception"); +define_php_exception!(Error, ThrowableFields, r"Error"); +define_php_exception!(TypeError, Error, r"TypeError"); +define_php_exception!(RuntimeException, Exception, r"RuntimeException"); +define_php_exception!( + UnexpectedValueException, + RuntimeException, + r"UnexpectedValueException" +); +define_php_exception!( + OutOfBoundsException, + RuntimeException, + r"OutOfBoundsException" +); +define_php_exception!(LogicException, Exception, r"LogicException"); +define_php_exception!( + InvalidArgumentException, + LogicException, + r"InvalidArgumentException" +); +define_php_exception!( + BadFunctionCallException, + LogicException, + r"BadFunctionCallException" +); +define_php_exception!( + BadMethodCallException, + BadFunctionCallException, + r"BadMethodCallException" +); #[derive(Debug)] pub struct ErrorException { - pub message: String, - pub code: i64, + inner: Exception, pub severity: i64, pub filename: String, pub lineno: i64, } -impl std::fmt::Display for ErrorException { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) +impl ErrorException { + pub fn new( + message: String, + code: i64, + severity: i64, + filename: String, + lineno: i64, + previous: Option<std::sync::Arc<AnyThrowable>>, + ) -> Self { + Self { + inner: Exception::with_code_and_previous(message, code, previous), + severity, + filename, + lineno, + } } } -impl std::error::Error for ErrorException {} +crate::impl_php_exception!(ErrorException, inner, r"ErrorException"); /// Models PHP's `exit`/`die` language construct propagated as a recoverable error so the actual /// process termination happens at a single top-level site instead of deep in the call stack. @@ -148,39 +431,103 @@ impl std::fmt::Display for ExitException { impl std::error::Error for ExitException {} -pub fn php_exception_get_code(_error: &anyhow::Error) -> i32 { - // PHP's Throwable::getCode(). anyhow::Error carries the concrete exception type, so enumerate - // the flat standard exception structs and read their `code` field; everything else defaults to - // 0, matching PHP's default exception code. - if let Some(e) = _error.downcast_ref::<Exception>() { - return e.code as i32; +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Subclass { + inner: UnexpectedValueException, + detail: i64, } - if let Some(e) = _error.downcast_ref::<RuntimeException>() { - return e.code as i32; + + impl Subclass { + fn new(detail: i64) -> Self { + Self { + inner: UnexpectedValueException::new("boom".to_string()), + detail, + } + } } - if let Some(e) = _error.downcast_ref::<UnexpectedValueException>() { - return e.code as i32; + + crate::impl_php_exception!(Subclass, inner, r"Vendor\Subclass"); + + #[test] + fn catch_reaches_every_superclass() { + let error: anyhow::Error = Subclass::new(7).into(); + + assert_eq!(error.catch::<Subclass>().map(|e| e.detail), Some(7)); + assert!(error.catch::<UnexpectedValueException>().is_some()); + assert!(error.catch::<RuntimeException>().is_some()); + assert!(error.catch::<Exception>().is_some()); + assert!(error.catch::<ThrowableFields>().is_some()); } - if let Some(e) = _error.downcast_ref::<InvalidArgumentException>() { - return e.code as i32; + + #[test] + fn catch_reaches_no_sibling_or_subclass() { + let error: anyhow::Error = RuntimeException::new("boom".to_string()).into(); + + assert!(error.catch::<Subclass>().is_none()); + assert!(error.catch::<LogicException>().is_none()); + assert!(error.catch::<Error>().is_none()); } - if let Some(e) = _error.downcast_ref::<TypeError>() { - return e.code as i32; + + #[test] + fn an_error_is_not_an_exception() { + let error: anyhow::Error = TypeError::new("boom".to_string()).into(); + + assert!(error.catch::<Error>().is_some()); + assert!(error.catch::<ThrowableFields>().is_some()); + assert!(error.catch::<Exception>().is_none()); } - if let Some(e) = _error.downcast_ref::<LogicException>() { - return e.code as i32; + + #[test] + fn catch_reaches_nothing_in_a_plain_rust_error() { + let error = anyhow::anyhow!("boom"); + + assert!(error.catch::<ThrowableFields>().is_none()); } - if let Some(e) = _error.downcast_ref::<BadMethodCallException>() { - return e.code as i32; + + #[test] + fn is_class_reaches_no_superclass() { + let error: anyhow::Error = Subclass::new(7).into(); + + assert!(error.is_class::<Subclass>()); + assert!(error.is_instanceof::<UnexpectedValueException>()); + assert!(!error.is_class::<UnexpectedValueException>()); + assert!(!error.is_class::<ThrowableFields>()); } - if let Some(e) = _error.downcast_ref::<OutOfBoundsException>() { - return e.code as i32; + + #[test] + fn is_class_reaches_nothing_in_a_plain_rust_error() { + let error = anyhow::anyhow!("boom"); + + assert!(!error.is_class::<ThrowableFields>()); } - if let Some(e) = _error.downcast_ref::<ErrorException>() { - return e.code as i32; + + #[test] + fn catch_mut_writes_through_to_the_superclass_state() { + let mut error: anyhow::Error = Subclass::new(7).into(); + + error + .catch_mut::<UnexpectedValueException>() + .unwrap() + .set_code(42); + + assert_eq!(error.catch::<Subclass>().unwrap().get_code(), 42); } - if let Some(e) = _error.downcast_ref::<PharException>() { - return e.code as i32; + + #[test] + fn the_previous_exception_is_the_error_source() { + let previous = std::sync::Arc::new(AnyThrowable::new(RuntimeException::new( + "cause".to_string(), + ))); + let error: anyhow::Error = + Exception::with_code_and_previous("boom".to_string(), 0, Some(previous)).into(); + + let source = std::error::Error::source( + error.downcast_ref::<AnyThrowable>().unwrap() as &dyn std::error::Error + ); + assert_eq!(source.map(ToString::to_string), Some("cause".to_string())); } - 0 } |
