From 11cdaae87c37e479fea6c39447041c312410b101 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 4 Aug 2026 06:33:32 +0900 Subject: feat(plugin): instantiate plugin capabilities through the PHP RPC worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 to IndexMap: 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> 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 --- crates/shirabe/src/plugin/php_plugin_proxy.rs | 292 ++++++++++++++++++++++++-- 1 file changed, 272 insertions(+), 20 deletions(-) (limited to 'crates/shirabe/src/plugin/php_plugin_proxy.rs') 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>, ) -> 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> { + 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::>()?, + PluginValue::List(items) => items + .into_iter() + .enumerate() + .map(|(i, v)| Ok((i.to_string(), v.to_php_mixed()?))) + .collect::>()?, + 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>, +) -> anyhow::Result { + 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 { + 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>> { + 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>` 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> = 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 { + 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>, + _output: std::rc::Rc>, + ) -> anyhow::Result { + // 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); + } +} -- cgit v1.3.1