diff options
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/event_dispatcher/event_dispatcher.rs | 230 | ||||
| -rw-r--r-- | crates/shirabe/src/event_dispatcher/event_subscriber_interface.rs | 13 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/capable.rs | 5 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 193 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/plugin_interface.rs | 10 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/plugin_manager.rs | 32 | ||||
| -rw-r--r-- | crates/shirabe/tests/command/archive_command_test.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/tests/installer_test.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/fixtures/subscriber-v1/Subscriber/Plugin.php | 61 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/fixtures/subscriber-v1/composer.json | 12 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/main.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/plugin_installer_test.rs | 45 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/subscriber_test.rs | 109 |
13 files changed, 629 insertions, 92 deletions
diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index f5b85ca1..2a25eac8 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -9,11 +9,13 @@ use crate::event_dispatcher::Event; use crate::event_dispatcher::EventInterface; use crate::event_dispatcher::EventSubscriberInterface; use crate::event_dispatcher::ScriptExecutionException; +use crate::event_dispatcher::SubscribedEventEntry; use crate::installer::BinaryInstaller; use crate::installer::InstallerEvent; use crate::installer::PackageEvent; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; +use crate::plugin::php_plugin_proxy::PluginRpcDispatcher; use crate::repository::RepositoryInterface; use crate::script::Event as ScriptEvent; use crate::util::Platform; @@ -25,7 +27,7 @@ use shirabe_external_packages::symfony::process::ExecutableFinder; use shirabe_external_packages::symfony::process::PhpExecutableFinder; use shirabe_php_rpc::{ PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, call_function, - call_function_with_dispatcher, call_static_method, + call_function_with_dispatcher, call_php_method, call_static_method, }; use shirabe_php_shim::{ InvalidArgumentException, PATH_SEPARATOR, PhpMixed, RuntimeException, array_pop, array_push, @@ -38,14 +40,14 @@ use shirabe_php_shim::{ /// Represents a callable listener. PHP's `callable` may be a string (command, script, or /// "Class::method"), a `[object|string, method]` pair, or a `\Closure`. -/// -/// TODO(plugin): Subscriber-based (`ArrayCallable`) listeners come from plugins and are not -/// implemented yet — only the string forms used by composer.json `scripts` work here. #[derive(Clone)] pub enum Callable { String(String), /// `[$className_or_object, $methodName]` array callable. The first element is represented /// here as `PhpMixed` to keep parity with PHP's loose typing. + /// + /// TODO(plugin): only listeners whose object half lives in the PHP child (`PhpMethod`) are + /// invocable; an `ArrayCallable` carrying a `PhpMixed` object still has no invocation path. ArrayCallable(Box<PhpMixed>, String), /// PHP `\Closure`, invoked with the event exactly like `$callable($event)` in /// `EventDispatcher::doDispatch`. Today this is only produced by Composer's own commands @@ -53,6 +55,9 @@ pub enum Callable { /// `dependencyResolutionCompleted` tracker) — Plugin-supplied closures remain out of scope /// pending Plugin API. Closure(std::rc::Rc<dyn Fn(&dyn EventInterface) -> PhpMixed>), + /// `[$subscriber, $methodName]` array callable whose object half is a plugin entity in the + /// PHP child process, registered through `addSubscriber`. Invoked over RPC. + PhpMethod(shirabe_php_rpc::PhpObjHandle, String), } impl std::fmt::Debug for Callable { @@ -65,6 +70,11 @@ impl std::fmt::Debug for Callable { .field(method) .finish(), Callable::Closure(_) => f.write_str("Closure(..)"), + Callable::PhpMethod(handle, method) => f + .debug_tuple("PhpMethod") + .field(handle) + .field(method) + .finish(), } } } @@ -358,9 +368,87 @@ impl EventDispatcher { } else { 0 }; + } else if let Callable::PhpMethod(ref handle, ref method_name) = callable { + self.make_autoloader(event, &callable)?; + Self::ensure_script_autoloader()?; + let is_callable_value = unwrap_php_result(call_function_with_dispatcher( + "is_callable", + vec![PluginValue::List(vec![ + PluginValue::PhpHandle(handle.clone()), + PluginValue::string(method_name.clone()), + ])], + Some(&mut PluginRpcDispatcher::default()), + ))?; + if !matches!(is_callable_value, PluginValue::Bool(true)) { + return Err(anyhow::anyhow!(RuntimeException { + message: format!( + "Subscriber {}::{} for event {} is not callable, make sure the function is defined and public", + handle.class, + method_name, + event.get_name() + ), + code: 0, + })); + } + self.io.write_error3( + &format!( + "> {}: {}->{}", + formatted_event_name_with_args.clone(), + handle.class, + method_name, + ), + true, + crate::io::VERBOSE, + ); + let stub_class = Self::event_stub_class(event).ok_or_else(|| { + // TODO(plugin): only the base Event and Script\Event proxy stubs exist so + // far; installer/package/plugin events need their own stubs. + anyhow::anyhow!(RuntimeException { + message: format!( + "no proxy stub is available yet for the event `{}` dispatched to {}::{}", + event.get_name(), + handle.class, + method_name, + ), + code: 0, + }) + })?; + let event_rhandle = shirabe_php_rpc::alloc_rhandle(); + let mut dispatcher = PluginRpcDispatcher { + event: Some((event_rhandle, event)), + }; + let outcome = call_php_method( + handle.phandle, + method_name, + vec![PluginValue::RustHandle(RustObjHandle { + rhandle: event_rhandle, + class: stub_class.to_string(), + epoch: 0, + snapshot: None, + })], + Some(&mut dispatcher), + )?; + r#return = match outcome { + Ok(value) => { + if matches!(value, PluginValue::Bool(false)) { + 1 + } else { + 0 + } + } + // TODO(plugin): the original exception class is collapsed to + // RuntimeException on this side of the boundary. + Err(throw) => { + return Err(anyhow::anyhow!(RuntimeException { + message: throw.message, + code: throw.code, + })); + } + }; } else if !is_string_callable { - // TODO(plugin): non-string callable handling — verify is_callable, invoke, - // and replicate the get_class / write_error / is_callable error path from PHP. + // TODO(plugin): an ArrayCallable whose object half is a PhpMixed has no + // invocation path; only the is_callable error lane and the verbose echo of the + // PHP branch are replicated here. self.make_autoloader(event, &callable)?; if !is_callable(&PhpMixed::Null) { let (class_name, method) = match &callable { @@ -1034,16 +1122,10 @@ try {{ ); } - // The event crosses the boundary as a proxy stub: the child sees an instance of the - // stub class (same FQCN as the real event class) whose methods call back here. - let stub_class = if event.as_any().downcast_ref::<ScriptEvent>().is_some() { - "Composer\\Script\\Event" - } else if event.as_any().downcast_ref::<Event>().is_some() { - "Composer\\EventDispatcher\\Event" - } else { + let stub_class = Self::event_stub_class(event).ok_or_else(|| { // TODO(plugin): only the base Event and Script\Event proxy stubs exist so far; // installer/package/plugin events need their own stubs. - return Err(anyhow::anyhow!(RuntimeException { + anyhow::anyhow!(RuntimeException { message: format!( "no proxy stub is available yet for the event `{}` dispatched to {}::{}", event.get_name(), @@ -1051,8 +1133,8 @@ try {{ method_name, ), code: 0, - })); - }; + }) + })?; Self::ensure_script_autoloader()?; let rhandle = shirabe_php_rpc::alloc_rhandle(); @@ -1082,6 +1164,18 @@ try {{ } } + /// The proxy stub class (same FQCN as the real event class) an event crosses the RPC + /// boundary as, or `None` when no stub exists for it yet. + fn event_stub_class(event: &dyn EventInterface) -> Option<&'static str> { + if event.as_any().downcast_ref::<ScriptEvent>().is_some() { + Some("Composer\\Script\\Event") + } else if event.as_any().downcast_ref::<Event>().is_some() { + Some("Composer\\EventDispatcher\\Event") + } else { + None + } + } + fn event_needs_to_output(&self, event: &dyn EventInterface) -> bool { // do not output the command being run when using `composer exec` as it is fairly obvious the user is running it if event.get_name() == "__exec_command" { @@ -1135,12 +1229,38 @@ try {{ } /// Adds object methods as listeners for the events in getSubscribedEvents - pub fn add_subscriber<S: EventSubscriberInterface>(&mut self, _subscriber: &S) { - // TODO(plugin): port full subscriber registration — depends on dynamic dispatch - // for `[$subscriber, $methodName]` style callables. - for (event_name, _params) in S::get_subscribed_events() { - let _ = event_name; + pub fn add_subscriber( + &mut self, + subscriber: &dyn EventSubscriberInterface, + ) -> anyhow::Result<()> { + for (event_name, params) in subscriber.get_subscribed_events()? { + match params { + SubscribedEventEntry::Method(method) => { + self.add_listener( + &event_name, + Callable::PhpMethod(subscriber.subscriber_handle(), method), + 0, + ); + } + SubscribedEventEntry::MethodWithPriority(method, priority) => { + self.add_listener( + &event_name, + Callable::PhpMethod(subscriber.subscriber_handle(), method), + priority.unwrap_or(0), + ); + } + SubscribedEventEntry::Methods(listeners) => { + for (method, priority) in listeners { + self.add_listener( + &event_name, + Callable::PhpMethod(subscriber.subscriber_handle(), method), + priority.unwrap_or(0), + ); + } + } + } } + Ok(()) } /// Retrieves all listeners for a given event @@ -1358,6 +1478,8 @@ try {{ PhpMixed::String(class) => format!("{}::{}", class, method), other => format!("{}::{}", get_class(other), method), }, + // PHP: get_class($callable[0]).'::'.$callable[1] — the object half's runtime class. + Callable::PhpMethod(handle, method) => format!("{}::{}", handle.class, method), Callable::Closure(_) => "closure".to_string(), }; if self.previous_listeners.contains_key(&callable_key) { @@ -1573,31 +1695,9 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { ))); } match self.event { - Some((event_rhandle, event)) if event_rhandle == rhandle => match method_name { - "getName" => Ok(PluginValue::string(event.get_name())), - "getArguments" => Ok(PluginValue::List( - event - .get_arguments() - .iter() - .map(|arg| PluginValue::string(arg.clone())) - .collect(), - )), - "getFlags" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( - event.get_flags().clone(), - ))), - "isPropagationStopped" => Ok(PluginValue::Bool(event.is_propagation_stopped())), - "isDevMode" => match event.as_any().downcast_ref::<ScriptEvent>() { - Some(script_event) => Ok(PluginValue::Bool(script_event.is_dev_mode())), - None => Err(runtime_throw( - "isDevMode is only available on script events".to_string(), - )), - }, - // TODO(plugin): getComposer/getIO/stopPropagation and the rest need the full - // object-graph proxying of the plugin activation milestone. - other => Err(runtime_throw(format!( - "the Event method `{other}` is not available over RPC yet" - ))), - }, + Some((event_rhandle, event)) if event_rhandle == rhandle => { + dispatch_event_method(event, method_name) + } _ => Err(runtime_throw(format!( "unknown Rust handle {rhandle} (script-event handles are scoped to a single \ dispatched call)" @@ -1606,6 +1706,39 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { } } +/// Serves an event proxy stub's method call, shared by the script dispatcher and the plugin +/// dispatcher (both expose one live event handle per dispatched call). +pub(crate) fn dispatch_event_method( + event: &dyn EventInterface, + method_name: &str, +) -> Result<PluginValue, PhpThrow> { + match method_name { + "getName" => Ok(PluginValue::string(event.get_name())), + "getArguments" => Ok(PluginValue::List( + event + .get_arguments() + .iter() + .map(|arg| PluginValue::string(arg.clone())) + .collect(), + )), + "getFlags" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( + event.get_flags().clone(), + ))), + "isPropagationStopped" => Ok(PluginValue::Bool(event.is_propagation_stopped())), + "isDevMode" => match event.as_any().downcast_ref::<ScriptEvent>() { + Some(script_event) => Ok(PluginValue::Bool(script_event.is_dev_mode())), + None => Err(runtime_throw( + "isDevMode is only available on script events".to_string(), + )), + }, + // TODO(plugin): getComposer/getIO/stopPropagation and the rest need full proxying + // of the object graph an event exposes, which does not exist yet. + other => Err(runtime_throw(format!( + "the Event method `{other}` is not available over RPC yet" + ))), + } +} + fn runtime_throw(message: String) -> PhpThrow { PhpThrow { exception_class: "RuntimeException".to_string(), @@ -1657,6 +1790,7 @@ pub trait EventDispatcherInterface: std::fmt::Debug { transaction: Transaction, ) -> anyhow::Result<i64>; fn add_listener(&mut self, event_name: &str, listener: Callable, priority: i64); + fn add_subscriber(&mut self, subscriber: &dyn EventSubscriberInterface) -> anyhow::Result<()>; fn has_event_listeners(&mut self, event: &dyn EventInterface) -> bool; } @@ -1693,6 +1827,10 @@ impl EventDispatcherInterface for EventDispatcher { self.add_listener(event_name, listener, priority); } + fn add_subscriber(&mut self, subscriber: &dyn EventSubscriberInterface) -> anyhow::Result<()> { + self.add_subscriber(subscriber) + } + fn has_event_listeners(&mut self, event: &dyn EventInterface) -> bool { self.has_event_listeners(event) } diff --git a/crates/shirabe/src/event_dispatcher/event_subscriber_interface.rs b/crates/shirabe/src/event_dispatcher/event_subscriber_interface.rs index 41d723dc..0a5d88d5 100644 --- a/crates/shirabe/src/event_dispatcher/event_subscriber_interface.rs +++ b/crates/shirabe/src/event_dispatcher/event_subscriber_interface.rs @@ -1,15 +1,26 @@ //! ref: composer/src/Composer/EventDispatcher/EventSubscriberInterface.php use indexmap::IndexMap; +use shirabe_php_rpc::PhpObjHandle; /// Represents one event's subscriber info: method name only, method+priority, or multiple handlers. +#[derive(Debug)] pub enum SubscribedEventEntry { Method(String), MethodWithPriority(String, Option<i64>), Methods(Vec<(String, Option<i64>)>), } +// The sole implementor is the PHP plugin proxy (plugins are the only subscribers in Composer +// itself), so the trait deviates from the PHP shape in two deliberate ways: the PHP-side static +// `getSubscribedEvents()` takes `&self` here (the receiver carries which PHP class to call, and +// an associated function would not be dyn-compatible), and it is fallible because the answer +// crosses the RPC boundary. pub trait EventSubscriberInterface { /// Returns an array of event names this subscriber wants to listen to. - fn get_subscribed_events() -> IndexMap<String, SubscribedEventEntry>; + fn get_subscribed_events(&self) -> anyhow::Result<IndexMap<String, SubscribedEventEntry>>; + + /// The subscriber as it crosses the wire: PHP's `[$subscriber, $method]` array callables + /// capture the subscriber object itself, represented here by its P-table handle. + fn subscriber_handle(&self) -> PhpObjHandle; } diff --git a/crates/shirabe/src/plugin/capable.rs b/crates/shirabe/src/plugin/capable.rs index 23369046..830a7dab 100644 --- a/crates/shirabe/src/plugin/capable.rs +++ b/crates/shirabe/src/plugin/capable.rs @@ -2,7 +2,8 @@ use indexmap::IndexMap; -// TODO(plugin): Plugin API - interface for plugins that expose capability implementations +// The sole implementor is the PHP plugin proxy (Composer itself never implements Capable), so +// the trait is fallible: the answer crosses the RPC boundary. pub trait Capable { - fn get_capabilities(&self) -> IndexMap<String, String>; + fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, String>>; } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 2b823fa5..143af8bb 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -7,11 +7,14 @@ use crate::autoload::ClassLoader; use crate::composer::ComposerHandle; +use crate::event_dispatcher::event_dispatcher::dispatch_event_method; +use crate::event_dispatcher::{EventInterface, EventSubscriberInterface, SubscribedEventEntry}; use crate::io::IOInterface; use crate::plugin::plugin_interface::PluginInterface; use indexmap::IndexMap; use shirabe_php_rpc::{ - PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, call_php_method, release_php_handle, + PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, call_php_method, + release_php_handle, }; /// A Rust-side entity a PHP proxy stub points back to. @@ -22,20 +25,39 @@ enum RustEntity { } thread_local! { - /// The R table. Entries are strong references kept for the worker's lifetime. - /// TODO(plugin): GC (dropping entries on ReleaseRustHandle) is not implemented yet; - /// until then entities registered here are intentionally never released. + /// The R table. Entries are strong references held while the child-side stub lives; a + /// ReleaseRustHandle notification (sent by the stub's `__destruct`) removes the entry. + /// Handles are monotonically increasing and never reused, so a released handle can never + /// be confused with a later entity (no generation counter is needed). /// TODO(plugin): thread-local while the worker and its stub intern table are /// process-global; the session lock serializes calls, and every dispatch currently runs on /// the thread that registered the handle, but a handle minted on one thread is invisible /// to another. static R_TABLE: std::cell::RefCell<IndexMap<u64, RustEntity>> = std::cell::RefCell::new(IndexMap::new()); + + static RELEASE_HOOK_INSTALLED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; +} + +/// Installs the ReleaseRustHandle listener that drops R-table entries when the child-side stub +/// is destructed. Idempotent; called by every entity registration. +fn ensure_release_hook_installed() { + RELEASE_HOOK_INSTALLED.with(|installed| { + if !installed.get() { + installed.set(true); + shirabe_php_rpc::set_release_rust_handle_hook(|rhandle| { + R_TABLE.with(|table| { + table.borrow_mut().shift_remove(&rhandle); + }); + }); + } + }); } /// Registers the Composer instance in the R table, interned by shared-pointer identity so the /// same instance always crosses the boundary as the same handle (`===` in the child). pub(crate) fn register_composer_entity(composer: &ComposerHandle) -> u64 { + ensure_release_hook_installed(); R_TABLE.with(|table| { let mut table = table.borrow_mut(); let ptr = std::rc::Rc::as_ptr(composer.as_rc()) as *const () as usize; @@ -54,6 +76,7 @@ pub(crate) fn register_composer_entity(composer: &ComposerHandle) -> u64 { /// Registers an IO instance in the R table, interned like [`register_composer_entity`]. pub(crate) fn register_io_entity(io: &std::rc::Rc<std::cell::RefCell<dyn IOInterface>>) -> u64 { + ensure_release_hook_installed(); R_TABLE.with(|table| { let mut table = table.borrow_mut(); let ptr = std::rc::Rc::as_ptr(io) as *const () as usize; @@ -118,10 +141,18 @@ pub(crate) fn find_file_in_registered_loaders(class: &str) -> Option<String> { /// Serves `CallRustMethod` requests from the child while a plugin-related call is in flight: /// rhandle 0 is the runtime service endpoint (autoload lookups), every other rhandle resolves /// through the R table. Unsupported methods are explicit errors, never silent fallbacks. -#[derive(Debug)] -pub(crate) struct PluginRpcDispatcher; +#[derive(Debug, Default)] +pub(crate) struct PluginRpcDispatcher<'a> { + /// At most one live event handle exposed per dispatched listener call, alongside the + /// persistent R table. + /// + /// TODO(plugin): a listener that stores the event stub beyond its own call observes an + /// unknown handle error afterwards; keeping events in the R table needs full proxying of + /// the object graph an event exposes, which does not exist yet. + pub(crate) event: Option<(u64, &'a dyn EventInterface)>, +} -impl RustMethodDispatcher for PluginRpcDispatcher { +impl RustMethodDispatcher for PluginRpcDispatcher<'_> { fn dispatch( &mut self, rhandle: u64, @@ -150,6 +181,12 @@ impl RustMethodDispatcher for PluginRpcDispatcher { ))); } + if let Some((event_rhandle, event)) = self.event + && event_rhandle == rhandle + { + return dispatch_event_method(event, method_name); + } + // The entity is cloned out so no table borrow is held while the handler runs (a // handler that re-enters register_*_entity would otherwise panic on the RefCell). let entity = R_TABLE.with(|table| table.borrow().get(&rhandle).cloned()); @@ -264,11 +301,18 @@ fn runtime_throw(message: String) -> PhpThrow { pub struct PhpPluginProxy { pub(crate) phandle: u64, pub(crate) class: String, + /// Every interface the entity's class implements (`class_implements` in the child), for + /// the `instanceof` checks PHP performs on plugin objects. + pub(crate) implements: Vec<String>, } impl PhpPluginProxy { - pub fn new(phandle: u64, class: String) -> Self { - Self { phandle, class } + pub fn new(phandle: u64, class: String, implements: Vec<String>) -> Self { + Self { + phandle, + class, + implements, + } } fn forward_lifecycle_call( @@ -294,7 +338,12 @@ impl PhpPluginProxy { snapshot: None, }), ]; - let outcome = call_php_method(self.phandle, method, args, Some(&mut PluginRpcDispatcher))?; + let outcome = call_php_method( + self.phandle, + method, + args, + Some(&mut PluginRpcDispatcher::default()), + )?; match outcome { Ok(_) => Ok(()), // TODO(plugin): the original exception class is collapsed to RuntimeException on @@ -359,11 +408,135 @@ impl PluginInterface for PhpPluginProxy { self.class.clone() } + fn as_event_subscriber(&self) -> Option<&dyn EventSubscriberInterface> { + if self + .implements + .iter() + .any(|interface| interface == "Composer\\EventDispatcher\\EventSubscriberInterface") + { + Some(self) + } else { + None + } + } + fn __as_php_plugin_proxy(&self) -> Option<&PhpPluginProxy> { Some(self) } } +impl EventSubscriberInterface for PhpPluginProxy { + fn get_subscribed_events(&self) -> anyhow::Result<IndexMap<String, SubscribedEventEntry>> { + // PHP: `$subscriber->getSubscribedEvents()` (an instance call of the static method). + let outcome = call_php_method( + self.phandle, + "getSubscribedEvents", + 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, + })); + } + }; + decode_subscribed_events(&self.class, value) + } + + fn subscriber_handle(&self) -> PhpObjHandle { + PhpObjHandle { + phandle: self.phandle, + class: self.class.clone(), + implements: self.implements.clone(), + } + } +} + +/// 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. +fn decode_subscribed_events( + class: &str, + value: PluginValue, +) -> anyhow::Result<IndexMap<String, SubscribedEventEntry>> { + let entries = match value { + PluginValue::Array(entries) => entries, + PluginValue::List(items) => { + // A PHP list-shaped array (integer keys) cannot map event names; an empty one is + // the only legal case (an empty PHP array serializes as a list). + if items.is_empty() { + IndexMap::new() + } else { + return Err(subscribed_events_shape_error( + class, + &PluginValue::List(items), + )); + } + } + other => return Err(subscribed_events_shape_error(class, &other)), + }; + let mut events: IndexMap<String, SubscribedEventEntry> = IndexMap::new(); + for (event_name, params) in entries { + // TODO(phase-e): lossy UTF-8; event and method names are bytes in PHP. + let event_name = String::from_utf8_lossy(&event_name).into_owned(); + let entry = match ¶ms { + PluginValue::String(method) => { + SubscribedEventEntry::Method(String::from_utf8_lossy(method).into_owned()) + } + PluginValue::List(items) => match items.first() { + Some(PluginValue::String(method)) => SubscribedEventEntry::MethodWithPriority( + String::from_utf8_lossy(method).into_owned(), + decode_listener_priority(class, items.get(1))?, + ), + _ => { + let mut listeners = Vec::with_capacity(items.len()); + for listener in items { + let PluginValue::List(pair) = listener else { + return Err(subscribed_events_shape_error(class, ¶ms)); + }; + let Some(PluginValue::String(method)) = pair.first() else { + return Err(subscribed_events_shape_error(class, ¶ms)); + }; + listeners.push(( + String::from_utf8_lossy(method).into_owned(), + decode_listener_priority(class, pair.get(1))?, + )); + } + SubscribedEventEntry::Methods(listeners) + } + }, + _ => return Err(subscribed_events_shape_error(class, ¶ms)), + }; + events.insert(event_name, entry); + } + Ok(events) +} + +fn decode_listener_priority( + class: &str, + value: Option<&PluginValue>, +) -> anyhow::Result<Option<i64>> { + match value { + None => Ok(None), + Some(PluginValue::Int(priority)) => Ok(Some(*priority)), + Some(other) => Err(subscribed_events_shape_error(class, other)), + } +} + +fn subscribed_events_shape_error(class: &str, value: &PluginValue) -> anyhow::Error { + anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "{class}::getSubscribedEvents() returned an unsupported shape over RPC: {value:?}" + ), + code: 0, + }) +} + impl Drop for PhpPluginProxy { fn drop(&mut self) { // A dead worker has nothing left to release. diff --git a/crates/shirabe/src/plugin/plugin_interface.rs b/crates/shirabe/src/plugin/plugin_interface.rs index 989329ea..586fb43b 100644 --- a/crates/shirabe/src/plugin/plugin_interface.rs +++ b/crates/shirabe/src/plugin/plugin_interface.rs @@ -1,6 +1,7 @@ //! ref: composer/src/Composer/Plugin/PluginInterface.php use crate::composer::ComposerHandle; +use crate::event_dispatcher::EventSubscriberInterface; use crate::io::IOInterface; use crate::plugin::Capable; @@ -31,11 +32,10 @@ pub trait PluginInterface: std::fmt::Debug { /// the name PHP would report. fn get_class_name(&self) -> String; - // TODO(plugin): PHP-side `instanceof` checks for EventSubscriberInterface / Capable. - // EventSubscriberInterface is not dyn-compatible (its only method is associated, not - // a `&self` method), so we expose a boolean predicate instead. - fn is_event_subscriber_interface(&self) -> bool { - false + // PHP-side `instanceof EventSubscriberInterface` / `instanceof Capable` checks map to + // these downcast accessors. + fn as_event_subscriber(&self) -> Option<&dyn EventSubscriberInterface> { + None } fn as_capable(&self) -> Option<&dyn Capable> { diff --git a/crates/shirabe/src/plugin/plugin_manager.rs b/crates/shirabe/src/plugin/plugin_manager.rs index 65c4b03f..a3810d0c 100644 --- a/crates/shirabe/src/plugin/plugin_manager.rs +++ b/crates/shirabe/src/plugin/plugin_manager.rs @@ -485,9 +485,12 @@ impl PluginManager { .into()); } let handle = self.php_runtime_new_object(&class)?; - let plugin: std::rc::Rc<std::cell::RefCell<dyn PluginInterface>> = std::rc::Rc::new( - std::cell::RefCell::new(PhpPluginProxy::new(handle.phandle, handle.class)), - ); + let plugin: std::rc::Rc<std::cell::RefCell<dyn PluginInterface>> = + std::rc::Rc::new(std::cell::RefCell::new(PhpPluginProxy::new( + handle.phandle, + handle.class, + handle.implements, + ))); self.add_plugin(plugin.clone(), is_global_plugin, Some(package.clone()))?; self.registered_plugins .entry(package.get_name().to_string()) @@ -513,7 +516,7 @@ impl PluginManager { let value = unwrap_php_result(call_function_with_dispatcher( function, args, - Some(&mut PluginRpcDispatcher), + Some(&mut PluginRpcDispatcher::default()), ))?; match value { PluginValue::Bool(value) => Ok(value), @@ -545,7 +548,7 @@ impl PluginManager { unwrap_php_result(call_function_with_dispatcher( "__shirabe_eval", vec![PluginValue::string(code)], - Some(&mut PluginRpcDispatcher), + Some(&mut PluginRpcDispatcher::default()), ))?; Ok(()) } @@ -561,7 +564,7 @@ impl PluginManager { PluginValue::string(file_identifier), PluginValue::string(file), ], - Some(&mut PluginRpcDispatcher), + Some(&mut PluginRpcDispatcher::default()), ))?; Ok(()) } @@ -571,7 +574,7 @@ impl PluginManager { let value = unwrap_php_result(shirabe_php_rpc::new_object( class, vec![], - Some(&mut PluginRpcDispatcher), + Some(&mut PluginRpcDispatcher::default()), ))?; match value { PluginValue::PhpHandle(handle) => Ok(handle), @@ -735,12 +738,12 @@ impl PluginManager { .borrow_mut() .activate(self.composer_full(), self.io.clone())?; - // TODO(plugin): if plugin is EventSubscriberInterface, hook into the event dispatcher - // The PHP code calls $this->composer->getEventDispatcher()->addSubscriber($plugin); - // — add_subscriber here is generic over `S: EventSubscriberInterface` and cannot - // accept a `&dyn EventSubscriberInterface`. Skipped until subscriber dispatch is - // implemented dynamically. - let _ = plugin.borrow().is_event_subscriber_interface(); + let plugin_ref = plugin.borrow(); + if let Some(subscriber) = plugin_ref.as_event_subscriber() { + let event_dispatcher = self.composer_full().borrow().get_event_dispatcher(); + let result = event_dispatcher.borrow_mut().add_subscriber(subscriber); + result?; + } Ok(()) } @@ -949,13 +952,12 @@ impl PluginManager { plugin: &dyn PluginInterface, capability: &str, ) -> anyhow::Result<Option<String>> { - // TODO(plugin): capability lookup let capable = match plugin.as_capable() { Some(c) => c, None => return Ok(None), }; - let capabilities = capable.get_capabilities(); + let capabilities = capable.get_capabilities()?; // PHP: !empty($capabilities[$capability]) && is_string($capabilities[$capability]) && trim($capabilities[$capability]) if let Some(s) = capabilities.get(capability) { diff --git a/crates/shirabe/tests/command/archive_command_test.rs b/crates/shirabe/tests/command/archive_command_test.rs index 322894db..655020e0 100644 --- a/crates/shirabe/tests/command/archive_command_test.rs +++ b/crates/shirabe/tests/command/archive_command_test.rs @@ -85,6 +85,10 @@ mockall::mock! { transaction: Transaction, ) -> anyhow::Result<i64>; fn add_listener(&mut self, event_name: &str, listener: Callable, priority: i64); + fn add_subscriber<'a>( + &mut self, + subscriber: &'a dyn shirabe::event_dispatcher::EventSubscriberInterface, + ) -> anyhow::Result<()>; fn has_event_listeners(&mut self, event: &dyn EventInterface) -> bool; } } diff --git a/crates/shirabe/tests/installer_test.rs b/crates/shirabe/tests/installer_test.rs index d0b60ba8..1a8b7ccc 100644 --- a/crates/shirabe/tests/installer_test.rs +++ b/crates/shirabe/tests/installer_test.rs @@ -188,6 +188,12 @@ impl EventDispatcherInterface for StubEventDispatcher { Ok(0) } fn add_listener(&mut self, _event_name: &str, _listener: Callable, _priority: i64) {} + fn add_subscriber( + &mut self, + _subscriber: &dyn shirabe::event_dispatcher::EventSubscriberInterface, + ) -> anyhow::Result<()> { + Ok(()) + } fn has_event_listeners(&mut self, _event: &dyn EventInterface) -> bool { false } diff --git a/crates/shirabe/tests/plugin/fixtures/subscriber-v1/Subscriber/Plugin.php b/crates/shirabe/tests/plugin/fixtures/subscriber-v1/Subscriber/Plugin.php new file mode 100644 index 00000000..f75763ba --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/subscriber-v1/Subscriber/Plugin.php @@ -0,0 +1,61 @@ +<?php + +namespace Subscriber; + +use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\IO\IOInterface; +use Composer\Plugin\PluginInterface; + +class Plugin implements PluginInterface, EventSubscriberInterface +{ + public $version = 'subscriber-v1'; + + /** @var IOInterface */ + private $io; + + public function activate(Composer $composer, IOInterface $io) + { + $this->io = $io; + $io->write('activate subscriber-v1'); + } + + public function deactivate(Composer $composer, IOInterface $io) + { + } + + public function uninstall(Composer $composer, IOInterface $io) + { + } + + public static function getSubscribedEvents() + { + return [ + 'post-install-cmd' => 'onPostInstall', + 'shirabe-priority-event' => [['early', 10], ['late', -10]], + 'shirabe-false-event' => ['returnsFalse', 0], + ]; + } + + public function onPostInstall($event) + { + $this->io->write('subscriber saw ' . $event->getName()); + } + + public function early($event) + { + $this->io->write('early listener'); + } + + public function late($event) + { + $this->io->write('late listener'); + } + + public function returnsFalse($event) + { + $this->io->write('failing listener'); + + return false; + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/subscriber-v1/composer.json b/crates/shirabe/tests/plugin/fixtures/subscriber-v1/composer.json new file mode 100644 index 00000000..40659606 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/subscriber-v1/composer.json @@ -0,0 +1,12 @@ +{ + "name": "subscriber-v1", + "version": "1.0.0", + "type": "composer-plugin", + "autoload": { "psr-0": { "Subscriber": "" } }, + "extra": { + "class": "Subscriber\\Plugin" + }, + "require": { + "composer-plugin-api": "^2.0" + } +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index 62c8cfa8..3a66bb75 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -4,3 +4,4 @@ mod async_runtime; mod config_stub; mod plugin_installer_test; +mod subscriber_test; diff --git a/crates/shirabe/tests/plugin/plugin_installer_test.rs b/crates/shirabe/tests/plugin/plugin_installer_test.rs index 5dfb6e06..70548ba6 100644 --- a/crates/shirabe/tests/plugin/plugin_installer_test.rs +++ b/crates/shirabe/tests/plugin/plugin_installer_test.rs @@ -35,7 +35,7 @@ use tempfile::TempDir; /// The register/activate flow runs the plugin in the real PHP worker; without a PHP binary the /// worker cannot start. Tests exercising it return early, following the convention of the /// non-mock tests in `shirabe-php-rpc`. -fn php_runtime_available() -> bool { +pub(crate) fn php_runtime_available() -> bool { PhpExecutableFinder::new().find(false).is_some() } @@ -45,7 +45,7 @@ fn php_runtime_available() -> bool { /// race the other's `class_exists` checks, so the worker-touching tests run serialized. static PHP_WORKER_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); -fn lock_php_worker() -> std::sync::MutexGuard<'static, ()> { +pub(crate) fn lock_php_worker() -> std::sync::MutexGuard<'static, ()> { PHP_WORKER_TESTS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -62,6 +62,16 @@ fn fixtures_dir() -> String { .to_string() } +/// Shirabe-owned fixtures with no upstream counterpart (see `subscriber_test.rs`). +pub(crate) fn shirabe_fixtures_dir() -> String { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures"); + dir.canonicalize() + .expect("the Shirabe plugin fixtures directory must exist") + .to_str() + .unwrap() + .to_string() +} + // PHP mocks `Composer\Downloader\DownloadManager`; install/update/remove resolve to null and the // other methods are never reached by these tests. mockall::mock! { @@ -184,7 +194,16 @@ impl InstallationManagerInterface for MockInstallationManager { } fn get_install_path(&self, package: PackageInterfaceHandle) -> Option<String> { - Some(format!("{}/{}", fixtures_dir(), package.get_pretty_name())) + let upstream = format!("{}/{}", fixtures_dir(), package.get_pretty_name()); + if std::path::Path::new(&upstream).exists() { + return Some(upstream); + } + // Shirabe-specific fixtures (subscriber_test) live next to this test binary. + Some(format!( + "{}/{}", + shirabe_fixtures_dir(), + package.get_pretty_name() + )) } fn set_output_progress(&mut self, _output_progress: bool) {} @@ -214,20 +233,20 @@ fn locker_installation_manager( } #[derive(Debug)] -struct SetUp { - io: std::rc::Rc<std::cell::RefCell<BufferIO>>, - io_dyn: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, - pm: std::rc::Rc<std::cell::RefCell<PluginManager>>, +pub(crate) struct SetUp { + pub(crate) io: std::rc::Rc<std::cell::RefCell<BufferIO>>, + pub(crate) io_dyn: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, + pub(crate) pm: std::rc::Rc<std::cell::RefCell<PluginManager>>, autoload_generator: std::rc::Rc<std::cell::RefCell<AutoloadGenerator>>, packages: Vec<PackageInterfaceHandle>, - repository: InstalledRepositoryInterfaceHandle, + pub(crate) repository: InstalledRepositoryInterfaceHandle, // Keeps the Composer alive; PluginManager only holds a weak back-reference to it. - composer: ComposerHandle, + pub(crate) composer: ComposerHandle, // PHP's tearDown() removes this directory; TempDir does the same on drop. _directory: TempDir, } -fn set_up() -> SetUp { +pub(crate) fn set_up() -> SetUp { let loader = JsonLoader::new(Box::new(ArrayLoader::new(None, false))); let mut packages = vec![]; let directory = TempDir::new().unwrap(); @@ -380,7 +399,7 @@ fn plugin_property( } } -fn new_installer(set_up: &SetUp) -> PluginInstaller { +pub(crate) fn new_installer(set_up: &SetUp) -> PluginInstaller { PluginInstaller::new( set_up.io_dyn.clone(), set_up.composer.upcast().downgrade(), @@ -740,9 +759,9 @@ impl PluginInterface for CapablePlugin { } impl Capable for CapablePlugin { - fn get_capabilities(&self) -> IndexMap<String, String> { + fn get_capabilities(&self) -> anyhow::Result<IndexMap<String, String>> { *self.get_capabilities_calls.borrow_mut() += 1; - IndexMap::new() + Ok(IndexMap::new()) } } diff --git a/crates/shirabe/tests/plugin/subscriber_test.rs b/crates/shirabe/tests/plugin/subscriber_test.rs new file mode 100644 index 00000000..8de3d8c2 --- /dev/null +++ b/crates/shirabe/tests/plugin/subscriber_test.rs @@ -0,0 +1,109 @@ +//! Shirabe-specific integration tests for the subscriber plugin path: upstream Composer +//! has no test that exercises `addSubscriber`/`getSubscribedEvents` through a real plugin, so +//! these tests use a Shirabe-owned fixture (`fixtures/subscriber-v1`) instead of a ported one. + +use crate::async_runtime::run; +use crate::plugin_installer_test::{lock_php_worker, new_installer, php_runtime_available, set_up}; +use shirabe::installer::InstallerInterface; +use shirabe::package::PackageInterfaceHandle; +use shirabe::package::loader::{ArrayLoader, JsonLoader, JsonLoaderInput}; + +fn subscriber_fixture_package() -> PackageInterfaceHandle { + let loader = JsonLoader::new(Box::new(ArrayLoader::new(None, false))); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/plugin/fixtures/subscriber-v1/composer.json"); + loader + .load(JsonLoaderInput::String( + path.canonicalize().unwrap().to_str().unwrap().to_string(), + )) + .unwrap() +} + +/// Installs the subscriber fixture plugin: activate runs in the PHP child and +/// `addSubscriber` registers its listeners with the event dispatcher. +fn install_subscriber_plugin(set_up: &crate::plugin_installer_test::SetUp) { + let installer = new_installer(set_up); + set_up.pm.borrow_mut().load_installed_plugins().unwrap(); + run(installer.install(&set_up.repository, subscriber_fixture_package())).unwrap(); + assert_eq!("activate subscriber-v1\n", set_up.io.borrow().get_output()); +} + +fn dispatch(set_up: &crate::plugin_installer_test::SetUp, event_name: &str) -> i64 { + let dispatcher = set_up.composer.borrow().get_event_dispatcher(); + let result = dispatcher.borrow_mut().dispatch(Some(event_name), None); + result.unwrap() +} + +#[test] +fn test_subscriber_listener_receives_event() { + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + install_subscriber_plugin(&set_up); + + // 'post-install-cmd' => 'onPostInstall' (bare method-name shape); the listener calls + // $event->getName() back over RPC. + let return_code = dispatch(&set_up, "post-install-cmd"); + + assert_eq!(0, return_code); + assert_eq!( + "activate subscriber-v1\nsubscriber saw post-install-cmd\n", + set_up.io.borrow().get_output() + ); +} + +#[test] +fn test_subscriber_listeners_run_in_priority_order() { + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + install_subscriber_plugin(&set_up); + + // 'shirabe-priority-event' => [['early', 10], ['late', -10]] (multi-handler shape). + let return_code = dispatch(&set_up, "shirabe-priority-event"); + + assert_eq!(0, return_code); + assert_eq!( + "activate subscriber-v1\nearly listener\nlate listener\n", + set_up.io.borrow().get_output() + ); +} + +#[test] +fn test_subscriber_listener_returning_false_sets_return_code() { + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + install_subscriber_plugin(&set_up); + + // 'shirabe-false-event' => ['returnsFalse', 0] (method+priority shape); PHP maps a false + // listener return to exit code 1. + let return_code = dispatch(&set_up, "shirabe-false-event"); + + assert_eq!(1, return_code); + assert_eq!( + "activate subscriber-v1\nfailing listener\n", + set_up.io.borrow().get_output() + ); +} + +#[test] +fn test_unrelated_event_does_not_reach_the_subscriber() { + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + install_subscriber_plugin(&set_up); + + let return_code = dispatch(&set_up, "shirabe-unrelated-event"); + + assert_eq!(0, return_code); + assert_eq!("activate subscriber-v1\n", set_up.io.borrow().get_output()); +} |
