diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-03 01:30:26 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-03 01:30:26 +0900 |
| commit | 20f7a7826ae048d249e0d837ca6393a5f09c9ba6 (patch) | |
| tree | e35b541857c47543b54afcacf58b82ef3a7cce70 /crates | |
| parent | bf7ec47524a068c7fa7658f03f4dd44957f492a6 (diff) | |
| download | php-shirabe-20f7a7826ae048d249e0d837ca6393a5f09c9ba6.tar.gz php-shirabe-20f7a7826ae048d249e0d837ca6393a5f09c9ba6.tar.zst php-shirabe-20f7a7826ae048d249e0d837ca6393a5f09c9ba6.zip | |
feat(event-dispatcher): run composer.json PHP scripts through the RPC worker
Implement the two script execution paths that previously stopped at
todo!(): a Class::method listener is invoked as CallStaticMethod with the
event crossing the boundary as a proxy-stub handle, and a Command-class
listener runs inside a throwaway bare Symfony Application hosted by the
worker via a generated snippet, its BufferedOutput written back through
the dispatcher's IO. makeAutoloader is ported for real (canonical-package
hash, setDevMode, buildPackageMap/parseAutoloads/createLoader), and the
class_exists/is_callable/is_a/defined guards now query the worker, whose
script autoloader resolves classes by asking the Rust-side ClassLoader
over the reverse channel. EventInterface gains as_any (the IOInterface
downcast pattern) so the concrete event type is reachable behind the
trait object. Application::do_run now registers ScriptAliasCommand
entries as typed commands, unblocking the run-script --list/alias tests;
the dev-mode-to-generator test is ported with local mockall mocks. The
remaining ignored tests carry re-verified reasons: the listener methods
live on the PHPUnit test class itself (unloadable in the worker), or the
test needs live import of a user PHP Command class into the Application.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 98 | ||||
| -rw-r--r-- | crates/shirabe/src/event_dispatcher/event.rs | 7 | ||||
| -rw-r--r-- | crates/shirabe/src/event_dispatcher/event_dispatcher.rs | 534 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/installer_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/package_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/command_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/post_file_download_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/pre_command_run_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/pre_file_download_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/pre_pool_create_event.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/script/event.rs | 8 | ||||
| -rw-r--r-- | crates/shirabe/tests/command/run_script_command_test.rs | 49 | ||||
| -rw-r--r-- | crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs | 246 |
13 files changed, 799 insertions, 171 deletions
diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 56718164..af18cd04 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -2372,63 +2372,51 @@ impl ApplicationHandle { // if the command is not an array of commands, and points to a valid SymfonyCommand subclass, import its details directly let dummy_str = dummy.as_string().unwrap_or("").to_string(); - let cmd: PhpMixed = if is_string(dummy) - && shirabe_php_shim::class_exists(&dummy_str) - && is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\Command\\Command", - true, - ) { - if is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\SingleCommandApplication", - true, - ) { - io.write_error(&format!("<warning>The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.</warning>", script)); - } - let mut cmd = shirabe_php_shim::instantiate_class( - &dummy_str, - vec![PhpMixed::String(script.clone())], - ); - // TODO(phase-c): the script's command class is built by - // reflection (instantiate_class) and stays PhpMixed; the - // SingleCommandApplication / SymfonyCommand typed registry it - // belongs to is an external-package todo!() stub. - // let _ = SingleCommandApplication::new; - - // makes sure the command is find()'able by the name defined in composer.json, and the name isn't overridden in its configure() - // TODO(phase-c): cmd is the PhpMixed result of reflection - // instantiation; reading/overriding its - // name/description requires the typed SymfonyCommand model that - // the Symfony stub does not yet provide. - let _ = description; - let _ = &mut cmd; - cmd - } else { - // fallback to usual aliasing behavior - // TODO(phase-c): ScriptAliasCommand is a typed BaseCommand - // but this code path stores commands as PhpMixed; it can - // only be carried as a typed trait object once the Symfony - // command registry is modelled. - let _ = ScriptAliasCommand::new( - script.clone(), - Some(description), - aliases, - ); - PhpMixed::Null - }; + let cmd: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>> = + if is_string(dummy) + && shirabe_php_shim::class_exists(&dummy_str) + && is_subclass_of( + &PhpMixed::String(dummy_str.clone()), + "Symfony\\Component\\Console\\Command\\Command", + true, + ) + { + if is_subclass_of( + &PhpMixed::String(dummy_str.clone()), + "Symfony\\Component\\Console\\SingleCommandApplication", + true, + ) { + io.write_error(&format!("<warning>The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.</warning>", script)); + } + // TODO(plugin): `new $dummy($script)` instantiates the + // user's PHP command class in-process and registers the + // live object on this Application; hosting a PHP-owned + // command here needs the PHP-side Application / command + // proxying of the plugin milestones. The shim + // class_exists above never recognizes user classes, so + // this arm is currently unreachable. + let _ = shirabe_php_shim::instantiate_class( + &dummy_str, + vec![PhpMixed::String(script.clone())], + ); + todo!( + "plugin: import a user Command class as a live application command" + ); + } else { + // fallback to usual aliasing behavior + std::rc::Rc::new(std::cell::RefCell::new( + ScriptAliasCommand::new( + script.clone(), + Some(description), + aliases, + )?, + )) + }; // Compatibility layer for symfony/console <7.4 - // TODO(phase-c): Application::add() takes Rc<RefCell<dyn - // SymfonyCommand>> - // but `cmd` here is the PhpMixed result of reflection-based - // plugin command instantiation; registering it as a typed - // command instance is blocked on the Symfony command-registry - // model (external-package todo!() stub). - let _ = &cmd; - todo!( - "plugin: register reflection-instantiated command on Application::add" - ); + // (addCommand does not exist in the ported Application; add() + // is the only registration entry point.) + self.add(cmd)?; } } } diff --git a/crates/shirabe/src/event_dispatcher/event.rs b/crates/shirabe/src/event_dispatcher/event.rs index f04839f6..7075ada0 100644 --- a/crates/shirabe/src/event_dispatcher/event.rs +++ b/crates/shirabe/src/event_dispatcher/event.rs @@ -47,6 +47,9 @@ impl Event { } pub trait EventInterface: std::fmt::Debug { + /// For downcasting to the concrete event type (PHP `instanceof`), mirroring + /// `IOInterface::as_any`. + fn as_any(&self) -> &dyn std::any::Any; fn get_name(&self) -> &str; fn get_arguments(&self) -> &Vec<String>; fn get_flags(&self) -> &IndexMap<String, PhpMixed>; @@ -55,6 +58,10 @@ pub trait EventInterface: std::fmt::Debug { } impl EventInterface for Event { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { &self.name } diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index d9bf1720..2097c83d 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -20,15 +20,20 @@ use crate::util::Platform; use crate::util::ProcessExecutor; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; +use shirabe_external_packages::symfony::console::output::output_interface; 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, +}; use shirabe_php_shim::{ InvalidArgumentException, PATH_SEPARATOR, PhpMixed, RuntimeException, array_pop, array_push, - array_search_in_vec, array_splice, class_exists, defined, file_exists, get_class, implode, - ini_get, is_a, is_array, is_callable, is_object, is_string, krsort, php_regex, preg_quote, - realpath, spl_autoload_functions, spl_autoload_register, spl_autoload_unregister, - spl_object_hash, str_contains, str_ends_with, str_replace, str_starts_with, strlen, strpos, - strtoupper, substr, trim, + array_search_in_vec, array_splice, file_exists, get_class, hash, implode, ini_get, is_array, + is_callable, is_object, is_string, krsort, php_regex, preg_quote, realpath, + spl_autoload_functions, spl_autoload_register, spl_autoload_unregister, spl_object_hash, + str_contains, str_ends_with, str_replace, str_starts_with, strlen, strpos, strtoupper, substr, + trim, }; /// Represents a callable listener. PHP's `callable` may be a string (command, script, or @@ -345,7 +350,7 @@ impl EventDispatcher { ); let is_string_callable = matches!(callable, Callable::String(_)); if let Callable::Closure(ref closure) = callable { - let _ = self.make_autoloader(event, &callable); + self.make_autoloader(event, &callable)?; // Closures are always callable in PHP (is_callable() returns true for any \Closure), // so the is_callable()/RuntimeException branch below never applies here. r#return = if matches!(closure(event), PhpMixed::Bool(false)) { @@ -356,7 +361,7 @@ impl EventDispatcher { } 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. - let _ = self.make_autoloader(event, &callable); + self.make_autoloader(event, &callable)?; if !is_callable(&PhpMixed::Null) { let (class_name, method) = match &callable { Callable::ArrayCallable(first, m) => { @@ -536,9 +541,11 @@ impl EventDispatcher { let class_name = substr(callable_str, 0, Some(pos)); let method_name = substr(callable_str, pos + 2, None); - let _ = - self.make_autoloader(event, &Callable::String(callable_str.clone())); - if !class_exists(&class_name) { + self.make_autoloader(event, &Callable::String(callable_str.clone()))?; + if !self.php_runtime_bool( + "class_exists", + vec![PluginValue::string(class_name.clone())], + )? { self.io.write_error3(&format!( "<warning>Class {} is not autoloadable, can not call {} script</warning>", class_name, @@ -546,7 +553,10 @@ impl EventDispatcher { ), true, crate::io::QUIET); continue; } - if !is_callable(&PhpMixed::String(callable_str.clone())) { + if !self.php_runtime_bool( + "is_callable", + vec![PluginValue::string(callable_str.clone())], + )? { self.io.write_error3(&format!( "<warning>Method {} is not callable, can not call {} script</warning>", callable_str, @@ -576,14 +586,21 @@ impl EventDispatcher { Callable::String(ref callable_str) if self.is_command_class(callable_str) => { let class_name = callable_str.clone(); - let _ = self.make_autoloader( + self.make_autoloader( event, &Callable::ArrayCallable( Box::new(PhpMixed::String(callable_str.clone())), "run".to_string(), ), - ); - if !class_exists(&class_name) { + )?; + // The user's command class extends Symfony's Command, so the child + // process needs the real symfony/console classes before it can even + // autoload the user class. + self.ensure_composer_php_runtime()?; + if !self.php_runtime_bool( + "class_exists", + vec![PluginValue::string(class_name.clone())], + )? { self.io.write_error3(&format!( "<warning>Class {} is not autoloadable, can not call {} script</warning>", class_name, @@ -591,11 +608,16 @@ impl EventDispatcher { ), true, crate::io::QUIET); continue; } - if !is_a( - &PhpMixed::String(class_name.clone()), - "Symfony\\Component\\Console\\Command\\Command", - true, - ) { + if !self.php_runtime_bool( + "is_a", + vec![ + PluginValue::string(class_name.clone()), + PluginValue::string( + "Symfony\\Component\\Console\\Command\\Command", + ), + PluginValue::Bool(true), + ], + )? { self.io.write_error3(&format!( "<warning>Class {} does not extend Symfony\\Component\\Console\\Command\\Command, can not call {} script</warning>", class_name, @@ -603,10 +625,13 @@ impl EventDispatcher { ), true, crate::io::QUIET); continue; } - if defined(&format!( - "Composer\\Script\\ScriptEvents::{}", - str_replace("-", "_", &strtoupper(event.get_name())) - )) { + if self.php_runtime_bool( + "defined", + vec![PluginValue::string(format!( + "Composer\\Script\\ScriptEvents::{}", + str_replace("-", "_", &strtoupper(event.get_name())) + ))], + )? { self.io.write_error3(&format!( "<warning>You cannot bind {} to a Command class, use a non-reserved name</warning>", event.get_name() @@ -615,27 +640,132 @@ impl EventDispatcher { } // PHP hosts the user's Command class in a throwaway, bare - // `Symfony\Component\Console\Application` (NOT Composer's Application): - // $app = new Application(); - // $app->setCatchExceptions(false); - // $app->setAutoExit(false); - // $cmd = new $className($event->getName()); - // $app->add($cmd); - // $app->setDefaultCommand((string) $cmd->getName(), true); - // $return = $app->run(new StringInput(...), $output); - // - // TODO(plugin): a `scripts` entry naming a Symfony Command subclass is run by - // hosting it in a bare Symfony console Application. This requires the PHP - // runtime — both the dynamic `new $className(...)` instantiation and the real - // Symfony Application. It will be implemented by generating a PHP bootstrap - // (the boilerplate above) parameterized by the class name, event name and - // args, then executing it via the PHP runtime with the child process - // inheriting STDOUT/STDERR in place of reusing the in-memory output. No - // Rust-side Symfony Application is involved, so none is constructed here. - let _ = &additional_args; - todo!( - "plugin: run a `scripts` Command class via the PHP runtime (bare Symfony Application host)" + // `Symfony\Component\Console\Application` (NOT Composer's Application), + // built by a generated snippet running inside the worker. The command's + // output is captured in a BufferedOutput and written back through the + // dispatcher's IO; upstream hands the live output object of `$this->io` + // to `$app->run()` instead, so only the interleaving with concurrent + // writes differs. + let args = additional_args + .iter() + .map(|arg| ProcessExecutor::escape(arg)) + .collect::<Vec<_>>() + .join(" "); + let string_input = event + .get_flags() + .get("script-alias-input") + .and_then(|v| v.as_string().map(|s| s.to_string())) + .unwrap_or(args); + let verbosity = if self.io.is_debug() { + output_interface::VERBOSITY_DEBUG + } else if self.io.is_very_verbose() { + output_interface::VERBOSITY_VERY_VERBOSE + } else if self.io.is_verbose() { + output_interface::VERBOSITY_VERBOSE + } else { + output_interface::VERBOSITY_NORMAL + }; + let snippet = format!( + r#" +$className = {class_name_lit}; +$app = new \Symfony\Component\Console\Application(); +$app->setCatchExceptions(false); +if (method_exists($app, 'setCatchErrors')) {{ + $app->setCatchErrors(false); +}} +$app->setAutoExit(false); +$cmd = new $className({event_name_lit}); +if (method_exists($app, 'addCommand')) {{ + $app->addCommand($cmd); +}} else {{ + $app->add($cmd); +}} +$app->setDefaultCommand((string) $cmd->getName(), true); +$output = new \Symfony\Component\Console\Output\BufferedOutput({verbosity}, {decorated}); +try {{ + $status = $app->run(new \Symfony\Component\Console\Input\StringInput({input_lit}), $output); + return ['status' => $status, 'output' => $output->fetch()]; +}} catch (\Throwable $e) {{ + return ['throw' => [get_class($e), $e->getMessage(), (int) $e->getCode()], 'output' => $output->fetch()]; +}} +"#, + class_name_lit = php_single_quote(&class_name), + event_name_lit = php_single_quote(event.get_name()), + input_lit = php_single_quote(&string_input), + verbosity = verbosity, + decorated = if self.io.is_decorated() { + "true" + } else { + "false" + }, ); + self.ensure_script_autoloader()?; + let mut dispatcher = ScriptRpcDispatcher { + loader: self.loader.clone(), + event: None, + }; + let outcome = call_function_with_dispatcher( + "__shirabe_eval", + vec![PluginValue::string(snippet)], + Some(&mut dispatcher), + )?; + let result = match outcome { + Ok(value) => value.to_php_mixed()?, + Err(throw) => { + self.io.write_error3( + &format!( + "<error>Script {} handling the {} event terminated with an exception</error>", + callable_str.clone(), + event.get_name(), + ), + true, + crate::io::QUIET, + ); + return Err(anyhow::anyhow!(RuntimeException { + message: throw.message, + code: throw.code, + })); + } + }; + let command_output = result + .as_array() + .and_then(|map| map.get("output")) + .and_then(|v| v.as_string()) + .unwrap_or_default() + .to_string(); + if !command_output.is_empty() { + self.io.write3(&command_output, false, crate::io::NORMAL); + } + if let Some(throw) = result.as_array().and_then(|map| map.get("throw")) { + let fields = throw + .as_list() + .expect("the eval snippet reports exceptions as a list"); + let message = fields + .get(1) + .and_then(|v| v.as_string()) + .unwrap_or_default() + .to_string(); + let code = match fields.get(2) { + Some(PhpMixed::Int(code)) => *code, + _ => 0, + }; + self.io.write_error3( + &format!( + "<error>Script {} handling the {} event terminated with an exception</error>", + callable_str.clone(), + event.get_name(), + ), + true, + crate::io::QUIET, + ); + return Err(anyhow::anyhow!(RuntimeException { message, code })); + } + r#return = match result.as_array().and_then(|map| map.get("status")) { + Some(PhpMixed::Int(status)) => *status, + other => panic!( + "the eval snippet always returns an int status, got {other:?}" + ), + }; } Callable::String(callable_str) => { let args = additional_args @@ -904,8 +1034,52 @@ impl EventDispatcher { ); } - // TODO(plugin): invoke `$className::$methodName($event)` dynamically - todo!("dynamic static method invocation requires plugin runtime") + // 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 { + // 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 { + message: format!( + "no proxy stub is available yet for the event `{}` dispatched to {}::{}", + event.get_name(), + class_name, + method_name, + ), + code: 0, + })); + }; + + self.ensure_script_autoloader()?; + let rhandle = shirabe_php_rpc::alloc_rhandle(); + let mut dispatcher = ScriptRpcDispatcher { + loader: self.loader.clone(), + event: Some((rhandle, event)), + }; + let outcome = call_static_method( + class_name, + method_name, + vec![PluginValue::RustHandle(RustObjHandle { + rhandle, + class: stub_class.to_string(), + epoch: 0, + snapshot: None, + })], + Some(&mut dispatcher), + )?; + match outcome { + Ok(value) => Ok(value.to_php_mixed()?), + // TODO(plugin): the original exception class is collapsed to RuntimeException on + // this side of the boundary. + Err(throw) => Err(anyhow::anyhow!(RuntimeException { + message: throw.message, + code: throw.code, + })), + } } fn event_needs_to_output(&self, event: &dyn EventInterface) -> bool { @@ -1173,20 +1347,161 @@ impl EventDispatcher { event: &dyn EventInterface, callable: &Callable, ) -> anyhow::Result<()> { - // TODO(plugin): full autoloader rebuild on plugin-supplied/script-listener callables — - // a genuine no-op here, not merely a stub. All 3 call sites already discard the return - // value, and rebuilding+registering a ClassLoader has no observable effect in this port: - // there is no embedded PHP interpreter to register it into, and `class_exists` for - // user-defined classes is a hardcoded-false shim, so the caller's very next check always - // treats the class as unavailable regardless of what this function does. Also, every - // caller reaches this from inside AutoloadGenerator::dump(), which is invoked while a - // caller higher up the stack still holds the local-repository/installation-manager - // RefCells borrowed for the duration of its own statement — doing the real work here - // (which needs those same RefCells) would panic with "already borrowed". - let _ = (event, callable); + let composer = self.composer(); + let Some(composer) = composer.as_full() else { + return Ok(()); + }; + + let callable_key = match callable { + Callable::String(callable_str) => callable_str.clone(), + Callable::ArrayCallable(first, method) => match first.as_ref() { + PhpMixed::String(class) => format!("{}::{}", class, method), + other => format!("{}::{}", get_class(other), method), + }, + Callable::Closure(_) => "closure".to_string(), + }; + if self.previous_listeners.contains_key(&callable_key) { + return Ok(()); + } + self.previous_listeners.insert(callable_key, true); + + let package = composer.borrow().get_package().clone(); + let repository_manager = composer.borrow().get_repository_manager(); + let local_repository = repository_manager.borrow().get_local_repository(); + let packages = local_repository.get_canonical_packages()?; + let generator = composer.borrow().get_autoload_generator(); + let mut hash_input = packages + .iter() + .map(|p| format!("{}/{}", p.get_name(), p.get_version())) + .collect::<Vec<_>>() + .join(","); + let dev_mode = event + .as_any() + .downcast_ref::<ScriptEvent>() + .map(|e| e.is_dev_mode()) + .or_else(|| { + event + .as_any() + .downcast_ref::<PackageEvent>() + .map(|e| e.is_dev_mode()) + }) + .or_else(|| { + event + .as_any() + .downcast_ref::<InstallerEvent>() + .map(|e| e.is_dev_mode()) + }); + if let Some(dev_mode) = dev_mode { + generator.borrow_mut().set_dev_mode(dev_mode); + if dev_mode { + hash_input.push_str("/dev"); + } + } + let hash = hash("sha256", &hash_input); + + if self.previous_hash.as_deref() == Some(hash.as_str()) { + return Ok(()); + } + + self.previous_hash = Some(hash); + + let installation_manager = composer.borrow().get_installation_manager(); + let package_map = generator.borrow().build_package_map( + &mut *installation_manager.borrow_mut(), + package.clone(), + packages, + )?; + let map = generator + .borrow() + .parse_autoloads(package_map, package, PhpMixed::Bool(false)); + + if let Some(loader) = &self.loader { + loader.unregister(); + } + + let vendor_dir = composer + .borrow() + .get_config() + .borrow() + .get("vendor-dir") + .as_string() + .map(|s| s.to_string()); + let loader = generator.borrow().create_loader(&map, vendor_dir); + loader.register(false); + self.loader = Some(loader); Ok(()) } + /// Makes the worker's script-class autoloader active, so class queries and script execution + /// in the child can resolve classes through the Rust-side [`ClassLoader`] built by + /// [`Self::make_autoloader`]. + fn ensure_script_autoloader(&self) -> anyhow::Result<()> { + unwrap_php_result(call_function( + "__shirabe_enable_script_autoloader", + Vec::new(), + ))?; + Ok(()) + } + + /// Loads the Composer PHP runtime (symfony/console and friends) into the worker, needed + /// before a `scripts` Command class can be autoloaded and hosted. + fn ensure_composer_php_runtime(&self) -> anyhow::Result<()> { + // TODO(plugin): the real PHP classes are taken from a Composer checkout for now; how + // they ship with a released Shirabe binary is part of the plugin distribution work. + let autoload = Self::composer_php_runtime_autoload().ok_or_else(|| { + anyhow::anyhow!(RuntimeException { + message: "unable to locate the Composer PHP runtime; set SHIRABE_COMPOSER_PHP_DIR \ + to a Composer checkout with its vendor directory installed" + .to_string(), + code: 0, + }) + })?; + unwrap_php_result(call_function( + "__shirabe_require", + vec![PluginValue::string(autoload)], + ))?; + Ok(()) + } + + fn composer_php_runtime_autoload() -> Option<String> { + if let Some(dir) = Platform::get_env("SHIRABE_COMPOSER_PHP_DIR") { + let path = std::path::Path::new(&dir) + .join("vendor") + .join("autoload.php"); + if path.is_file() { + return path.to_str().map(|s| s.to_string()); + } + } + // Development fallback: the Composer checkout sitting next to this workspace. + let dev = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/vendor/autoload.php"); + if dev.is_file() { + return dev.canonicalize().ok()?.to_str().map(|s| s.to_string()); + } + None + } + + /// Runs a boolean runtime query (`class_exists`, `is_a`, ...) inside the PHP worker, with + /// the script autoloader active so the query can trigger class loading. + fn php_runtime_bool(&self, function: &str, args: Vec<PluginValue>) -> anyhow::Result<bool> { + self.ensure_script_autoloader()?; + let mut dispatcher = ScriptRpcDispatcher { + loader: self.loader.clone(), + event: None, + }; + let value = unwrap_php_result(call_function_with_dispatcher( + function, + args, + Some(&mut dispatcher), + ))?; + match value { + PluginValue::Bool(value) => Ok(value), + other => Err(anyhow::anyhow!( + "PHP runtime query `{function}` did not return a bool: {other:?}" + )), + } + } + fn io_clone(&self) -> std::rc::Rc<std::cell::RefCell<dyn IOInterface>> { self.io.clone() } @@ -1211,6 +1526,113 @@ impl EventDispatcher { } } +/// Serves `CallRustMethod` requests issued by the PHP worker while a script-related call is in +/// flight: Rust handle 0 is the runtime service endpoint (autoload lookups against the +/// Rust-side [`ClassLoader`]), and at most one live event handle is exposed per dispatched +/// call. +/// +/// TODO(plugin): this per-call scope stands in for the persistent R table of the plugin +/// activation milestone; a stub retained by the script beyond the call observes an unknown +/// handle error instead of the live object. +struct ScriptRpcDispatcher<'a> { + loader: Option<ClassLoader>, + event: Option<(u64, &'a dyn EventInterface)>, +} + +impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { + fn dispatch( + &mut self, + rhandle: u64, + method_name: &str, + args: Vec<PluginValue>, + _out_param_positions: &[u32], + ) -> Result<PluginValue, PhpThrow> { + if rhandle == 0 { + if method_name == "__shirabe_find_file" { + let class = match args.first() { + Some(PluginValue::String(bytes)) => String::from_utf8_lossy(bytes).into_owned(), + other => { + return Err(runtime_throw(format!( + "__shirabe_find_file expects a class name argument, got {other:?}" + ))); + } + }; + return Ok( + match self + .loader + .as_mut() + .and_then(|loader| loader.find_file(&class)) + { + Some(file) => PluginValue::string(file), + None => PluginValue::Null, + }, + ); + } + return Err(runtime_throw(format!( + "unknown runtime service method `{method_name}`" + ))); + } + 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" + ))), + }, + _ => Err(runtime_throw(format!( + "unknown Rust handle {rhandle} (script-event handles are scoped to a single \ + dispatched call)" + ))), + } + } +} + +fn runtime_throw(message: String) -> PhpThrow { + PhpThrow { + exception_class: "RuntimeException".to_string(), + message, + code: 0, + } +} + +/// Collapses the two failure lanes of an RPC call into `anyhow`: the callers here treat a PHP +/// exception raised during a runtime query as fatal for the current dispatch. +fn unwrap_php_result( + outcome: anyhow::Result<Result<PluginValue, PhpThrow>>, +) -> anyhow::Result<PluginValue> { + match outcome? { + Ok(value) => Ok(value), + Err(throw) => Err(anyhow::anyhow!(RuntimeException { + message: throw.message, + code: throw.code, + })), + } +} + +/// Quotes a string as a PHP single-quoted literal for a generated snippet. +fn php_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + // Composer's PartialComposer::setEventDispatcher() accepts any EventDispatcher subclass, so plugins // may swap in a replacement. The interface captures the methods reached through Composer's accessor // and through the `Rc<RefCell<dyn EventDispatcherInterface>>` references fed from it. diff --git a/crates/shirabe/src/installer/installer_event.rs b/crates/shirabe/src/installer/installer_event.rs index 5171b6d0..fac42a05 100644 --- a/crates/shirabe/src/installer/installer_event.rs +++ b/crates/shirabe/src/installer/installer_event.rs @@ -60,6 +60,10 @@ impl InstallerEvent { } impl EventInterface for InstallerEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/installer/package_event.rs b/crates/shirabe/src/installer/package_event.rs index 28a8a2ea..9435a581 100644 --- a/crates/shirabe/src/installer/package_event.rs +++ b/crates/shirabe/src/installer/package_event.rs @@ -71,6 +71,10 @@ impl PackageEvent { } impl EventInterface for PackageEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/plugin/command_event.rs b/crates/shirabe/src/plugin/command_event.rs index ca63461d..afd93fbb 100644 --- a/crates/shirabe/src/plugin/command_event.rs +++ b/crates/shirabe/src/plugin/command_event.rs @@ -60,6 +60,10 @@ impl CommandEvent { } impl EventInterface for CommandEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/plugin/post_file_download_event.rs b/crates/shirabe/src/plugin/post_file_download_event.rs index 88fc181b..a9cadc44 100644 --- a/crates/shirabe/src/plugin/post_file_download_event.rs +++ b/crates/shirabe/src/plugin/post_file_download_event.rs @@ -60,6 +60,10 @@ impl PostFileDownloadEvent { } impl EventInterface for PostFileDownloadEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/plugin/pre_command_run_event.rs b/crates/shirabe/src/plugin/pre_command_run_event.rs index 854c47f0..50a182b6 100644 --- a/crates/shirabe/src/plugin/pre_command_run_event.rs +++ b/crates/shirabe/src/plugin/pre_command_run_event.rs @@ -42,6 +42,10 @@ impl PreCommandRunEvent { } impl EventInterface for PreCommandRunEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/plugin/pre_file_download_event.rs b/crates/shirabe/src/plugin/pre_file_download_event.rs index 9ec74a07..73d59a5b 100644 --- a/crates/shirabe/src/plugin/pre_file_download_event.rs +++ b/crates/shirabe/src/plugin/pre_file_download_event.rs @@ -78,6 +78,10 @@ impl PreFileDownloadEvent { } impl EventInterface for PreFileDownloadEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/plugin/pre_pool_create_event.rs b/crates/shirabe/src/plugin/pre_pool_create_event.rs index 0ffde945..9547ac8b 100644 --- a/crates/shirabe/src/plugin/pre_pool_create_event.rs +++ b/crates/shirabe/src/plugin/pre_pool_create_event.rs @@ -95,6 +95,10 @@ impl PrePoolCreateEvent { } impl EventInterface for PrePoolCreateEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } diff --git a/crates/shirabe/src/script/event.rs b/crates/shirabe/src/script/event.rs index 1b1e76dd..b17e77cf 100644 --- a/crates/shirabe/src/script/event.rs +++ b/crates/shirabe/src/script/event.rs @@ -75,6 +75,10 @@ impl Event { } impl EventInterface for Event { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { self.inner.get_name() } @@ -97,6 +101,10 @@ impl EventInterface for Event { } impl EventInterface for OriginatingEvent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn get_name(&self) -> &str { match self { OriginatingEvent::Base(e) => e.get_name(), diff --git a/crates/shirabe/tests/command/run_script_command_test.rs b/crates/shirabe/tests/command/run_script_command_test.rs index 6e40bde0..74d05758 100644 --- a/crates/shirabe/tests/command/run_script_command_test.rs +++ b/crates/shirabe/tests/command/run_script_command_test.rs @@ -10,33 +10,25 @@ use shirabe_php_shim::PhpMixed; /// `ScriptEvent` passed to `hasEventListeners` matches the script name AND its `isDevMode()` equals /// the computed dev mode (`dev || !noDev`) -- the latter being the whole point of the test. #[test] -#[ignore = "PHP asserts (a) via mocked hasEventListeners that the ScriptEvent has isDevMode() == \ - (dev || !no_dev) and (b) via mocked dispatchScript that it is called once with \ - ($script, $expectedDevMode, []). Neither expectation is expressible: (a) the \ - __set_get_listeners_override callback only sees &dyn EventInterface \ - (src/event_dispatcher/event.rs:49), which has no as_any/downcast seam to reach the \ - concrete ScriptEvent::is_dev_mode (src/script/event.rs:51) -- adding one is a \ - cross-cutting trait change over every event type, not a small test seam; (b) \ - dispatch_script is a concrete method with no call-recording seam, and letting the \ - real one run would execute listeners for real. The faithful body is therefore \ - inexpressible and is left as todo!()."] +#[ignore = "PHP mocks RunScriptCommand itself (onlyMethods incl. requireComposer -> a composer \ + whose EventDispatcher is a hasEventListeners/dispatchScript recording mock) and \ + drives run() with mocked Input/Output. The Rust RunScriptCommand has no \ + requireComposer override seam and Input/Output are concrete types, so the mocked \ + harness is inexpressible; the event-side isDevMode downcast now exists \ + (EventInterface::as_any), but that alone does not unblock the test."] fn test_detect_and_pass_dev_mode_to_event_and_to_dispatching() { - // TODO(phase-d): PHP asserts (a) via mocked hasEventListeners that the ScriptEvent has - // isDevMode() == (dev || !no_dev) and (b) via mocked dispatchScript that it is called once - // with ($script, $expectedDevMode, []). Neither expectation is expressible: (a) the - // __set_get_listeners_override callback only sees &dyn EventInterface - // (src/event_dispatcher/event.rs:49), which has no as_any/downcast seam to reach the concrete - // ScriptEvent::is_dev_mode (src/script/event.rs:51) -- adding one is a cross-cutting trait - // change over every event type, not a small test seam; (b) dispatch_script is a concrete - // method with no call-recording seam, and letting the real one run would execute listeners - // for real. The faithful body is therefore inexpressible and is left as todo!(). + // TODO(phase-d): PHP mocks RunScriptCommand itself (onlyMethods incl. requireComposer -> a + // composer whose EventDispatcher is a hasEventListeners/dispatchScript recording mock) and + // drives run() with mocked Input/Output. The Rust RunScriptCommand has no requireComposer + // override seam and Input/Output are concrete types, so the mocked harness is + // inexpressible; the event-side isDevMode downcast now exists (EventInterface::as_any), but + // that alone does not unblock the test. todo!() } /// ref: RunScriptCommandTest::testCanListScripts #[test] #[serial] -#[ignore = "Application::do_run registers composer.json scripts as commands; that path calls loader.register (class_loader.rs:288 -> spl_autoload_register at runtime.rs:231) which is a todo!() stub. With a 'scripts' key present, app_tester.run() panics there before the command executes"] fn test_can_list_scripts() { let tear_down = init_temp_composer( Some(&serde_json::json!({ @@ -82,7 +74,6 @@ fn test_can_list_scripts() { /// ref: RunScriptCommandTest::testCanDefineAliases #[test] #[serial] -#[ignore = "Application::do_run registers composer.json scripts as commands; that path calls loader.register (class_loader.rs:288 -> spl_autoload_register at runtime.rs:231) which is a todo!() stub. With a 'scripts' key present, app_tester.run() panics there before the command executes"] fn test_can_define_aliases() { let expected_aliases = vec!["one", "two", "three"]; @@ -131,19 +122,19 @@ fn test_can_define_aliases() { } #[test] -#[ignore = "requires writing and executing a PHP-generated Symfony Command class (file_put_contents MyCommand.php) loaded via composer autoload; fundamentally unportable, no PHP runtime command loading in shirabe"] +#[ignore = "the test invokes the script name as a top-level composer command, which requires Application::do_run to import the user's PHP Command class (MyCommand.php) as a live application command (todo!() in application.rs; PHP-side Application milestone). The EventDispatcher-side Command-class path alone cannot satisfy the direct invocation and its argument definitions"] fn test_execution_of_simple_symfony_command() { - // TODO(phase-d): requires writing and executing a PHP-generated Symfony Command class - // (file_put_contents MyCommand.php) loaded via composer autoload; fundamentally unportable, no - // PHP runtime command loading in shirabe. + // TODO(phase-d): the test invokes the script name as a top-level composer command, which + // requires Application::do_run to import the user's PHP Command class (MyCommand.php) as a + // live application command (todo!() in application.rs; PHP-side Application milestone). todo!() } #[test] -#[ignore = "requires writing and executing a PHP-generated Symfony Command class (file_put_contents MyCommandWithDefinitions.php) loaded via composer autoload; fundamentally unportable, no PHP runtime command loading in shirabe"] +#[ignore = "the test invokes the script name as a top-level composer command, which requires Application::do_run to import the user's PHP Command class (MyCommandWithDefinitions.php) as a live application command (todo!() in application.rs; PHP-side Application milestone). The EventDispatcher-side Command-class path alone cannot satisfy the direct invocation and its argument definitions"] fn test_execution_of_symfony_command_with_configuration() { - // TODO(phase-d): requires writing and executing a PHP-generated Symfony Command class - // (file_put_contents MyCommandWithDefinitions.php) loaded via composer autoload; fundamentally - // unportable, no PHP runtime command loading in shirabe. + // TODO(phase-d): the test invokes the script name as a top-level composer command, which + // requires Application::do_run to import the user's PHP Command class (MyCommandWithDefinitions.php) + // as a live application command (todo!() in application.rs; PHP-side Application milestone). todo!() } diff --git a/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs b/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs index e8413c17..e9620158 100644 --- a/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs +++ b/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs @@ -3,20 +3,30 @@ use crate::process_executor_mock::{ProcessExecutorMockGuard, cmd, get_process_executor_mock}; use indexmap::IndexMap; use serial_test::serial; +use shirabe::autoload::{AutoloadGeneratorInterface, ClassLoader}; use shirabe::composer::{ComposerHandle, PartialOrFullComposer}; use shirabe::config::Config; use shirabe::dependency_resolver::Transaction; +use shirabe::dependency_resolver::operation::AnyOperation; use shirabe::event_dispatcher::{Callable, EventDispatcher, EventInterface}; -use shirabe::installer::InstallerEvents; +use shirabe::filter::PlatformRequirementFilterInterface; +use shirabe::installer::{InstallationManagerInterface, InstallerEvents, InstallerInterface}; use shirabe::io::IOInterface; use shirabe::io::buffer_io::BufferIO; -use shirabe::package::{RootPackageHandle, RootPackageInterfaceHandle}; +use shirabe::package::{ + LockerInterface, PackageInterfaceHandle, RootPackageHandle, RootPackageInterfaceHandle, +}; +use shirabe::repository::{ + InstalledArrayRepository, InstalledRepositoryInterface, RepositoryInterfaceHandle, + RepositoryManagerInterface, +}; use shirabe::script::Event as ScriptEvent; use shirabe::script::ScriptEvents; use shirabe::util::platform::Platform; use shirabe::util::process_executor::{MockHandler, ProcessExecutor}; +use shirabe_class_map_generator::class_map::ClassMap; use shirabe_external_packages::symfony::console::output::output_interface; -use shirabe_php_shim::PHP_EOL; +use shirabe_php_shim::{PHP_EOL, PhpMixed}; fn tear_down() { Platform::clear_env("COMPOSER_SKIP_SCRIPTS"); @@ -322,32 +332,206 @@ fn test_dispatcher_doesnt_return_skipped_scripts() { let _ = &mut event; } -// The remaining ignored tests drive listeners that invoke PHP scripts (`Class::method`), require -// the autoloader rebuild of `make_autoloader` (an intentional no-op in the port), or rely on -// object-identity callables. None of those seams exist in the Rust port (the PHP-script -// invocation path is an unimplemented plugin-runtime `todo!`), so they remain ignored. +// The remaining ignored tests use, as their listeners, static methods of the PHPUnit test class +// `Composer\Test\EventDispatcher\EventDispatcherTest` itself (or object-identity array +// callables). The PHP-script invocation path is implemented (execute_event_php_script sends a +// CallStaticMethod over the RPC channel), but the worker child process cannot load that test +// class: it extends PHPUnit\Framework\TestCase and phpunit is not part of composer/vendor. +// Making these pass needs a decision on how to provide the listener methods to the child (e.g. a +// stand-in fixture class with the same FQCN and method bodies), which is not a call to make +// unilaterally under the no-test-alteration rule. #[test] -#[ignore = "listener `EventDispatcherTest::call` is a PHP-script callable; dynamic static-method invocation requires the plugin runtime (execute_event_php_script is todo!())"] +#[ignore = "listener `EventDispatcherTest::call` is a static method of the PHPUnit test class itself; the PHP worker cannot load it (extends PHPUnit\\Framework\\TestCase, phpunit absent from composer/vendor) — see the note above the ignored block"] fn test_listener_exceptions_are_caught() { let _tear_down = TearDown; - // TODO(phase-d): listener `EventDispatcherTest::call` is a PHP-script callable; dynamic - // static-method invocation requires the plugin runtime (execute_event_php_script is todo!()) + // TODO(phase-d): the listener is a static method of the PHPUnit test class itself, which + // the PHP worker cannot load (phpunit is absent from composer/vendor); pending a decision on + // providing the listener methods to the child process. todo!() } +// PHP mocks `Composer\Autoload\AutoloadGenerator` with onlyMethods(['buildPackageMap', +// 'parseAutoloads', 'createLoader', 'setDevMode']). +mockall::mock! { + #[derive(Debug)] + pub AutoloadGenerator {} + impl AutoloadGeneratorInterface for AutoloadGenerator { + fn set_dev_mode(&mut self, dev_mode: bool); + fn set_class_map_authoritative(&mut self, class_map_authoritative: bool); + fn set_apcu(&mut self, apcu: bool, apcu_prefix: Option<String>); + fn set_run_scripts(&mut self, run_scripts: bool); + fn set_dry_run(&mut self, dry_run: bool); + fn set_platform_requirement_filter( + &mut self, + platform_requirement_filter: std::rc::Rc<dyn PlatformRequirementFilterInterface>, + ); + fn dump<'a>( + &mut self, + config: &Config, + local_repo: &mut dyn InstalledRepositoryInterface, + root_package: RootPackageInterfaceHandle, + installation_manager: &mut dyn InstallationManagerInterface, + target_dir: &str, + scan_psr_packages: bool, + suffix: Option<String>, + locker: Option<&'a mut dyn LockerInterface>, + strict_ambiguous: bool, + ) -> anyhow::Result<ClassMap>; + fn build_package_map( + &self, + installation_manager: &mut dyn InstallationManagerInterface, + root_package: RootPackageInterfaceHandle, + packages: Vec<PackageInterfaceHandle>, + ) -> anyhow::Result<Vec<(PackageInterfaceHandle, Option<String>)>>; + fn parse_autoloads( + &self, + package_map: Vec<(PackageInterfaceHandle, Option<String>)>, + root_package: RootPackageInterfaceHandle, + filtered_dev_packages: PhpMixed, + ) -> IndexMap<String, PhpMixed>; + fn create_loader<'a>( + &self, + autoloads: &IndexMap<String, PhpMixed>, + vendor_dir: Option<String>, + ) -> ClassLoader; + } +} + +// PHP mocks `Composer\Repository\RepositoryManager` with onlyMethods(['getLocalRepository']). +mockall::mock! { + #[derive(Debug)] + pub RepositoryManager {} + impl RepositoryManagerInterface for RepositoryManager { + fn get_local_repository(&self) -> RepositoryInterfaceHandle; + fn get_repositories(&self) -> &Vec<RepositoryInterfaceHandle>; + fn create_repository<'a>( + &self, + r#type: &str, + config: IndexMap<String, PhpMixed>, + name: Option<&'a str>, + ) -> anyhow::Result<RepositoryInterfaceHandle>; + fn add_repository(&mut self, repository: RepositoryInterfaceHandle); + fn set_local_repository(&mut self, repository: RepositoryInterfaceHandle); + } +} + +// PHP mocks `Composer\Installer\InstallationManager` with disableOriginalConstructor(). +mockall::mock! { + #[derive(Debug)] + pub InstallationManager {} + impl InstallationManagerInterface for InstallationManager { + fn add_installer(&mut self, installer: Box<dyn InstallerInterface>); + fn remove_installer(&mut self, installer: &dyn InstallerInterface); + fn disable_plugins(&mut self); + fn is_package_installed( + &mut self, + repo: &mut dyn InstalledRepositoryInterface, + package: PackageInterfaceHandle, + ) -> anyhow::Result<bool>; + fn ensure_binaries_presence(&mut self, package: PackageInterfaceHandle); + fn execute( + &mut self, + repo: &mut dyn InstalledRepositoryInterface, + operations: Vec<AnyOperation>, + dev_mode: bool, + run_scripts: bool, + download_only: bool, + ) -> anyhow::Result<()>; + fn get_install_path(&self, package: PackageInterfaceHandle) -> Option<String>; + fn set_output_progress(&mut self, output_progress: bool); + fn notify_installs(&mut self, io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>); + } +} + +/// ref: EventDispatcherTest::testDispatcherPassDevModeToAutoloadGeneratorForScriptEvents #[test] -#[ignore = "EventDispatcher::make_autoloader (PHP makeAutoloader, called from doDispatch's script branches) is an intentional no-op in the port, so AutoloadGeneratorInterface::set_dev_mode is never invoked and a set_dev_mode spy would observe nothing"] +#[serial] fn test_dispatcher_pass_dev_mode_to_autoload_generator_for_script_events() { let _tear_down = TearDown; - // TODO(phase-d): the PHP test spies on AutoloadGenerator::setDevMode, which PHP calls from - // makeAutoloader (invoked from doDispatch's script branches; it rebuilds and registers the - // project autoloader — loader->unregister, setDevMode(event->isDevMode()), buildPackageMap, - // parseAutoloads, createLoader->register — so that PHP-script listeners can be invoked). The - // Rust EventDispatcher::make_autoloader is an intentional no-op (see its TODO(plugin) - // marker), so set_dev_mode is never reached. A spy could be written against - // `dyn AutoloadGeneratorInterface` once make_autoloader does the real work. - todo!() + if !ensure_php_binary() { + // The php-script listener path queries class_exists through the PHP worker. + return; + } + + // dataProvider provideDevModes + for dev_mode in [true, false] { + let composer = create_composer_instance(); + + let mut generator = MockAutoloadGenerator::new(); + generator + .expect_set_dev_mode() + .with(mockall::predicate::eq(dev_mode)) + .times(1..) + .return_const(()); + generator + .expect_build_package_map() + .returning(|_, _, _| Ok(Vec::new())); + generator.expect_parse_autoloads().returning(|_, _, _| { + [ + ("psr-0".to_string(), PhpMixed::List(vec![])), + ("psr-4".to_string(), PhpMixed::List(vec![])), + ("classmap".to_string(), PhpMixed::List(vec![])), + ("files".to_string(), PhpMixed::List(vec![])), + ("exclude-from-classmap".to_string(), PhpMixed::List(vec![])), + ] + .into_iter() + .collect() + }); + generator + .expect_create_loader() + .returning(|_, _| ClassLoader::new(None)); + composer + .borrow_mut() + .set_autoload_generator(std::rc::Rc::new(std::cell::RefCell::new(generator))); + + let package: RootPackageInterfaceHandle = RootPackageHandle::new( + "foo".to_string(), + "1.0.0.0".to_string(), + "1.0.0".to_string(), + ) + .into(); + let mut scripts: IndexMap<String, Vec<String>> = IndexMap::new(); + scripts.insert( + "scriptName".to_string(), + vec!["ClassName::testMethod".to_string()], + ); + package.set_scripts(scripts); + composer.borrow_mut().set_package(package); + + let mut repository_manager = MockRepositoryManager::new(); + repository_manager + .expect_get_local_repository() + .returning(|| RepositoryInterfaceHandle::new(InstalledArrayRepository::new().unwrap())); + composer + .borrow_mut() + .set_repository_manager(std::rc::Rc::new(std::cell::RefCell::new( + repository_manager, + ))); + composer + .borrow_mut() + .set_installation_manager(std::rc::Rc::new(std::cell::RefCell::new( + MockInstallationManager::new(), + ))); + + let (process, _process_guard) = + get_process_executor_mock(vec![], false, MockHandler::default()); + let mut dispatcher = + EventDispatcher::new(composer.upcast().downgrade(), null_io(), Some(process)); + + let mut event = ScriptEvent::new( + "scriptName".to_string(), + composer.downgrade(), + null_io(), + dev_mode, + Vec::new(), + IndexMap::new(), + ); + + dispatcher + .dispatch(Some("scriptName"), Some(&mut event)) + .unwrap(); + } } #[test] @@ -361,32 +545,32 @@ fn test_dispatcher_remove_listener() { } #[test] -#[ignore = "mixes a PHP-script listener (EventDispatcherTest::someMethod) into the stack; dynamic static-method invocation requires the plugin runtime (execute_event_php_script is todo!())"] +#[ignore = "listener `EventDispatcherTest::someMethod` is a static method of the PHPUnit test class itself; the PHP worker cannot load it — see the note above the ignored block"] fn test_dispatcher_can_execute_cli_and_php_in_same_event_script_stack() { let _tear_down = TearDown; - // TODO(phase-d): mixes a PHP-script listener (EventDispatcherTest::someMethod) into the - // stack; dynamic static-method invocation requires the plugin runtime - // (execute_event_php_script is todo!()) + // TODO(phase-d): the PHP-script listener is a static method of the PHPUnit test class + // itself, which the PHP worker cannot load; pending a decision on providing the listener + // methods to the child process. todo!() } #[test] -#[ignore = "second listener EventDispatcherTest::getTestEnv is a PHP-script callable; dynamic static-method invocation requires the plugin runtime (execute_event_php_script is todo!())"] +#[ignore = "listener `EventDispatcherTest::getTestEnv` is a static method of the PHPUnit test class itself; the PHP worker cannot load it — see the note above the ignored block"] fn test_dispatcher_can_put_env() { let _tear_down = TearDown; - // TODO(phase-d): second listener EventDispatcherTest::getTestEnv is a PHP-script callable; - // dynamic static-method invocation requires the plugin runtime (execute_event_php_script is - // todo!()) + // TODO(phase-d): the second listener is a static method of the PHPUnit test class itself, + // which the PHP worker cannot load; pending a decision on providing the listener methods to + // the child process. todo!() } #[test] -#[ignore = "listeners are PHP-script callables (createsVendorBinFolderChecksEnv*) asserting on PATH; dynamic static-method invocation requires the plugin runtime (execute_event_php_script is todo!())"] +#[ignore = "listeners (createsVendorBinFolderChecksEnv*) are static methods of the PHPUnit test class itself; the PHP worker cannot load them — see the note above the ignored block"] fn test_dispatcher_appends_dir_bin_on_path_for_every_listener() { let _tear_down = TearDown; - // TODO(phase-d): listeners are PHP-script callables (createsVendorBinFolderChecksEnv*) - // asserting on PATH; dynamic static-method invocation requires the plugin runtime - // (execute_event_php_script is todo!()) + // TODO(phase-d): the listeners are static methods of the PHPUnit test class itself, which + // the PHP worker cannot load; pending a decision on providing the listener methods to the + // child process. todo!() } |
