From 78c23c7105914b74ae91c71496265d3b03d7e285 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 19:10:27 +0900 Subject: feat(plugin): let a Rust-implemented plugin cross into the worker PluginManager::getPluginCapability hands the plugin itself to the capability constructor. Only a PHP-implemented plugin had an entity the child could receive, so a Rust-implemented one bailed out; it now crosses as a handle to an R-table entity behind Shirabe\RustPluginStub, or its Capable flavour, since `$plugin instanceof Capable` is what decides whether Composer asks a plugin for capabilities at all. This is what the two capability tests of PluginInstallerTest were waiting on: the mocked Capable plugin is Rust-side, and one of them asserts the identity of the plugin read back out of $capability->args. --- .../src/event_dispatcher/event_dispatcher.rs | 7 + crates/shirabe/src/plugin/capability/capability.rs | 6 + crates/shirabe/src/plugin/php_plugin_proxy.rs | 80 ++++++++- crates/shirabe/src/plugin/plugin_manager.rs | 34 ++-- .../shirabe/tests/plugin/plugin_installer_test.rs | 189 ++++++++++++++++----- 5 files changed, 252 insertions(+), 64 deletions(-) (limited to 'crates/shirabe') diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index 72db0543..ca6c2adc 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -1586,6 +1586,13 @@ try {{ Ok(()) } + /// For testing only: a test that never registers a plugin package still needs the Composer + /// PHP runtime in the worker before a class of its own can implement a Composer interface + /// there. + pub fn __ensure_composer_php_runtime() -> anyhow::Result<()> { + Self::ensure_composer_php_runtime() + } + fn composer_php_runtime_autoload() -> Option { if let Some(dir) = Platform::get_env("SHIRABE_COMPOSER_PHP_DIR") { let path = std::path::Path::new(&dir) diff --git a/crates/shirabe/src/plugin/capability/capability.rs b/crates/shirabe/src/plugin/capability/capability.rs index aa70ffa0..bb634b9f 100644 --- a/crates/shirabe/src/plugin/capability/capability.rs +++ b/crates/shirabe/src/plugin/capability/capability.rs @@ -11,4 +11,10 @@ pub trait Capability { fn as_command_provider(&self) -> Option<&dyn CommandProvider> { None } + + /// For testing only: recovers the PHP-backed proxy so tests can read capability properties + /// the way PHPUnit asserts `$capability->args`. + fn __as_php_capability_proxy(&self) -> Option<&crate::plugin::PhpCapabilityProxy> { + None + } } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 715a21dd..8e24dbfe 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -54,6 +54,7 @@ enum RustEntity { std::rc::Rc>, ), Operation(std::rc::Rc), + Plugin(std::rc::Rc>), } /// The pointer identity backing R-table interning: the same shared instance must always cross @@ -74,6 +75,7 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { std::rc::Rc::as_ptr(dispatcher) as *const () as usize } RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize, + RustEntity::Plugin(plugin) => std::rc::Rc::as_ptr(plugin) as *const () as usize, } } @@ -196,6 +198,22 @@ pub(crate) fn package_handle_value( rust_handle_value(rhandle, class) } +/// Registers a Rust-implemented plugin and returns its wire descriptor. A PHP-implemented +/// plugin never takes this route: its entity lives in the child's P table already. +pub fn plugin_handle_value( + plugin: &std::rc::Rc>, +) -> PluginValue { + // TODO(plugin): a Rust-implemented plugin that is also an EventSubscriberInterface crosses + // as one of these two classes, so `instanceof EventSubscriberInterface` is false in the + // child; Composer's own subscriber dispatch runs on the Rust side and never asks. + let class = match plugin.borrow().as_capable() { + Some(_) => "Shirabe\\RustCapablePluginStub", + None => "Shirabe\\RustPluginStub", + }; + let rhandle = register_entity(RustEntity::Plugin(plugin.clone())); + rust_handle_value(rhandle, class) +} + /// Registers a solver operation and returns its wire descriptor. pub(crate) fn operation_handle_value(operation: &std::rc::Rc) -> PluginValue { let class = operation_stub_class(operation); @@ -363,6 +381,7 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { Some(RustEntity::Operation(operation)) => { dispatch_operation_method(&operation, method_name, &args) } + Some(RustEntity::Plugin(plugin)) => dispatch_plugin_method(&plugin, method_name), None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } @@ -539,7 +558,8 @@ fn clone_entity(entity: &RustEntity) -> Result { | RustEntity::RepositoryManager(_) | RustEntity::Repository(_) | RustEntity::EventDispatcher(_) - | RustEntity::Operation(_) => { + | RustEntity::Operation(_) + | RustEntity::Plugin(_) => { return Err(runtime_throw( "cloning this Rust-side entity over RPC is not supported".to_string(), )); @@ -592,6 +612,33 @@ fn dispatch_property_access( ))) } +fn dispatch_plugin_method( + plugin: &std::rc::Rc>, + method_name: &str, +) -> Result { + match method_name { + "getCapabilities" => { + let plugin = plugin.borrow(); + let capable = plugin.as_capable().ok_or_else(|| { + runtime_throw(format!( + "plugin {} does not implement Capable", + plugin.get_class_name() + )) + })?; + let capabilities = capable + .get_capabilities() + .map_err(|error| runtime_throw(format!("getCapabilities failed: {error:#}")))?; + Ok(PluginValue::from_php_mixed(&PhpMixed::Array(capabilities))) + } + // TODO(plugin): the lifecycle methods would have to turn the `$composer`/`$io` stubs the + // child passes back into the Rust handles they stand for; nothing calls them, because + // Composer activates a Rust-implemented plugin on the Rust side. + other => Err(runtime_throw(format!( + "the plugin method `{other}` is not available over RPC yet" + ))), + } +} + fn dispatch_composer_method( composer: &ComposerHandle, method_name: &str, @@ -2582,9 +2629,38 @@ impl PhpCapabilityProxy { pub(crate) fn new(handle: PhpObjHandle) -> Self { Self { handle } } + + /// For testing only: the entity descriptor, whose class and interface list answer the + /// `assertInstanceOf` checks PHPUnit makes on a capability. + pub fn __handle(&self) -> &PhpObjHandle { + &self.handle + } + + /// For testing only: reads a public property of the capability entity in the child. Unlike + /// `PhpPluginProxy::__get_property` the value keeps its wire form, so a test can assert the + /// object identity behind a handle instead of only the plain data around it. + pub fn __get_property(&self, name: &str) -> anyhow::Result { + let outcome = shirabe_php_rpc::call_function( + "__shirabe_get_property", + vec![ + PluginValue::PhpHandle(self.handle.clone()), + PluginValue::string(name), + ], + )?; + match outcome { + Ok(value) => Ok(value), + Err(throw) => { + Err(shirabe_php_shim::RuntimeException::with_code(throw.message, throw.code).into()) + } + } + } } -impl Capability for PhpCapabilityProxy {} +impl Capability for PhpCapabilityProxy { + fn __as_php_capability_proxy(&self) -> Option<&PhpCapabilityProxy> { + Some(self) + } +} impl Drop for PhpCapabilityProxy { fn drop(&mut self) { diff --git a/crates/shirabe/src/plugin/plugin_manager.rs b/crates/shirabe/src/plugin/plugin_manager.rs index 0746a9b4..0667c6a9 100644 --- a/crates/shirabe/src/plugin/plugin_manager.rs +++ b/crates/shirabe/src/plugin/plugin_manager.rs @@ -16,6 +16,7 @@ use crate::plugin::PluginBlockedException; use crate::plugin::capability::Capability; use crate::plugin::php_plugin_proxy::{ PhpCapabilityProxy, PhpCommandProviderProxy, PhpPluginProxy, PluginRpcDispatcher, php_is_a, + plugin_handle_value, }; use crate::plugin::plugin_interface::{self, PluginInterface}; use crate::repository::InstalledRepository; @@ -1031,15 +1032,16 @@ impl PluginManager { pub fn get_plugin_capability( &self, - plugin: &dyn PluginInterface, + plugin: &std::rc::Rc>, capability_class_name: &str, ctor_args: IndexMap, ) -> anyhow::Result>> { - let capability_class = - match self.get_capability_implementation_class_name(plugin, capability_class_name)? { - Some(c) => c, - None => return Ok(None), - }; + let capability_class = match self + .get_capability_implementation_class_name(&*plugin.borrow(), capability_class_name)? + { + Some(c) => c, + None => return Ok(None), + }; // PHP: if (!class_exists($capabilityClass)) let exists = unwrap_php_result(call_function_with_dispatcher( @@ -1051,23 +1053,21 @@ impl PluginManager { return Err(RuntimeException::new(format!( "Cannot instantiate Capability, as class {} from plugin {} does not exist.", capability_class, - plugin.get_class_name() + plugin.borrow().get_class_name() )) .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() { + // instance itself. A PHP-implemented plugin already has an entity in the child's P + // table; a Rust-implemented one crosses as a handle to its R-table entity. + let plugin_value = match plugin.borrow().__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() - ), + None => plugin_handle_value(plugin), }; let mut ctor_args = ctor_args; ctor_args.insert("plugin".to_string(), plugin_value); @@ -1121,11 +1121,9 @@ impl PluginManager { ) -> anyhow::Result>> { let mut capabilities: Vec> = vec![]; for plugin in self.get_plugins() { - if let Some(capability) = self.get_plugin_capability( - &*plugin.borrow(), - capability_class_name, - ctor_args.clone(), - )? { + if let Some(capability) = + self.get_plugin_capability(plugin, capability_class_name, ctor_args.clone())? + { capabilities.push(capability); } } diff --git a/crates/shirabe/tests/plugin/plugin_installer_test.rs b/crates/shirabe/tests/plugin/plugin_installer_test.rs index 0ef36923..b713104e 100644 --- a/crates/shirabe/tests/plugin/plugin_installer_test.rs +++ b/crates/shirabe/tests/plugin/plugin_installer_test.rs @@ -20,7 +20,9 @@ use shirabe::package::{ CompletePackageHandle, Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle, }; use shirabe::plugin::plugin_interface::PluginInterface; -use shirabe::plugin::{Capable, PluginManager, composer_handle_value, io_handle_value}; +use shirabe::plugin::{ + Capable, PluginManager, composer_handle_value, io_handle_value, plugin_handle_value, +}; use shirabe::repository::{ InstalledArrayRepository, InstalledRepositoryInterfaceHandle, RepositoryInterfaceHandle, RepositoryManagerInterface, @@ -29,6 +31,7 @@ use shirabe::util::Platform; use shirabe::util::http_downloader::HttpDownloader; use shirabe::util::r#loop::Loop; use shirabe::util::process_executor::ProcessExecutor; +use shirabe_php_rpc::PluginValue; use shirabe_php_shim::Catch as _; use shirabe_php_shim::PhpMixed; use shirabe_semver::VersionParser; @@ -801,7 +804,8 @@ impl PluginInterface for NoopPlugin { fn test_incapable_plugin_is_correctly_detected() { let set_up = set_up(); - let plugin = NoopPlugin; + let plugin: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(NoopPlugin)); let result = set_up .pm .borrow() @@ -810,14 +814,82 @@ fn test_incapable_plugin_is_correctly_detected() { assert!(result.is_none()); } -#[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))"] +/// PHPUnit autoloads `Composer\Test\Plugin\Mock\Capability` from the Composer checkout; the +/// worker resolves `Composer\` to `src/Composer` only, so the class file is loaded by path. +fn load_mock_capability_class() { + EventDispatcher::__ensure_composer_php_runtime().unwrap(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/tests/Composer/Test/Plugin/Mock/Capability.php") + .canonicalize() + .expect("the Composer checkout must provide the plugin test mocks"); + shirabe_php_rpc::call_function( + "__shirabe_require", + vec![PluginValue::string(path.to_str().unwrap())], + ) + .unwrap() + .unwrap(); +} + #[test] fn test_capability_implements_composer_plugin_api_class_and_is_constructed_with_args() { - // 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!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + load_mock_capability_class(); + + let capability_api = "Composer\\Plugin\\Capability\\Capability"; + let capability_implementation = "Composer\\Test\\Plugin\\Mock\\Capability"; + + let get_capabilities_calls = std::rc::Rc::new(std::cell::RefCell::new(0)); + let plugin: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(CapablePlugin { + capabilities: IndexMap::from([( + capability_api.to_string(), + PhpMixed::String(capability_implementation.to_string()), + )]), + get_capabilities_calls: get_capabilities_calls.clone(), + })); + + let capability = set_up + .pm + .borrow() + .get_plugin_capability( + &plugin, + capability_api, + IndexMap::from([ + ("a".to_string(), PluginValue::Int(1)), + ("b".to_string(), PluginValue::Int(2)), + ]), + ) + .unwrap() + .expect("the plugin provides an implementation of the queried capability"); + let capability = capability + .__as_php_capability_proxy() + .expect("a capability built in the child is a PHP-backed proxy"); + + // PHP: assertInstanceOf($capabilityApi, $capability) + assert!( + capability + .__handle() + .implements + .iter() + .any(|interface| interface == capability_api) + ); + // PHP: assertInstanceOf($capabilityImplementation, $capability) + assert_eq!(capability_implementation, capability.__handle().class); + // PHP: assertSame(['a' => 1, 'b' => 2, 'plugin' => $plugin], $capability->args) + assert_eq!( + PluginValue::Array(IndexMap::from([ + (b"a".to_vec(), PluginValue::Int(1)), + (b"b".to_vec(), PluginValue::Int(2)), + (b"plugin".to_vec(), plugin_handle_value(&plugin)), + ])), + capability.__get_property("args").unwrap() + ); + // PHP: ->expects($this->once())->method('getCapabilities'). + assert_eq!(1, *get_capabilities_calls.borrow()); } /// PHP data provider `invalidImplementationClassNames`, one PhpMixed per entry. @@ -835,36 +907,50 @@ fn invalid_implementation_class_names() -> Vec { ] } -#[test] -fn test_querying_with_invalid_capability_class_name_throws() { +/// PHP: testQueryingWithInvalidCapabilityClassNameThrows, whose `$expect` parameter the second +/// data-driven test overrides by calling this one directly. +fn assert_querying_with_invalid_capability_class_name_throws( + invalid_implementation_class_names: &PhpMixed, + expect: fn(&anyhow::Error) -> bool, + expect_name: &str, +) { 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 { + let set_up = set_up(); + let get_capabilities_calls = std::rc::Rc::new(std::cell::RefCell::new(0)); + let plugin: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(CapablePlugin { capabilities: IndexMap::from([( capability_api.to_string(), - invalid_implementation_class_name.clone(), + invalid_implementation_class_names.clone(), )]), - get_capabilities_calls: std::cell::RefCell::new(0), + get_capabilities_calls: get_capabilities_calls.clone(), + })); + + let err = + match set_up + .pm + .borrow() + .get_plugin_capability(&plugin, capability_api, IndexMap::new()) + { + Err(err) => err, + Ok(_) => panic!("expected {expect_name} for {invalid_implementation_class_names:?}"), }; + assert!( + expect(&err), + "expected {expect_name} for {invalid_implementation_class_names:?}, got: {err}" + ); + // PHP: ->expects($this->once())->method('getCapabilities'). + assert_eq!(1, *get_capabilities_calls.borrow()); +} - 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.is_instanceof::(), - "expected UnexpectedValueException for {invalid_implementation_class_name:?}, got: {err}" +#[test] +fn test_querying_with_invalid_capability_class_name_throws() { + for invalid_implementation_class_name in invalid_implementation_class_names() { + assert_querying_with_invalid_capability_class_name_throws( + &invalid_implementation_class_name, + |err| err.is_instanceof::(), + "UnexpectedValueException", ); - // PHP: ->expects($this->once())->method('getCapabilities'). - assert_eq!(1, *plugin.get_capabilities_calls.borrow()); } } @@ -875,7 +961,8 @@ fn test_querying_with_invalid_capability_class_name_throws() { #[derive(Debug)] struct CapablePlugin { capabilities: IndexMap, - get_capabilities_calls: std::cell::RefCell, + // Shared with the test body, which only ever holds the plugin as a `dyn PluginInterface`. + get_capabilities_calls: std::rc::Rc>, } impl PluginInterface for CapablePlugin { @@ -923,10 +1010,12 @@ impl Capable for CapablePlugin { 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), - }; + let get_capabilities_calls = std::rc::Rc::new(std::cell::RefCell::new(0)); + let plugin: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(CapablePlugin { + capabilities: IndexMap::new(), + get_capabilities_calls: get_capabilities_calls.clone(), + })); let result = set_up .pm @@ -938,16 +1027,28 @@ fn test_querying_non_provided_capability_returns_null_safely() { ) .unwrap(); assert!(result.is_none()); - assert_eq!(1, *plugin.get_capabilities_calls.borrow()); + assert_eq!(1, *get_capabilities_calls.borrow()); +} + +/// PHP data provider `nonExistingOrInvalidImplementationClassTypes`. +fn non_existing_or_invalid_implementation_class_types() -> Vec<&'static str> { + vec!["\\stdClass", "NonExistentClassLikeMiddleClass"] } -#[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): 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!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + // `new \stdClass($ctorArgs)` receives the plugin, whose proxy stub implements the real + // PluginInterface in the child. + EventDispatcher::__ensure_composer_php_runtime().unwrap(); + for wrong_implementation_class_type in non_existing_or_invalid_implementation_class_types() { + assert_querying_with_invalid_capability_class_name_throws( + &PhpMixed::String(wrong_implementation_class_type.to_string()), + |err| err.is_instanceof::(), + "RuntimeException", + ); + } } -- cgit v1.3.1-4-g156e