diff options
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/plugin/capability/capability.rs | 14 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/capability/command_provider.rs | 8 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/capable.rs | 7 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 292 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/plugin_manager.rs | 138 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/plugin_installer_test.rs | 122 |
6 files changed, 494 insertions, 87 deletions
diff --git a/crates/shirabe/src/plugin/capability/capability.rs b/crates/shirabe/src/plugin/capability/capability.rs index 1788d9a0..aa70ffa0 100644 --- a/crates/shirabe/src/plugin/capability/capability.rs +++ b/crates/shirabe/src/plugin/capability/capability.rs @@ -1,4 +1,14 @@ //! ref: composer/src/Composer/Plugin/Capability/Capability.php -// TODO(plugin): Marker interface for Plugin capabilities. Every new Capability which is added to the Plugin API must implement this interface. -pub trait Capability {} +use crate::plugin::capability::CommandProvider; + +/// Marker interface for Plugin capabilities. Every new Capability which is added to the +/// Plugin API must implement this interface. +/// +/// The accessor replaces PHP's `instanceof` downcast on a capability instance of unknown +/// concrete type (the way `PluginInterface::as_capable` does for plugins). +pub trait Capability { + fn as_command_provider(&self) -> Option<&dyn CommandProvider> { + None + } +} diff --git a/crates/shirabe/src/plugin/capability/command_provider.rs b/crates/shirabe/src/plugin/capability/command_provider.rs index 6d6f6cf1..2453860a 100644 --- a/crates/shirabe/src/plugin/capability/command_provider.rs +++ b/crates/shirabe/src/plugin/capability/command_provider.rs @@ -1,9 +1,13 @@ //! ref: composer/src/Composer/Plugin/Capability/CommandProvider.php -// TODO(plugin): Commands Provider Interface. Plugins implementing this capability provide a list of commands. use crate::command::BaseCommand; use crate::plugin::capability::Capability; +/// Commands Provider Interface. Plugins implementing this capability provide a list of +/// commands. +/// +/// The sole implementor is the PHP capability proxy (Composer itself never implements a +/// capability), so the method is fallible: the answer crosses the RPC boundary. pub trait CommandProvider: Capability { - fn get_commands(&self) -> Vec<Box<dyn BaseCommand>>; + fn get_commands(&self) -> anyhow::Result<Vec<Box<dyn BaseCommand>>>; } diff --git a/crates/shirabe/src/plugin/capable.rs b/crates/shirabe/src/plugin/capable.rs index 830a7dab..3d3b8e3b 100644 --- a/crates/shirabe/src/plugin/capable.rs +++ b/crates/shirabe/src/plugin/capable.rs @@ -1,9 +1,12 @@ //! ref: composer/src/Composer/Plugin/Capable.php use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; // The sole implementor is the PHP plugin proxy (Composer itself never implements Capable), so -// the trait is fallible: the answer crosses the RPC boundary. +// the trait is fallible: the answer crosses the RPC boundary. Values are PhpMixed, not String: +// PHP's getCapabilities() may return anything, and PluginManager validates only the queried +// key, so narrowing the type here would reject maps upstream Composer accepts. pub trait Capable { - fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, String>>; + fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, PhpMixed>>; } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 70061542..9cccb1af 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -6,22 +6,30 @@ //! (`$composer`, `$io`) live in the R table here. use crate::autoload::ClassLoader; +use crate::command::BaseCommand; use crate::composer::ComposerHandle; use crate::event_dispatcher::event_dispatcher::dispatch_event_method; -use crate::event_dispatcher::{EventInterface, EventSubscriberInterface, SubscribedEventEntry}; +use crate::event_dispatcher::{ + EventInterface, EventSubscriberInterface, SubscribedEventEntry, unwrap_php_result, +}; use crate::installer::InstallationManagerInterface; use crate::io::IOInterface; use crate::package::handle::AnyPackage; use crate::package::{DisplayMode, PackageInterfaceHandle}; +use crate::plugin::capability::{Capability, CommandProvider}; +use crate::plugin::capable::Capable; use crate::plugin::plugin_interface::PluginInterface; use crate::repository::{ InstalledArrayRepository, InstalledFilesystemRepository, RepositoryInterfaceHandle, RepositoryManagerInterface, }; use indexmap::IndexMap; +use shirabe_external_packages::symfony::console::command::command::Command; +use shirabe_external_packages::symfony::console::input::InputInterface; +use shirabe_external_packages::symfony::console::output::OutputInterface; use shirabe_php_rpc::{ - PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, call_php_method, - release_php_handle, + PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, + call_function_with_dispatcher, call_php_method, release_php_handle, }; use shirabe_php_shim::PhpMixed; @@ -576,23 +584,7 @@ impl PhpPluginProxy { composer: &ComposerHandle, io: &std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, ) -> anyhow::Result<()> { - let composer_rhandle = register_composer_entity(composer); - let io_rhandle = register_io_entity(io); - let io_class = io_stub_class(io)?; - let args = vec![ - PluginValue::RustHandle(RustObjHandle { - rhandle: composer_rhandle, - class: "Composer\\Composer".to_string(), - epoch: 0, - snapshot: None, - }), - PluginValue::RustHandle(RustObjHandle { - rhandle: io_rhandle, - class: io_class.to_string(), - epoch: 0, - snapshot: None, - }), - ]; + let args = vec![composer_handle_value(composer), io_handle_value(io)?]; let outcome = call_php_method( self.phandle, method, @@ -675,6 +667,18 @@ impl PluginInterface for PhpPluginProxy { } } + fn as_capable(&self) -> Option<&dyn Capable> { + if self + .implements + .iter() + .any(|interface| interface == "Composer\\Plugin\\Capable") + { + Some(self) + } else { + None + } + } + fn __as_php_plugin_proxy(&self) -> Option<&PhpPluginProxy> { Some(self) } @@ -712,6 +716,45 @@ impl EventSubscriberInterface for PhpPluginProxy { } } +impl Capable for PhpPluginProxy { + fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, PhpMixed>> { + let outcome = call_php_method( + self.phandle, + "getCapabilities", + Vec::new(), + Some(&mut PluginRpcDispatcher::default()), + )?; + let value = match outcome { + Ok(value) => value, + // TODO(plugin): the original exception class is collapsed to RuntimeException on + // this side of the boundary. + Err(throw) => { + return Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: throw.message, + code: throw.code, + })); + } + }; + // PHP: `(array) $plugin->getCapabilities()` — the interface declares no return type, + // so a non-array return is cast. A handle in the map (a plugin putting an object among + // its capability values) has no PhpMixed image and fails the conversion explicitly. + let entries = match value { + PluginValue::Null => IndexMap::new(), + PluginValue::Array(map) | PluginValue::Object(map) => map + .into_iter() + .map(|(k, v)| Ok((String::from_utf8_lossy(&k).into_owned(), v.to_php_mixed()?))) + .collect::<anyhow::Result<_>>()?, + PluginValue::List(items) => items + .into_iter() + .enumerate() + .map(|(i, v)| Ok((i.to_string(), v.to_php_mixed()?))) + .collect::<anyhow::Result<_>>()?, + scalar => IndexMap::from([("0".to_string(), scalar.to_php_mixed()?)]), + }; + Ok(entries) + } +} + /// Decodes a `getSubscribedEvents()` wire value into the three shapes `addSubscriber` /// distinguishes: `'method'`, `['method', priority]`, and `[['method', priority], ...]`. /// A shape outside these is an explicit error, never a silently dropped listener. @@ -798,3 +841,212 @@ impl Drop for PhpPluginProxy { let _ = release_php_handle(self.phandle); } } + +/// Wire value handing the shared Rust-side `$composer` to the child, interned in the R table. +pub fn composer_handle_value(composer: &ComposerHandle) -> PluginValue { + rust_handle_value(register_composer_entity(composer), "Composer\\Composer") +} + +/// Wire value handing the shared Rust-side `$io` to the child, interned in the R table. +pub fn io_handle_value( + io: &std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, +) -> anyhow::Result<PluginValue> { + let class = io_stub_class(io)?; + Ok(rust_handle_value(register_io_entity(io), class)) +} + +/// `is_a($obj, $class)` evaluated in the worker: the child's own class table answers, so +/// parent classes are covered (a `PhpObjHandle`'s `implements` lists interfaces only). +pub(crate) fn php_is_a(handle: &PhpObjHandle, class: &str) -> anyhow::Result<bool> { + let value = unwrap_php_result(call_function_with_dispatcher( + "is_a", + vec![ + PluginValue::PhpHandle(handle.clone()), + PluginValue::string(class), + ], + Some(&mut PluginRpcDispatcher::default()), + ))?; + Ok(matches!(value, PluginValue::Bool(true))) +} + +/// `Capability` adapter for a capability entity living in the PHP child process, for +/// capability interfaces that add no methods of their own (the plain +/// `Composer\Plugin\Capability\Capability` marker). +#[derive(Debug)] +pub struct PhpCapabilityProxy { + pub(crate) handle: PhpObjHandle, +} + +impl PhpCapabilityProxy { + pub(crate) fn new(handle: PhpObjHandle) -> Self { + Self { handle } + } +} + +impl Capability for PhpCapabilityProxy {} + +impl Drop for PhpCapabilityProxy { + fn drop(&mut self) { + let _ = release_php_handle(self.handle.phandle); + } +} + +/// `CommandProvider` adapter for a capability entity living in the PHP child process. +#[derive(Debug)] +pub struct PhpCommandProviderProxy { + handle: PhpObjHandle, +} + +impl PhpCommandProviderProxy { + pub(crate) fn new(handle: PhpObjHandle) -> Self { + Self { handle } + } +} + +impl Capability for PhpCommandProviderProxy { + fn as_command_provider(&self) -> Option<&dyn CommandProvider> { + Some(self) + } +} + +impl CommandProvider for PhpCommandProviderProxy { + fn get_commands(&self) -> anyhow::Result<Vec<Box<dyn BaseCommand>>> { + let value = unwrap_php_result(call_php_method( + self.handle.phandle, + "getCommands", + Vec::new(), + Some(&mut PluginRpcDispatcher::default()), + ))?; + // PHP's getCommands(): array is unenforceable on the wire, and a + // `Vec<Box<dyn BaseCommand>>` asserts every element up front, so the two checks + // Application::getPluginCommands performs on the raw value live here, with its + // messages. + let items = match value { + PluginValue::List(items) => items, + PluginValue::Array(map) => map.into_values().collect(), + _ => { + return Err(anyhow::anyhow!( + shirabe_php_shim::UnexpectedValueException { + message: format!( + "Plugin capability {} failed to return an array from getCommands", + self.handle.class + ), + code: 0, + } + )); + } + }; + let mut commands: Vec<Box<dyn BaseCommand>> = Vec::new(); + for item in items { + let command_handle = match item { + PluginValue::PhpHandle(handle) => handle, + _ => return Err(invalid_command_error(&self.handle)), + }; + if !php_is_a(&command_handle, "Composer\\Command\\BaseCommand")? { + return Err(invalid_command_error(&self.handle)); + } + commands.push(Box::new(PhpCommandProxy::new(command_handle)?)); + } + Ok(commands) + } +} + +fn invalid_command_error(capability: &PhpObjHandle) -> anyhow::Error { + anyhow::anyhow!(shirabe_php_shim::UnexpectedValueException { + message: format!( + "Plugin capability {} returned an invalid value, we expected an array of Composer\\Command\\BaseCommand objects", + capability.class + ), + code: 0, + }) +} + +impl Drop for PhpCommandProviderProxy { + fn drop(&mut self) { + let _ = release_php_handle(self.handle.phandle); + } +} + +/// `BaseCommand` adapter for a command entity living in the PHP child process. The Rust-side +/// command state mirrors the child's (the name is read back over RPC at construction, after +/// the PHP constructor ran `configure()`); running the command needs the PHP-side Symfony +/// Application and is an explicit error until that exists. +#[derive(Debug)] +pub struct PhpCommandProxy { + base_command_data: crate::command::BaseCommandData, + handle: PhpObjHandle, +} + +impl PhpCommandProxy { + pub(crate) fn new(handle: PhpObjHandle) -> anyhow::Result<Self> { + let data = crate::command::BaseCommandData::new(None); + let name = unwrap_php_result(call_php_method( + handle.phandle, + "getName", + Vec::new(), + Some(&mut PluginRpcDispatcher::default()), + ))?; + match name { + PluginValue::Null => {} + PluginValue::String(bytes) => { + Command::set_name(&data, &String::from_utf8_lossy(&bytes))?; + } + other => { + return Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "{}::getName() returned an unsupported shape over RPC: {other:?}", + handle.class + ), + code: 0, + })); + } + } + Ok(Self { + base_command_data: data, + handle, + }) + } +} + +impl Command for PhpCommandProxy { + fn execute( + &self, + _input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, + _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<i64> { + // TODO(plugin): executing a plugin-provided command requires the PHP-side Symfony + // Application; until then this is an explicit error, never a silent no-op. + Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "cannot execute plugin-provided command {} yet: running PHP commands is not supported", + self.handle.class + ), + code: 0, + })) + } + + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} + +impl BaseCommand for PhpCommandProxy { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data + } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); +} + +impl shirabe_php_shim::PhpClass for PhpCommandProxy { + fn php_class_name(&self) -> &'static str { + // TODO(plugin): PhpClass reports only &'static str, but this command's PHP class name + // is runtime data (`self.handle.class`); callers needing get_class() must read the + // handle instead. + panic!("PhpCommandProxy has no static PHP class name; read the handle's class instead") + } +} + +impl Drop for PhpCommandProxy { + fn drop(&mut self) { + let _ = release_php_handle(self.handle.phandle); + } +} diff --git a/crates/shirabe/src/plugin/plugin_manager.rs b/crates/shirabe/src/plugin/plugin_manager.rs index d720bcfe..9e52c01f 100644 --- a/crates/shirabe/src/plugin/plugin_manager.rs +++ b/crates/shirabe/src/plugin/plugin_manager.rs @@ -14,7 +14,9 @@ use crate::package::base_package::{self}; use crate::package::version::VersionParser; use crate::plugin::PluginBlockedException; use crate::plugin::capability::Capability; -use crate::plugin::php_plugin_proxy::{PhpPluginProxy, PluginRpcDispatcher}; +use crate::plugin::php_plugin_proxy::{ + PhpCapabilityProxy, PhpCommandProviderProxy, PhpPluginProxy, PluginRpcDispatcher, php_is_a, +}; use crate::plugin::plugin_interface::{self, PluginInterface}; use crate::repository::InstalledRepository; use crate::repository::RepositoryInterfaceHandle; @@ -25,9 +27,9 @@ use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::Preg; use shirabe_php_rpc::{PluginValue, call_function_with_dispatcher}; use shirabe_php_shim::{ - E_USER_DEPRECATED, PhpMixed, RuntimeException, UnexpectedValueException, array_key_exists, - dirname, file_get_contents, implode, ksort, php_regex, preg_quote, strrpos, strtr_array, - substr, trigger_error, trim, var_export_str, version_compare, + E_USER_DEPRECATED, PhpMixed, RuntimeException, UnexpectedValueException, dirname, empty, + file_get_contents, implode, ksort, php_regex, preg_quote, strrpos, strtr_array, substr, + trigger_error, trim, var_export, var_export_str, version_compare, }; use shirabe_semver::constraint::SimpleConstraint; @@ -966,36 +968,26 @@ impl PluginManager { let capabilities = capable.get_capabilities()?; // PHP: !empty($capabilities[$capability]) && is_string($capabilities[$capability]) && trim($capabilities[$capability]) - if let Some(s) = capabilities.get(capability) { + if let Some(value) = capabilities.get(capability) + && !empty(value) + && let PhpMixed::String(s) = value + { let trimmed = trim(s, Some(" \t\n\r\0\u{0B}")); - if !s.is_empty() && s != "0" && !trimmed.is_empty() { + // PHP evaluates trim(...) in boolean context: "" and "0" are falsy. + if !trimmed.is_empty() && trimmed != "0" { return Ok(Some(trimmed)); } } - // PHP: empty($capabilities[$capability]) — true for null, false, 0, "", "0", [], or missing key. - // In Rust the values are typed as String, so we only need to consider "", "0". - let cap_is_empty = match capabilities.get(capability) { - None => true, - Some(s) if s.is_empty() || s == "0" => true, - _ => false, - }; - if array_key_exists(capability, &capabilities) - && (cap_is_empty - || trim( - capabilities - .get(capability) - .map(|s| s.as_str()) - .unwrap_or(""), - Some(" \t\n\r\0\u{0B}"), - ) - .is_empty()) - { + // PHP: array_key_exists($capability, $capabilities) && (empty(...) || !is_string(...) + // || !trim(...)). Once the first branch has declined, a present key always fails one + // of the three disjuncts, so a present key unconditionally throws here. + if let Some(value) = capabilities.get(capability) { return Err(UnexpectedValueException { message: format!( "Plugin {} provided invalid capability class name(s), got {}", plugin.get_class_name(), - var_export_str(capabilities.get(capability).unwrap(), true) + var_export(value, true) ), code: 0, } @@ -1009,36 +1001,110 @@ impl PluginManager { &self, plugin: &dyn PluginInterface, capability_class_name: &str, - _ctor_args: IndexMap<String, PhpMixed>, + ctor_args: IndexMap<String, PluginValue>, ) -> anyhow::Result<Option<Box<dyn Capability>>> { - // TODO(plugin): instantiate plugin capability via runtime class lookup - let _capability_class = + let capability_class = match self.get_capability_implementation_class_name(plugin, capability_class_name)? { Some(c) => c, None => return Ok(None), }; - // PHP: requires class_exists / new $capabilityClass($ctorArgs); cannot be performed in Rust without a runtime registry. - Ok(None) + + // PHP: if (!class_exists($capabilityClass)) + let exists = unwrap_php_result(call_function_with_dispatcher( + "class_exists", + vec![PluginValue::string(capability_class.clone())], + Some(&mut PluginRpcDispatcher::default()), + ))?; + if !matches!(exists, PluginValue::Bool(true)) { + return Err(RuntimeException { + message: format!( + "Cannot instantiate Capability, as class {} from plugin {} does not exist.", + capability_class, + plugin.get_class_name() + ), + code: 0, + } + .into()); + } + + // PHP: $ctorArgs['plugin'] = $plugin; the capability constructor receives the plugin + // instance itself, so a plugin with no PHP-side entity cannot be represented. + let plugin_value = match plugin.__as_php_plugin_proxy() { + Some(proxy) => PluginValue::PhpHandle(shirabe_php_rpc::PhpObjHandle { + phandle: proxy.phandle, + class: proxy.class.clone(), + implements: proxy.implements.clone(), + }), + None => anyhow::bail!( + "cannot instantiate capability {capability_class}: plugin {} has no PHP-side entity to pass as $ctorArgs['plugin']", + plugin.get_class_name() + ), + }; + let mut ctor_args = ctor_args; + ctor_args.insert("plugin".to_string(), plugin_value); + let args_value = PluginValue::Array( + ctor_args + .into_iter() + .map(|(k, v)| (k.into_bytes(), v)) + .collect(), + ); + + // PHP: $capabilityObj = new $capabilityClass($ctorArgs); + let capability_obj = unwrap_php_result(shirabe_php_rpc::new_object( + &capability_class, + vec![args_value], + Some(&mut PluginRpcDispatcher::default()), + ))?; + let handle = match capability_obj { + PluginValue::PhpHandle(handle) => handle, + other => anyhow::bail!( + "worker returned a non-object for new {capability_class}(...): {other:?}" + ), + }; + + // PHP: if (!$capabilityObj instanceof Capability || !$capabilityObj instanceof $capabilityClassName) + if !php_is_a(&handle, "Composer\\Plugin\\Capability\\Capability")? + || !php_is_a(&handle, capability_class_name)? + { + return Err(RuntimeException { + message: format!( + "Class {capability_class} must implement both Composer\\Plugin\\Capability\\Capability and {capability_class_name}." + ), + code: 0, + } + .into()); + } + + match capability_class_name { + "Composer\\Plugin\\Capability\\CommandProvider" => { + Ok(Some(Box::new(PhpCommandProviderProxy::new(handle)))) + } + "Composer\\Plugin\\Capability\\Capability" => { + Ok(Some(Box::new(PhpCapabilityProxy::new(handle)))) + } + // A capability interface outside composer-plugin-api that the instanceof checks + // accepted (the plugin ships its own): no Rust adapter exists for its methods. + other => anyhow::bail!("capability {other} is recognized but not yet adapted"), + } } pub fn get_plugin_capabilities( &self, capability_class_name: &str, - ctor_args: IndexMap<String, PhpMixed>, - ) -> Vec<Box<dyn Capability>> { - // TODO(plugin): aggregate capabilities across all loaded plugins + ctor_args: IndexMap<String, PluginValue>, + ) -> anyhow::Result<Vec<Box<dyn Capability>>> { let mut capabilities: Vec<Box<dyn Capability>> = vec![]; for plugin in self.get_plugins() { - if let Ok(Some(capability)) = self.get_plugin_capability( + if let Some(capability) = self.get_plugin_capability( &*plugin.borrow(), capability_class_name, ctor_args.clone(), - ) { + )? { capabilities.push(capability); } } - capabilities + Ok(capabilities) } fn parse_allowed_plugins( diff --git a/crates/shirabe/tests/plugin/plugin_installer_test.rs b/crates/shirabe/tests/plugin/plugin_installer_test.rs index 70548ba6..ec5f4fe3 100644 --- a/crates/shirabe/tests/plugin/plugin_installer_test.rs +++ b/crates/shirabe/tests/plugin/plugin_installer_test.rs @@ -18,7 +18,7 @@ use shirabe::json::JsonFile; use shirabe::package::loader::{ArrayLoader, JsonLoader, JsonLoaderInput}; use shirabe::package::{Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle}; use shirabe::plugin::plugin_interface::PluginInterface; -use shirabe::plugin::{Capable, PluginManager}; +use shirabe::plugin::{Capable, PluginManager, composer_handle_value, io_handle_value}; use shirabe::repository::{ InstalledArrayRepository, InstalledRepositoryInterfaceHandle, RepositoryInterfaceHandle, RepositoryManagerInterface, @@ -633,13 +633,44 @@ fn test_plugin_range_constraints_work_only_with_certain_api_version() { todo!() } -#[ignore = "get_plugin_capability never instantiates a capability class (TODO(plugin) in plugin/plugin_manager.rs); Capability::CommandProvider/BaseCommand runtime instantiation is unported"] #[test] fn test_command_provider_capability() { - // TODO(phase-d): get_plugin_capability never instantiates a capability class (TODO(plugin) - // in plugin/plugin_manager.rs); Capability::CommandProvider/BaseCommand runtime - // instantiation is also unported. - todo!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + // PHP mocks the repository so getPackages() returns [$this->packages[7]] (plugin-v8); + // the real InstalledArrayRepository reaches the same state by adding the package. + set_up + .repository + .borrow_mut() + .add_package(set_up.packages[7].clone()) + .unwrap(); + let _installer = new_installer(&set_up); + set_up.pm.borrow_mut().load_installed_plugins().unwrap(); + + let mut ctor_args = IndexMap::new(); + ctor_args.insert( + "composer".to_string(), + composer_handle_value(&set_up.composer), + ); + ctor_args.insert("io".to_string(), io_handle_value(&set_up.io_dyn).unwrap()); + let caps = set_up + .pm + .borrow() + .get_plugin_capabilities("Composer\\Plugin\\Capability\\CommandProvider", ctor_args) + .unwrap(); + assert_eq!(1, caps.len()); + // PHP: assertInstanceOf('Composer\Plugin\Capability\CommandProvider', $caps[0]). + let provider = caps[0] + .as_command_provider() + .expect("the capability implements CommandProvider"); + + let commands = provider.get_commands().unwrap(); + assert_eq!(1, commands.len()); + // PHP: assertInstanceOf('Composer\Command\BaseCommand', $commands[0]) is witnessed by the + // element type of Vec<Box<dyn BaseCommand>>. } // A hand-written stub is used in place of PHPUnit's @@ -692,27 +723,63 @@ fn test_incapable_plugin_is_correctly_detected() { assert!(result.is_none()); } -#[ignore = "Requires runtime instantiation of Mock\\Capability via get_plugin_capability; not implemented (TODO(plugin))"] +#[ignore = "the mocked Capable plugin is Rust-native and cannot cross the RPC boundary: getPluginCapability passes the plugin itself as $ctorArgs['plugin'] to Mock\\Capability, and the test reads $capability->args back; PluginInterface has no rust-proxy stub (TODO(plugin))"] #[test] fn test_capability_implements_composer_plugin_api_class_and_is_constructed_with_args() { - // TODO(phase-d): requires runtime instantiation of Mock\Capability via - // get_plugin_capability; not implemented (TODO(plugin)). + // TODO(phase-d): the mocked Capable plugin is Rust-native and cannot cross the RPC + // boundary — getPluginCapability must pass the plugin itself as $ctorArgs['plugin'] to + // Mock\Capability's constructor, and the test reads $capability->args back over RPC; + // PluginInterface has no rust-proxy stub (TODO(plugin)). todo!() } -// PluginManager::get_capability_implementation_class_name (via Capable::get_capabilities) -// resolves capability class names through an IndexMap<String, String>, so most of PHP's -// invalidImplementationClassNames data provider (null, 0, 1000, [1], [], stdClass) cannot be -// represented at all in the ported type — only the string entries ("", " ") could be -// constructed. Per the phase-d rule against porting a subset of a data provider, this whole -// test must stay unported rather than dropping the non-string cases. -#[ignore = "Capable::get_capabilities is typed IndexMap<String, String>; most of the invalidImplementationClassNames data provider (null, 0, 1000, [1], [], stdClass) is not representable, and porting only the string cases would drop data-provider entries (TODO(phase-d))"] +/// PHP data provider `invalidImplementationClassNames`, one PhpMixed per entry. +fn invalid_implementation_class_names() -> Vec<PhpMixed> { + vec![ + PhpMixed::Null, + PhpMixed::String(String::new()), + PhpMixed::Int(0), + PhpMixed::Int(1000), + PhpMixed::String(" ".to_string()), + PhpMixed::List(vec![PhpMixed::Int(1)]), + PhpMixed::List(vec![]), + // PHP: new \stdClass() + PhpMixed::Object(IndexMap::new()), + ] +} + #[test] fn test_querying_with_invalid_capability_class_name_throws() { - // TODO(phase-d): Capable::get_capabilities is typed IndexMap<String, String>; most of the - // invalidImplementationClassNames data provider (null, 0, 1000, [1], [], stdClass) is not - // representable, and porting only the string cases would drop data-provider entries. - todo!() + let capability_api = "Composer\\Plugin\\Capability\\Capability"; + for invalid_implementation_class_name in invalid_implementation_class_names() { + let set_up = set_up(); + let plugin = CapablePlugin { + capabilities: IndexMap::from([( + capability_api.to_string(), + invalid_implementation_class_name.clone(), + )]), + get_capabilities_calls: std::cell::RefCell::new(0), + }; + + let err = + match set_up + .pm + .borrow() + .get_plugin_capability(&plugin, capability_api, IndexMap::new()) + { + Err(err) => err, + Ok(_) => panic!( + "expected UnexpectedValueException for {invalid_implementation_class_name:?}" + ), + }; + assert!( + err.downcast_ref::<shirabe_php_shim::UnexpectedValueException>() + .is_some(), + "expected UnexpectedValueException for {invalid_implementation_class_name:?}, got: {err}" + ); + // PHP: ->expects($this->once())->method('getCapabilities'). + assert_eq!(1, *plugin.get_capabilities_calls.borrow()); + } } // A hand-written stub plays the role of PHPUnit's @@ -721,6 +788,7 @@ fn test_querying_with_invalid_capability_class_name_throws() { // is reproduced with a call counter asserted after the call. #[derive(Debug)] struct CapablePlugin { + capabilities: IndexMap<String, PhpMixed>, get_capabilities_calls: std::cell::RefCell<i64>, } @@ -759,9 +827,9 @@ impl PluginInterface for CapablePlugin { } impl Capable for CapablePlugin { - fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, String>> { + fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, PhpMixed>> { *self.get_capabilities_calls.borrow_mut() += 1; - Ok(IndexMap::new()) + Ok(self.capabilities.clone()) } } @@ -770,6 +838,7 @@ fn test_querying_non_provided_capability_returns_null_safely() { let set_up = set_up(); let plugin = CapablePlugin { + capabilities: IndexMap::new(), get_capabilities_calls: std::cell::RefCell::new(0), }; @@ -786,10 +855,13 @@ fn test_querying_non_provided_capability_returns_null_safely() { assert_eq!(1, *plugin.get_capabilities_calls.borrow()); } -#[ignore = "Requires runtime get_plugin_capability with PHP-class-name capability lookup (class_exists/instanceof checks are unported TODO(plugin)); not implemented"] +#[ignore = "the '\\stdClass' data-provider case reaches new \\stdClass($ctorArgs), which needs the Rust-native mocked plugin passed as $ctorArgs['plugin'] over RPC (no PluginInterface rust-proxy stub exists, TODO(plugin)); porting only the NonExistentClassLikeMiddleClass case would drop a data-provider entry (TODO(phase-d))"] #[test] fn test_querying_with_non_existing_or_wrong_capability_class_types_throws() { - // TODO(phase-d): requires runtime get_plugin_capability with PHP-class-name capability lookup - // (class_exists/instanceof checks are unported TODO(plugin)); not implemented. + // TODO(phase-d): the '\stdClass' data-provider case reaches new \stdClass($ctorArgs), + // which needs the Rust-native mocked plugin passed as $ctorArgs['plugin'] over RPC — no + // PluginInterface rust-proxy stub exists (TODO(plugin)). Porting only the + // NonExistentClassLikeMiddleClass case (which fails at class_exists, before the plugin is + // passed) would drop a data-provider entry. todo!() } |
