diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-08 22:14:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-08 22:14:12 +0900 |
| commit | f4cad2123b2af0de72bda4ce039e16e74f163f4e (patch) | |
| tree | 21803308c5ff41e23c9d3b117433eea16b4ff663 /crates/shirabe/src/console/application.rs | |
| parent | 0209f63210e5b547b5c6b73367bb80ea86c255ec (diff) | |
| download | php-shirabe-f4cad2123b2af0de72bda4ce039e16e74f163f4e.tar.gz php-shirabe-f4cad2123b2af0de72bda4ce039e16e74f163f4e.tar.zst php-shirabe-f4cad2123b2af0de72bda4ce039e16e74f163f4e.zip | |
feat(php-shim): give ported exceptions PHP's class hierarchy
Ported exceptions were flat structs reached with `downcast_ref`, so
Composer's `catch (\RuntimeException $e)` only matched the exact leaf
type and `get_class($e)` had nothing to report. Each exception now
embeds an instance of the class it extends and travels inside an
`AnyThrowable`; `Catch::catch`/`catch_mut` walk that chain, and
`PhpClass::php_class_name` yields the PHP FQCN.
Dropping the `std::error::Error` impls from the exception types leaves
`AnyThrowable` as the only route into an `anyhow::Error`, so the walk
cannot be bypassed. A `no_exception_downcast` linter catches the
`downcast::<X>()` calls that would now silently answer `None`.
Three sites change behavior as a result: the `TransportException`
exit-code override reaches `MaxFileSizeExceededException`, the
`catch (\LogicException)` in findSimilar() reaches its subclasses, and
rendered exception titles carry the real class name rather than a
guess. `get_class_err()` is no longer a `todo!()`, which re-enables
FilesystemRepositoryTest::testCorruptedRepositoryFile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src/console/application.rs')
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 260 |
1 files changed, 65 insertions, 195 deletions
diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 96f80d9b..16a67b72 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -93,6 +93,7 @@ use shirabe_external_packages::symfony::console::style::style_interface::StyleIn use shirabe_external_packages::symfony::console::style::symfony_style::SymfonyStyle; use shirabe_external_packages::symfony::console::terminal::Terminal; use shirabe_external_packages::symfony::process::exception::ProcessTimedOutException; +use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ LogicException as ShimLogicException, PHP_VERSION, PHP_VERSION_ID, PhpMixed, RuntimeException, bin2hex, chdir, date_default_timezone_get, date_default_timezone_set, defined, dirname, @@ -232,13 +233,10 @@ impl Application { if let Some(ref wd) = working_dir && !is_dir(wd) { - return Err(RuntimeException { - message: format!( - "Invalid working directory specified, {} does not exist.", - wd - ), - code: 0, - } + return Err(RuntimeException::new(format!( + "Invalid working directory specified, {} does not exist.", + wd + )) .into()); } @@ -250,8 +248,8 @@ impl Application { exception: &anyhow::Error, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, ) { - let is_logic_or_error = exception.downcast_ref::<ShimLogicException>().is_some(); - if is_logic_or_error + if (exception.is_class::<ShimLogicException>() + || exception.is_instanceof::<shirabe_php_shim::Error>()) && output.borrow().get_verbosity() < output_interface::VERBOSITY_VERBOSE { output @@ -308,7 +306,7 @@ impl Application { } let message = exception.to_string(); - if exception.downcast_ref::<TransportException>().is_some() + if exception.is_instanceof::<TransportException>() && str_contains(&message, "Unable to use a proxy") { io.write_error3( @@ -320,7 +318,7 @@ impl Application { } if Platform::is_windows() - && exception.downcast_ref::<TransportException>().is_some() + && exception.is_instanceof::<TransportException>() && str_contains(&message, "unable to get local issuer certificate") { let avast_detect = glob("C:\\Program Files\\Avast*"); @@ -359,10 +357,7 @@ impl Application { io.write_error3("<error>Check https://getcomposer.org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details</error>", true, io_interface::QUIET); } - if exception - .downcast_ref::<ProcessTimedOutException>() - .is_some() - { + if exception.is_instanceof::<ProcessTimedOutException>() { io.write_error3( "<error>The following exception is caused by a process timeout</error>", true, @@ -376,9 +371,7 @@ impl Application { && !self.io.is_interactive() { io.write_error3("<error>Plugins have been disabled automatically as you are running as root, this may be the cause of the following exception. See also https://getcomposer.org/root</error>", true, io_interface::QUIET); - } else if exception - .downcast_ref::<CommandNotFoundException>() - .is_some() + } else if exception.is_instanceof::<CommandNotFoundException>() && self.get_disable_plugins_by_default() { io.write_error3("<error>Plugins have been disabled, which may be why some commands are missing, unless you made a typo</error>", true, io_interface::QUIET); @@ -416,9 +409,7 @@ impl Application { match Factory::create(io_for_factory, None, disable_plugins_enum, disable_scripts) { Ok(c) => self.composer = Some(c.upcast()), Err(e) => { - if e.downcast_ref::<shirabe_php_shim::InvalidArgumentException>() - .is_some() - { + if e.is_instanceof::<shirabe_php_shim::InvalidArgumentException>() { if required { self.io.write_error(&e.to_string()); if self.are_exceptions_caught() { @@ -429,11 +420,11 @@ impl Application { } return Err(e); } - } else if e.downcast_ref::<JsonValidationException>().is_some() - || e.downcast_ref::<RuntimeException>().is_some() + } else if e.is_instanceof::<JsonValidationException>() + || e.is_instanceof::<RuntimeException>() // PHP's `catch (RuntimeException)` also catches subclasses; // NoSslException is the one Factory::create raises. - || e.downcast_ref::<NoSslException>().is_some() + || e.is_instanceof::<NoSslException>() { if required { return Err(e); @@ -523,7 +514,7 @@ impl Application { Ok(()) => {} Err(e) => { // Errors must be ignored, full binding/validation happens later when the command is known. - if !is_exception_interface(&e) { + if !throwable_is_exception_interface(e.as_ref()) { return Err(e); } } @@ -712,10 +703,7 @@ impl Application { pub fn get_signal_registry(&self) -> anyhow::Result<&SignalRegistry> { match &self.signal_registry { - None => Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { - message: "Signals are not supported. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), - code: 0, - }) + None => Err(ConsoleRuntimeException::new("Signals are not supported. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string()) .into()), Some(signal_registry) => Ok(signal_registry), } @@ -988,17 +976,12 @@ impl Application { message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); } - return Err(NamespaceNotFoundException(CommandNotFoundException::new( - message, - alternatives, - 0, - )) - .into()); + return Err(NamespaceNotFoundException::new(message, alternatives, 0).into()); } let exact = namespaces.iter().any(|n| n == namespace); if namespaces.len() > 1 && !exact { - return Err(NamespaceNotFoundException(CommandNotFoundException::new( + return Err(NamespaceNotFoundException::new( format!( "The namespace \"{}\" is ambiguous.\nDid you mean one of these?\n{}.", namespace, @@ -1006,7 +989,7 @@ impl Application { ), namespaces, 0, - )) + ) .into()); } @@ -1936,13 +1919,10 @@ impl ApplicationHandle { command.borrow().get_definition(); if command.borrow().get_name().is_none() { - return Err(ConsoleLogicException(shirabe_php_shim::LogicException { - message: format!( - "The command defined in \"{}\" cannot have an empty name.", - shirabe_php_shim::get_debug_type_obj(&command), - ), - code: 0, - }) + return Err(ConsoleLogicException::new(format!( + "The command defined in \"{}\" cannot have an empty name.", + shirabe_php_shim::get_debug_type_obj(&command), + )) .into()); } @@ -2057,7 +2037,7 @@ impl ApplicationHandle { command_name = cmd.borrow().get_name(); } Err(e) => { - if e.downcast_ref::<CommandNotFoundException>().is_some() { + if e.is_instanceof::<CommandNotFoundException>() { // we'll check command validity again later after plugins are loaded command_name = None; } @@ -2223,9 +2203,9 @@ impl ApplicationHandle { })() { Ok(_) => {} Err(e) => { - if e.downcast_ref::<NoSslException>().is_some() { + if e.is_instanceof::<NoSslException>() { // suppress these as they are not relevant at this point - } else if let Some(pe) = e.downcast_ref::<ParsingException>() { + } else if let Some(pe) = e.catch::<ParsingException>() { let details = pe.get_details(); let file = realpath(Factory::get_composer_file().unwrap_or_default()); @@ -2233,7 +2213,7 @@ impl ApplicationHandle { let line = details.line; let mut ghe = GithubActionError::new(io.clone()); - ghe.emit(&pe.message, file.as_deref(), line); + ghe.emit(pe.get_message(), file.as_deref(), line); return Err(e); } else { @@ -2542,7 +2522,7 @@ impl ApplicationHandle { let outcome = match result_outcome { Ok(r) => Ok(r), - Err(e) => { + Err(mut e) => { // PHP's `exit` bypasses parent::doRun()'s catch entirely; re-raise it untouched so // the GitHub Actions annotation and error hints below are skipped. if e.downcast_ref::<shirabe_php_shim::ExitException>() @@ -2550,7 +2530,7 @@ impl ApplicationHandle { { return Err(e); } - if let Some(see) = e.downcast_ref::<ScriptExecutionException>() { + if let Some(see) = e.catch::<ScriptExecutionException>() { if application.borrow().get_disable_plugins_by_default() && application.borrow().is_running_as_root() && !io.is_interactive() @@ -2572,15 +2552,9 @@ impl ApplicationHandle { // override TransportException's code for the purpose of parent::run() using it as process exit code // as http error codes are all beyond the 255 range of permitted exit codes - // TODO(phase-c): PHP's `instanceof TransportException` also matches the subclass - // MaxFileSizeExceededException, which is a newtype here and is not matched by this downcast. - let e = match e.downcast::<TransportException>() { - Ok(mut e) => { - e.code = Installer::ERROR_TRANSPORT_EXCEPTION; - anyhow::Error::new(e) - } - Err(e) => e, - }; + if let Some(te) = e.catch_mut::<TransportException>() { + te.set_code(Installer::ERROR_TRANSPORT_EXCEPTION); + } Err(e) } @@ -2691,9 +2665,7 @@ impl ApplicationHandle { // $exitCode = $e->getCode(); // is_numeric($exitCode) ? max(1, (int) $exitCode) : 1 - // TODO(phase-c): anyhow::Error has no PHP-style getCode(); the exit code derived - // from the exception's `code` field needs the downcast strategy decided. - let exit_code = shirabe_php_shim::php_exception_get_code(&e); + let exit_code = throwable_get_code(e.as_ref()) as i32; if shirabe_php_shim::is_numeric_string(&exit_code.to_string()) { if exit_code <= 0 { 1 } else { exit_code } } else { @@ -2735,7 +2707,7 @@ impl ApplicationHandle { Ok(()) => {} Err(e) => { // Errors must be ignored, full binding/validation happens later when the command is known. - if !is_exception_interface(&e) { + if !throwable_is_exception_interface(e.as_ref()) { return Err(e); } } @@ -2814,8 +2786,9 @@ impl ApplicationHandle { Err(e) => { // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) - let alternatives: Option<Vec<String>> = downcast_command_not_found(&e) - .filter(|_| !is_namespace_not_found(&e)) + let alternatives: Option<Vec<String>> = e + .catch::<CommandNotFoundException>() + .filter(|_| !e.is_instanceof::<NamespaceNotFoundException>()) .map(|cnf| cnf.get_alternatives().clone()); let single_alternative = match &alternatives { @@ -2907,10 +2880,7 @@ impl ApplicationHandle { if !command_signals.is_empty() { if application.borrow().signal_registry.is_none() { - return Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { - message: "Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), - code: 0, - }) + return Err(ConsoleRuntimeException::new("Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string()) .into()); } @@ -2994,133 +2964,35 @@ impl BaseApplication for Application { } } -/// Helper mirroring PHP's `$e instanceof ExceptionInterface`. -fn is_exception_interface(e: &anyhow::Error) -> bool { - // anyhow::Error stores concrete error types; enumerate the console exceptions - // that implement ExceptionInterface (PHP's `$e instanceof ExceptionInterface`). - e.downcast_ref::<CommandNotFoundException>().is_some() - || e.downcast_ref::<NamespaceNotFoundException>().is_some() - || e.downcast_ref::<ConsoleLogicException>().is_some() - || e.downcast_ref::<ConsoleRuntimeException>().is_some() - || e.downcast_ref::<ConsoleInvalidArgumentException>() - .is_some() - || e.downcast_ref::<InvalidOptionException>().is_some() - || e.downcast_ref::<MissingInputException>().is_some() -} - -/// `is_exception_interface` for a node of the `anyhow::Error` source chain (`&dyn Error`), used -/// while walking the getPrevious() chain in `do_render_throwable`. +/// PHP's `$e instanceof ExceptionInterface`, enumerating the console exceptions that implement it. +// TODO(plugin): a plugin can throw an exception class of its own that implements +// ExceptionInterface, and no enumeration on this side can name it. Answering that needs the +// interfaces a thrown exception implements, not just its superclasses. fn throwable_is_exception_interface(e: &(dyn std::error::Error + 'static)) -> bool { - e.downcast_ref::<CommandNotFoundException>().is_some() - || e.downcast_ref::<NamespaceNotFoundException>().is_some() - || e.downcast_ref::<ConsoleLogicException>().is_some() - || e.downcast_ref::<ConsoleRuntimeException>().is_some() - || e.downcast_ref::<ConsoleInvalidArgumentException>() - .is_some() - || e.downcast_ref::<InvalidOptionException>().is_some() - || e.downcast_ref::<MissingInputException>().is_some() + shirabe_php_shim::AnyThrowable::of(e).is_some_and(|e| { + e.is_instanceof::<CommandNotFoundException>() + || e.is_instanceof::<NamespaceNotFoundException>() + || e.is_instanceof::<ConsoleLogicException>() + || e.is_instanceof::<ConsoleRuntimeException>() + || e.is_instanceof::<ConsoleInvalidArgumentException>() + || e.is_instanceof::<InvalidOptionException>() + || e.is_instanceof::<MissingInputException>() + }) } -/// PHP's `$e->getCode()` for a node of the source chain. Enumerates the flat standard exception -/// structs that carry a `code`; everything else defaults to PHP's 0. +/// PHP's `$e->getCode()` for a node of the source chain; everything the port does not recognize as +/// an exception defaults to PHP's 0. fn throwable_get_code(e: &(dyn std::error::Error + 'static)) -> i64 { - if let Some(e) = e.downcast_ref::<shirabe_php_shim::Exception>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::RuntimeException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::UnexpectedValueException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::InvalidArgumentException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::TypeError>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::LogicException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::BadMethodCallException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::OutOfBoundsException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::ErrorException>() { - return e.code; - } - if let Some(e) = e.downcast_ref::<shirabe_php_shim::PharException>() { - return e.code; - } - 0 + shirabe_php_shim::AnyThrowable::of(e).map_or(0, shirabe_php_shim::AnyThrowable::get_code) } /// PHP's `get_debug_type($e)` for the title line, reached only when the message is empty or output -/// is verbose. PHP returns the exception's fully-qualified class name; Rust has no runtime FQCN, so -/// this maps the enumerable exception types to their PHP class names and falls back to `Exception`. -/// TODO(phase-c): the fully-qualified name (e.g. `Composer\...`) cannot be reproduced faithfully. +/// is verbose. fn throwable_debug_type(e: &(dyn std::error::Error + 'static)) -> String { - let name = if e - .downcast_ref::<shirabe_php_shim::RuntimeException>() - .is_some() - { - "RuntimeException" - } else if e - .downcast_ref::<shirabe_php_shim::UnexpectedValueException>() - .is_some() - { - "UnexpectedValueException" - } else if e - .downcast_ref::<shirabe_php_shim::InvalidArgumentException>() - .is_some() - { - "InvalidArgumentException" - } else if e.downcast_ref::<shirabe_php_shim::TypeError>().is_some() { - "TypeError" - } else if e - .downcast_ref::<shirabe_php_shim::LogicException>() - .is_some() - { - "LogicException" - } else if e - .downcast_ref::<shirabe_php_shim::BadMethodCallException>() - .is_some() - { - "BadMethodCallException" - } else if e - .downcast_ref::<shirabe_php_shim::OutOfBoundsException>() - .is_some() - { - "OutOfBoundsException" - } else if e - .downcast_ref::<shirabe_php_shim::ErrorException>() - .is_some() - { - "ErrorException" - } else if e - .downcast_ref::<shirabe_php_shim::PharException>() - .is_some() - { - "PharException" - } else { - "Exception" - }; - name.to_string() -} - -/// Helper mirroring PHP's `$e instanceof CommandNotFoundException`. -fn downcast_command_not_found(e: &anyhow::Error) -> Option<&CommandNotFoundException> { - if let Some(cnf) = e.downcast_ref::<CommandNotFoundException>() { - return Some(cnf); - } - e.downcast_ref::<NamespaceNotFoundException>().map(|n| &n.0) -} - -/// Helper mirroring PHP's `$e instanceof NamespaceNotFoundException`. -fn is_namespace_not_found(e: &anyhow::Error) -> bool { - e.downcast_ref::<NamespaceNotFoundException>().is_some() + shirabe_php_shim::AnyThrowable::of(e).map_or_else( + || "Exception".to_string(), + shirabe_php_shim::PhpClass::php_class_name, + ) } /// Borrows the shared input as a mutable `dyn InputInterface` for passing to @@ -3161,13 +3033,11 @@ pub(crate) fn register_worker_reverse_application( pub(crate) fn run_worker_reverse_command(name: &str, input_line: &str) -> anyhow::Result<i64> { let application = WORKER_REVERSE_APPLICATION .with(|slot| slot.borrow().as_ref().and_then(std::rc::Weak::upgrade)) - .ok_or_else(|| { - anyhow::anyhow!(shirabe_php_shim::RuntimeException { - message: format!( - "cannot run command {name}: no application is registered for worker callbacks" - ), - code: 0, - }) + .ok_or_else(|| -> anyhow::Error { + shirabe_php_shim::RuntimeException::new(format!( + "cannot run command {name}: no application is registered for worker callbacks" + )) + .into() })?; let command = application.borrow_mut().find(name)?; // PHP's `(string) $input` omits the command name when the input was built without an |
