diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-04 06:33:32 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-04 06:33:32 +0900 |
| commit | 11cdaae87c37e479fea6c39447041c312410b101 (patch) | |
| tree | 06e67edf1f437850f6f319c02f1304d130081fab | |
| parent | 901cbbf285ed4e9c1f7bf75b413643c117a6105a (diff) | |
| download | php-shirabe-11cdaae87c37e479fea6c39447041c312410b101.tar.gz php-shirabe-11cdaae87c37e479fea6c39447041c312410b101.tar.zst php-shirabe-11cdaae87c37e479fea6c39447041c312410b101.zip | |
feat(plugin): instantiate plugin capabilities through the PHP RPC worker
getPluginCapability was a no-op returning None. Now it runs the real
flow: class_exists in the worker, new $capabilityClass($ctorArgs) with
the plugin's own phandle spliced in as $ctorArgs['plugin'], both
instanceof checks answered by is_a in the child, and a per-interface
Rust adapter over the resulting entity (CommandProvider and the plain
Capability marker; anything else is an explicit error).
getPluginCapabilities now propagates errors instead of swallowing them.
Capable::get_capabilities widens from IndexMap<String, String> to
IndexMap<String, PhpMixed>: upstream casts the return with (array) and
validates only the queried key, so the narrow type both rejected maps
Composer accepts and made the invalidImplementationClassNames data
provider unrepresentable. The old code also returned ' 0 ' (trimmed to
the falsy '0') as a valid class name where upstream throws; the
rewritten validation follows upstream's empty/is_string/trim sequence.
CommandProvider::get_commands returns BaseCommand adapters whose name is
read back over RPC after the PHP constructor ran configure(); executing
one still needs the PHP-side Symfony Application and stays an explicit
error. The is_array / instanceof BaseCommand checks that upstream's
Application::getPluginCommands performs on the raw getCommands value
live in the adapter, because Vec<Box<dyn BaseCommand>> asserts every
element up front.
Ports testCommandProviderCapability (plugin-v8 end to end against the
real worker) and testQueryingWithInvalidCapabilityClassNameThrows (all
eight provider cases); the two tests that pass a PHPUnit mock plugin
into PHP stay ignored — a Rust-native mock has no PHP-side entity to
cross the boundary as $ctorArgs['plugin'].
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| -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!() } |
