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 /crates/shirabe/src/plugin/php_plugin_proxy.rs | |
| 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>
Diffstat (limited to 'crates/shirabe/src/plugin/php_plugin_proxy.rs')
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 292 |
1 files changed, 272 insertions, 20 deletions
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); + } +} |
