aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe/src/console/application.rs98
-rw-r--r--crates/shirabe/src/event_dispatcher/event.rs7
-rw-r--r--crates/shirabe/src/event_dispatcher/event_dispatcher.rs534
-rw-r--r--crates/shirabe/src/installer/installer_event.rs4
-rw-r--r--crates/shirabe/src/installer/package_event.rs4
-rw-r--r--crates/shirabe/src/plugin/command_event.rs4
-rw-r--r--crates/shirabe/src/plugin/post_file_download_event.rs4
-rw-r--r--crates/shirabe/src/plugin/pre_command_run_event.rs4
-rw-r--r--crates/shirabe/src/plugin/pre_file_download_event.rs4
-rw-r--r--crates/shirabe/src/plugin/pre_pool_create_event.rs4
-rw-r--r--crates/shirabe/src/script/event.rs8
-rw-r--r--crates/shirabe/tests/command/run_script_command_test.rs49
-rw-r--r--crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs246
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!()
}