aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php29
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php24
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php24
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php91
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php29
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php48
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php51
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs32
-rw-r--r--crates/shirabe/src/event_dispatcher/event_dispatcher.rs112
-rw-r--r--crates/shirabe/src/installer/installation_manager.rs98
-rw-r--r--crates/shirabe/src/installer/package_event.rs24
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs144
-rw-r--r--crates/shirabe/tests/plugin/e2e_package_event_test.rs105
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json5
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json5
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json17
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php87
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json35
-rw-r--r--crates/shirabe/tests/plugin/main.rs1
19 files changed, 864 insertions, 97 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php
new file mode 100644
index 00000000..6f675703
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php
@@ -0,0 +1,29 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\InstallOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+use Composer\Package\PackageInterface;
+
+class InstallOperation extends SolverOperation implements OperationInterface
+{
+ public function __construct(PackageInterface $package)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = 'install';
+
+ public static function format(PackageInterface $package, bool $lock = false): string
+ {
+ return ($lock ? 'Locking ' : 'Installing ').'<info>'.$package->getPrettyName().'</info> (<comment>'.$package->getFullPrettyVersion().'</comment>)';
+ }
+
+ public function getPackage(): PackageInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php
new file mode 100644
index 00000000..08293692
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php
@@ -0,0 +1,24 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\MarkAliasInstalledOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+use Composer\Package\AliasPackage;
+
+class MarkAliasInstalledOperation extends SolverOperation implements OperationInterface
+{
+ public function __construct(AliasPackage $package)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = 'markAliasInstalled';
+
+ public function getPackage(): AliasPackage
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php
new file mode 100644
index 00000000..acd5abec
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php
@@ -0,0 +1,24 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+use Composer\Package\AliasPackage;
+
+class MarkAliasUninstalledOperation extends SolverOperation implements OperationInterface
+{
+ public function __construct(AliasPackage $package)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = 'markAliasUninstalled';
+
+ public function getPackage(): AliasPackage
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php
new file mode 100644
index 00000000..382ad4a6
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php
@@ -0,0 +1,91 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\SolverOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+abstract class SolverOperation implements OperationInterface, \ShirabeRustStub
+{
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ /**
+ * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses
+ * the constructor, which belongs to plugin code building a new entity instead.
+ */
+ public function __shirabeBind(int $rhandle, int $epoch): void
+ {
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+
+ public function __clone()
+ {
+ // PHP has already shallow-copied this stub, so both copies would point at one
+ // entity and release it twice. The Rust side clones the entity instead, applying
+ // whatever __clone semantics the real class defines, and this copy rebinds to the
+ // fresh handle. Entities without clone semantics answer with an explicit error.
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ public function __get($name)
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, '__get', [$name]);
+ }
+
+ public function __set($name, $value): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]);
+ }
+
+ public function __isset($name): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, '__isset', [$name]);
+ }
+
+ public function __unset($name): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, '__unset', [$name]);
+ }
+
+ public function __construct()
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, []]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = '';
+
+ public function getOperationType(): string
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperationType', []);
+ }
+
+ public function show(bool $lock)
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'show', [$lock]);
+ }
+
+ public function __toString()
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, '__toString', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php
new file mode 100644
index 00000000..b2560873
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php
@@ -0,0 +1,29 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\UninstallOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+use Composer\Package\PackageInterface;
+
+class UninstallOperation extends SolverOperation implements OperationInterface
+{
+ public function __construct(PackageInterface $package)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = 'uninstall';
+
+ public static function format(PackageInterface $package, bool $lock = false): string
+ {
+ return 'Removing <info>'.$package->getPrettyName().'</info> (<comment>'.$package->getFullPrettyVersion().'</comment>)';
+ }
+
+ public function getPackage(): PackageInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php
new file mode 100644
index 00000000..1e60804f
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php
@@ -0,0 +1,48 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\DependencyResolver\Operation\UpdateOperation: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\DependencyResolver\Operation;
+
+use Composer\Package\PackageInterface;
+use Composer\Package\Version\VersionParser;
+
+class UpdateOperation extends SolverOperation implements OperationInterface
+{
+ public function __construct(PackageInterface $initial, PackageInterface $target)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$initial, $target]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ protected const TYPE = 'update';
+
+ public static function format(PackageInterface $initialPackage, PackageInterface $targetPackage, bool $lock = false): string
+ {
+ $fromVersion = $initialPackage->getFullPrettyVersion();
+ $toVersion = $targetPackage->getFullPrettyVersion();
+
+ if ($fromVersion === $toVersion && $initialPackage->getSourceReference() !== $targetPackage->getSourceReference()) {
+ $fromVersion = $initialPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_SOURCE_REF);
+ $toVersion = $targetPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_SOURCE_REF);
+ } elseif ($fromVersion === $toVersion && $initialPackage->getDistReference() !== $targetPackage->getDistReference()) {
+ $fromVersion = $initialPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_DIST_REF);
+ $toVersion = $targetPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_DIST_REF);
+ }
+
+ $actionName = VersionParser::isUpgrade($initialPackage->getVersion(), $targetPackage->getVersion()) ? 'Upgrading' : 'Downgrading';
+
+ return $actionName.' <info>'.$initialPackage->getPrettyName().'</info> (<comment>'.$fromVersion.'</comment> => <comment>'.$toVersion.'</comment>)';
+ }
+
+ public function getInitialPackage(): PackageInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getInitialPackage', []);
+ }
+
+ public function getTargetPackage(): PackageInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getTargetPackage', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php
new file mode 100644
index 00000000..99a1f0ee
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php
@@ -0,0 +1,51 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\Installer\PackageEvent: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\Installer;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\Repository\RepositoryInterface;
+use Composer\EventDispatcher\Event;
+
+class PackageEvent extends Event
+{
+ public function __construct(string $eventName, Composer $composer, IOInterface $io, bool $devMode, RepositoryInterface $localRepo, array $operations, OperationInterface $operation)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$eventName, $composer, $io, $devMode, $localRepo, $operations, $operation]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ public function getComposer(): Composer
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []);
+ }
+
+ public function getIO(): IOInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []);
+ }
+
+ public function isDevMode(): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isDevMode', []);
+ }
+
+ public function getLocalRepo(): RepositoryInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getLocalRepo', []);
+ }
+
+ public function getOperations(): array
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperations', []);
+ }
+
+ public function getOperation(): OperationInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperation', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 1b501caf..eac6f523 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -915,6 +915,38 @@ const STUB_FILES: &[(&str, &str)] = &[
"Composer/Installer/InstallationManager.php",
include_str!("../php/stubs/Composer/Installer/InstallationManager.php"),
),
+ (
+ "Composer/Installer/PackageEvent.php",
+ include_str!("../php/stubs/Composer/Installer/PackageEvent.php"),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/SolverOperation.php",
+ include_str!("../php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php"),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/InstallOperation.php",
+ include_str!("../php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php"),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/UpdateOperation.php",
+ include_str!("../php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php"),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/UninstallOperation.php",
+ include_str!("../php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php"),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php",
+ include_str!(
+ "../php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php"
+ ),
+ ),
+ (
+ "Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php",
+ include_str!(
+ "../php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php"
+ ),
+ ),
];
/// Hand-written worker-side classes (two-world implementations with behavior of their own, not
diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs
index 1480188a..b821f647 100644
--- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs
+++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs
@@ -16,7 +16,7 @@ use crate::installer::PackageEvent;
use crate::io::IOInterface;
use crate::io::IOInterfaceImmutable;
use crate::plugin::php_plugin_proxy::PluginRpcDispatcher;
-use crate::repository::RepositoryInterface;
+use crate::repository::InstalledRepositoryInterfaceHandle;
use crate::script::Event as ScriptEvent;
use crate::util::Platform;
use crate::util::ProcessExecutor;
@@ -250,9 +250,9 @@ impl EventDispatcher {
&mut self,
event_name: &str,
dev_mode: bool,
- local_repo: Box<dyn RepositoryInterface>,
- operations: Vec<AnyOperation>,
- operation: AnyOperation,
+ local_repo: InstalledRepositoryInterfaceHandle,
+ operations: Vec<std::rc::Rc<AnyOperation>>,
+ operation: std::rc::Rc<AnyOperation>,
) -> anyhow::Result<i64> {
let composer = self.composer();
assert!(
@@ -432,8 +432,7 @@ impl EventDispatcher {
crate::io::VERBOSE,
);
let stub_class = Self::event_stub_class(event).ok_or_else(|| {
- // TODO(plugin): only the base Event and Script\Event proxy stubs exist so
- // far; installer/package/plugin events need their own stubs.
+ // TODO(plugin): installer and plugin events have no proxy stub yet.
anyhow::anyhow!(RuntimeException {
message: format!(
"no proxy stub is available yet for the event `{}` dispatched to {}::{}",
@@ -1154,8 +1153,7 @@ try {{
}
let stub_class = Self::event_stub_class(event).ok_or_else(|| {
- // TODO(plugin): only the base Event and Script\Event proxy stubs exist so far;
- // installer/package/plugin events need their own stubs.
+ // TODO(plugin): installer and plugin events have no proxy stub yet.
anyhow::anyhow!(RuntimeException {
message: format!(
"no proxy stub is available yet for the event `{}` dispatched to {}::{}",
@@ -1200,6 +1198,8 @@ try {{
fn event_stub_class(event: &dyn EventInterface) -> Option<&'static str> {
if event.as_any().downcast_ref::<ScriptEvent>().is_some() {
Some("Composer\\Script\\Event")
+ } else if event.as_any().downcast_ref::<PackageEvent>().is_some() {
+ Some("Composer\\Installer\\PackageEvent")
} else if event.as_any().downcast_ref::<Event>().is_some() {
Some("Composer\\EventDispatcher\\Event")
} else {
@@ -1755,39 +1755,75 @@ pub(crate) fn dispatch_event_method(
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())),
+ "isDevMode" => match (script_event(event), package_event(event)) {
+ (Some(event), _) => Ok(PluginValue::Bool(event.is_dev_mode())),
+ (_, Some(event)) => Ok(PluginValue::Bool(event.is_dev_mode())),
+ _ => Err(runtime_throw(
+ "isDevMode is only available on script and package events".to_string(),
+ )),
+ },
+ "getComposer" => {
+ let composer = match (script_event(event), package_event(event)) {
+ (Some(event), _) => event.get_composer().upgrade(),
+ (_, Some(event)) => event.get_composer().upgrade(),
+ _ => {
+ return Err(runtime_throw(
+ "getComposer is only available on script and package events".to_string(),
+ ));
+ }
+ };
+ let composer = composer.ok_or_else(|| {
+ runtime_throw("the Composer instance of this event is gone".to_string())
+ })?;
+ let rhandle = crate::plugin::php_plugin_proxy::register_composer_entity(&composer);
+ Ok(crate::plugin::php_plugin_proxy::rust_handle_value(
+ rhandle,
+ "Composer\\Composer",
+ ))
+ }
+ "getIO" => {
+ let io = match (script_event(event), package_event(event)) {
+ (Some(event), _) => event.get_io(),
+ (_, Some(event)) => event.get_io(),
+ _ => {
+ return Err(runtime_throw(
+ "getIO is only available on script and package events".to_string(),
+ ));
+ }
+ };
+ let class = crate::plugin::php_plugin_proxy::io_stub_class(&io)
+ .map_err(|error| runtime_throw(error.to_string()))?;
+ let rhandle = crate::plugin::php_plugin_proxy::register_io_entity(&io);
+ Ok(crate::plugin::php_plugin_proxy::rust_handle_value(
+ rhandle, class,
+ ))
+ }
+ "getLocalRepo" => match package_event(event) {
+ Some(event) => crate::plugin::php_plugin_proxy::repository_handle_value(
+ &event.get_local_repo().as_repository_handle(),
+ ),
None => Err(runtime_throw(
- "isDevMode is only available on script events".to_string(),
+ "getLocalRepo is only available on package events".to_string(),
)),
},
- "getComposer" => match event.as_any().downcast_ref::<ScriptEvent>() {
- Some(script_event) => {
- let composer = script_event.get_composer().upgrade().ok_or_else(|| {
- runtime_throw("the Composer instance of this event is gone".to_string())
- })?;
- let rhandle = crate::plugin::php_plugin_proxy::register_composer_entity(&composer);
- Ok(crate::plugin::php_plugin_proxy::rust_handle_value(
- rhandle,
- "Composer\\Composer",
- ))
- }
+ "getOperations" => match package_event(event) {
+ Some(event) => Ok(PluginValue::List(
+ event
+ .get_operations()
+ .iter()
+ .map(crate::plugin::php_plugin_proxy::operation_handle_value)
+ .collect(),
+ )),
None => Err(runtime_throw(
- "getComposer is only available on script events".to_string(),
+ "getOperations is only available on package events".to_string(),
)),
},
- "getIO" => match event.as_any().downcast_ref::<ScriptEvent>() {
- Some(script_event) => {
- let io = script_event.get_io();
- let class = crate::plugin::php_plugin_proxy::io_stub_class(&io)
- .map_err(|error| runtime_throw(error.to_string()))?;
- let rhandle = crate::plugin::php_plugin_proxy::register_io_entity(&io);
- Ok(crate::plugin::php_plugin_proxy::rust_handle_value(
- rhandle, class,
- ))
- }
+ "getOperation" => match package_event(event) {
+ Some(event) => Ok(crate::plugin::php_plugin_proxy::operation_handle_value(
+ event.get_operation(),
+ )),
None => Err(runtime_throw(
- "getIO is only available on script events".to_string(),
+ "getOperation is only available on package events".to_string(),
)),
},
// TODO(plugin): stopPropagation and the rest need full proxying of the object graph
@@ -1798,6 +1834,14 @@ pub(crate) fn dispatch_event_method(
}
}
+fn script_event(event: &dyn EventInterface) -> Option<&ScriptEvent> {
+ event.as_any().downcast_ref::<ScriptEvent>()
+}
+
+fn package_event(event: &dyn EventInterface) -> Option<&PackageEvent> {
+ event.as_any().downcast_ref::<PackageEvent>()
+}
+
fn runtime_throw(message: String) -> PhpThrow {
PhpThrow {
exception_class: "RuntimeException".to_string(),
diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs
index 439026b0..1f1c60a5 100644
--- a/crates/shirabe/src/installer/installation_manager.rs
+++ b/crates/shirabe/src/installer/installation_manager.rs
@@ -327,16 +327,19 @@ impl InstallationManager {
}),
);
- let all_operations: Vec<AnyOperation> = operations.clone();
+ // Shared rather than owned so that one operation reaches a plugin as one object, both
+ // through the whole batch pipeline and through its pre- and post-event.
+ let all_operations: Vec<std::rc::Rc<AnyOperation>> =
+ operations.into_iter().map(std::rc::Rc::new).collect();
let result: anyhow::Result<()> = (|| -> anyhow::Result<()> {
// execute operations in batches to make sure download-modifying-plugins are installed
// before the other packages get downloaded
- let mut batches: Vec<IndexMap<i64, AnyOperation>> = vec![];
- let mut batch: IndexMap<i64, AnyOperation> = IndexMap::new();
- for (index, operation) in operations.into_iter().enumerate() {
+ let mut batches: Vec<IndexMap<i64, std::rc::Rc<AnyOperation>>> = vec![];
+ let mut batch: IndexMap<i64, std::rc::Rc<AnyOperation>> = IndexMap::new();
+ for (index, operation) in all_operations.iter().cloned().enumerate() {
let index = index as i64;
- let package: Option<PackageInterfaceHandle> = match &operation {
+ let package: Option<PackageInterfaceHandle> = match &*operation {
AnyOperation::Update(update) => Some(update.get_target_package()),
AnyOperation::Install(install) => Some(install.get_package()),
_ => None,
@@ -409,7 +412,7 @@ impl InstallationManager {
async fn download_and_execute_batch(
&self,
repo: &InstalledRepositoryInterfaceHandle,
- operations: IndexMap<i64, AnyOperation>,
+ operations: IndexMap<i64, std::rc::Rc<AnyOperation>>,
cleanup_promises: &mut IndexMap<
i64,
Box<
@@ -421,7 +424,7 @@ impl InstallationManager {
dev_mode: bool,
run_scripts: bool,
download_only: bool,
- all_operations: Vec<AnyOperation>,
+ all_operations: Vec<std::rc::Rc<AnyOperation>>,
) -> anyhow::Result<()> {
let mut promises: Vec<
std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>>>>,
@@ -436,7 +439,7 @@ impl InstallationManager {
}
let package = operation.get_target_package();
- let initial_package: Option<PackageInterfaceHandle> = match operation {
+ let initial_package: Option<PackageInterfaceHandle> = match &**operation {
AnyOperation::Update(update_op) => Some(update_op.get_initial_package()),
_ => None,
};
@@ -505,10 +508,10 @@ impl InstallationManager {
// execute operations in batches to make sure every plugin is installed in the
// right order and activated before the packages depending on it are installed
- let mut batches: Vec<IndexMap<i64, AnyOperation>> = vec![];
- let mut batch: IndexMap<i64, AnyOperation> = IndexMap::new();
+ let mut batches: Vec<IndexMap<i64, std::rc::Rc<AnyOperation>>> = vec![];
+ let mut batch: IndexMap<i64, std::rc::Rc<AnyOperation>> = IndexMap::new();
for (index, operation) in operations {
- let package: Option<PackageInterfaceHandle> = match &operation {
+ let package: Option<PackageInterfaceHandle> = match &*operation {
AnyOperation::Update(update) => Some(update.get_target_package()),
AnyOperation::Install(install) => Some(install.get_package()),
_ => None,
@@ -551,7 +554,7 @@ impl InstallationManager {
async fn execute_batch(
&self,
repo: &InstalledRepositoryInterfaceHandle,
- operations: IndexMap<i64, AnyOperation>,
+ operations: IndexMap<i64, std::rc::Rc<AnyOperation>>,
cleanup_promises: &IndexMap<
i64,
Box<
@@ -562,11 +565,13 @@ impl InstallationManager {
>,
dev_mode: bool,
run_scripts: bool,
- all_operations: &[AnyOperation],
+ all_operations: &[std::rc::Rc<AnyOperation>],
) -> anyhow::Result<()> {
let mut promises: Vec<
std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>>,
> = vec![];
+ // @var array<callable(): void> $postExecCallbacks
+ let mut post_exec_callbacks: Vec<Box<dyn Fn() -> anyhow::Result<()>>> = vec![];
for (index, operation) in operations {
let op_type = operation.get_operation_type();
@@ -581,7 +586,7 @@ impl InstallationManager {
io_interface::NORMAL,
);
}
- match &operation {
+ match &*operation {
AnyOperation::MarkAliasInstalled(op) => {
self.mark_alias_installed(&mut *repo.borrow_mut(), op)?;
}
@@ -595,7 +600,7 @@ impl InstallationManager {
}
let package = operation.get_target_package();
- let initial_package: Option<PackageInterfaceHandle> = match &operation {
+ let initial_package: Option<PackageInterfaceHandle> = match &*operation {
AnyOperation::Update(update_op) => Some(update_op.get_initial_package()),
_ => None,
};
@@ -607,11 +612,14 @@ impl InstallationManager {
_ => "",
};
- if run_scripts && self.event_dispatcher.is_some() {
- // TODO(phase-c): dispatch_package_event takes Box<dyn RepositoryInterface>/Vec<Box<...>>
- // but we hold a RefCell'd &mut dyn here. Needs structural rework (likely shared Rc
- // on repo and ops).
- let _ = (event_name, dev_mode, &repo, &all_operations, &operation);
+ if run_scripts && let Some(event_dispatcher) = &self.event_dispatcher {
+ event_dispatcher.borrow_mut().dispatch_package_event(
+ event_name,
+ dev_mode,
+ repo.clone(),
+ all_operations.to_vec(),
+ operation.clone(),
+ )?;
}
let installer = self.get_installer(&package.get_type())?;
@@ -620,16 +628,16 @@ impl InstallationManager {
// ->then(fn() => $this->{$opType}($repo, $operation))
// ->then($cleanupPromises[$index])
// ->then(fn() => $repo->write($devMode, $this), fn($e) => { "<op> of <pkg>
- // failed"; throw $e; })
- // ->then(fn() => dispatch POST_PACKAGE_* event);
+ // failed"; throw $e; });
// each package gets its own chain and the whole batch resolves via waitOnPromises.
+ let executed_operation = std::rc::Rc::clone(&operation);
promises.push(Box::pin(async move {
let chain_result: anyhow::Result<()> = async {
installer
.prepare(op_type, package.clone(), initial_package.clone())
.await?;
- match &operation {
+ match &*executed_operation {
AnyOperation::Install(op) => {
self.install(repo, op).await?;
}
@@ -668,24 +676,32 @@ impl InstallationManager {
// PHP: ->then(fn() => $repo->write($devMode, $this)) persists the repository after each op.
repo.borrow_mut().write(dev_mode, self)?;
- let event_name_post = match op_type {
- "install" => PackageEvents::POST_PACKAGE_INSTALL,
- "update" => PackageEvents::POST_PACKAGE_UPDATE,
- "uninstall" => PackageEvents::POST_PACKAGE_UNINSTALL,
- _ => "",
- };
-
- if run_scripts && self.event_dispatcher.is_some() {
- // PHP dispatches the POST_PACKAGE_* event at the end of the chain via the event
- // dispatcher with repo/all_operations/operation.
- // TODO(phase-c): dispatch_package_event takes Box<dyn RepositoryInterface>/
- // Vec<Box<...>> but we hold a RefCell'd &mut dyn here. Needs structural rework
- // (likely shared Rc on repo and ops).
- let _ = event_name_post;
- }
-
Ok(())
}));
+
+ let event_name = match op_type {
+ "install" => PackageEvents::POST_PACKAGE_INSTALL,
+ "update" => PackageEvents::POST_PACKAGE_UPDATE,
+ "uninstall" => PackageEvents::POST_PACKAGE_UNINSTALL,
+ _ => "",
+ };
+
+ if run_scripts && let Some(event_dispatcher) = &self.event_dispatcher {
+ let event_dispatcher = event_dispatcher.clone();
+ let repo = repo.clone();
+ let all_operations = all_operations.to_vec();
+ post_exec_callbacks.push(Box::new(move || {
+ event_dispatcher.borrow_mut().dispatch_package_event(
+ event_name,
+ dev_mode,
+ repo.clone(),
+ all_operations.clone(),
+ operation.clone(),
+ )?;
+
+ Ok(())
+ }));
+ }
}
if !promises.is_empty() {
@@ -694,6 +710,10 @@ impl InstallationManager {
Platform::workaround_filesystem_issues();
+ for cb in post_exec_callbacks {
+ cb()?;
+ }
+
Ok(())
}
diff --git a/crates/shirabe/src/installer/package_event.rs b/crates/shirabe/src/installer/package_event.rs
index 9435a581..5e6ddb34 100644
--- a/crates/shirabe/src/installer/package_event.rs
+++ b/crates/shirabe/src/installer/package_event.rs
@@ -5,19 +5,21 @@ use crate::dependency_resolver::operation::AnyOperation;
use crate::event_dispatcher::Event;
use crate::event_dispatcher::EventInterface;
use crate::io::IOInterface;
-use crate::repository::RepositoryInterface;
+use crate::repository::InstalledRepositoryInterfaceHandle;
use indexmap::IndexMap;
use shirabe_php_shim::PhpMixed;
+/// The operations are shared rather than owned so that the same operation crosses the plugin
+/// boundary as one object for both the pre- and the post-event, as it does in PHP.
#[derive(Debug)]
pub struct PackageEvent {
inner: Event,
composer: ComposerWeakHandle,
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
dev_mode: bool,
- local_repo: Box<dyn RepositoryInterface>,
- operations: Vec<AnyOperation>,
- operation: AnyOperation,
+ local_repo: InstalledRepositoryInterfaceHandle,
+ operations: Vec<std::rc::Rc<AnyOperation>>,
+ operation: std::rc::Rc<AnyOperation>,
}
impl PackageEvent {
@@ -26,9 +28,9 @@ impl PackageEvent {
composer: ComposerWeakHandle,
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
dev_mode: bool,
- local_repo: Box<dyn RepositoryInterface>,
- operations: Vec<AnyOperation>,
- operation: AnyOperation,
+ local_repo: InstalledRepositoryInterfaceHandle,
+ operations: Vec<std::rc::Rc<AnyOperation>>,
+ operation: std::rc::Rc<AnyOperation>,
) -> Self {
Self {
inner: Event::new(event_name, vec![], IndexMap::new()),
@@ -57,15 +59,15 @@ impl PackageEvent {
self.dev_mode
}
- pub fn get_local_repo(&self) -> &dyn RepositoryInterface {
- self.local_repo.as_ref()
+ pub fn get_local_repo(&self) -> InstalledRepositoryInterfaceHandle {
+ self.local_repo.clone()
}
- pub fn get_operations(&self) -> &Vec<AnyOperation> {
+ pub fn get_operations(&self) -> &Vec<std::rc::Rc<AnyOperation>> {
&self.operations
}
- pub fn get_operation(&self) -> &AnyOperation {
+ pub fn get_operation(&self) -> &std::rc::Rc<AnyOperation> {
&self.operation
}
}
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index 541718af..686c05bd 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -8,6 +8,7 @@
use crate::autoload::ClassLoader;
use crate::command::BaseCommand;
use crate::composer::ComposerHandle;
+use crate::dependency_resolver::operation::AnyOperation;
use crate::event_dispatcher::event_dispatcher::dispatch_event_method;
use crate::event_dispatcher::{
EventInterface, EventSubscriberInterface, SubscribedEventEntry, unwrap_php_result,
@@ -52,6 +53,7 @@ enum RustEntity {
EventDispatcher(
std::rc::Rc<std::cell::RefCell<dyn crate::event_dispatcher::EventDispatcherInterface>>,
),
+ Operation(std::rc::Rc<AnyOperation>),
}
/// The pointer identity backing R-table interning: the same shared instance must always cross
@@ -71,6 +73,7 @@ fn entity_ptr_id(entity: &RustEntity) -> usize {
RustEntity::EventDispatcher(dispatcher) => {
std::rc::Rc::as_ptr(dispatcher) as *const () as usize
}
+ RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize,
}
}
@@ -169,6 +172,21 @@ fn repository_stub_class(repository: &RepositoryInterfaceHandle) -> Result<&'sta
}
}
+/// The proxy stub class matching a solver operation's concrete type.
+fn operation_stub_class(operation: &AnyOperation) -> &'static str {
+ match operation {
+ AnyOperation::Install(_) => "Composer\\DependencyResolver\\Operation\\InstallOperation",
+ AnyOperation::Update(_) => "Composer\\DependencyResolver\\Operation\\UpdateOperation",
+ AnyOperation::Uninstall(_) => "Composer\\DependencyResolver\\Operation\\UninstallOperation",
+ AnyOperation::MarkAliasInstalled(_) => {
+ "Composer\\DependencyResolver\\Operation\\MarkAliasInstalledOperation"
+ }
+ AnyOperation::MarkAliasUninstalled(_) => {
+ "Composer\\DependencyResolver\\Operation\\MarkAliasUninstalledOperation"
+ }
+ }
+}
+
/// Registers a package and returns its wire descriptor.
pub(crate) fn package_handle_value(
package: &std::rc::Rc<std::cell::RefCell<AnyPackage>>,
@@ -178,6 +196,13 @@ pub(crate) fn package_handle_value(
rust_handle_value(rhandle, class)
}
+/// Registers a solver operation and returns its wire descriptor.
+pub(crate) fn operation_handle_value(operation: &std::rc::Rc<AnyOperation>) -> PluginValue {
+ let class = operation_stub_class(operation);
+ let rhandle = register_entity(RustEntity::Operation(operation.clone()));
+ rust_handle_value(rhandle, class)
+}
+
/// The PHP class name (= proxy stub class) of a Rust IO instance, for the `__class` field of
/// its handle descriptor.
pub(crate) fn io_stub_class(
@@ -335,6 +360,9 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> {
Some(RustEntity::EventDispatcher(dispatcher)) => {
dispatch_event_dispatcher_method(&dispatcher, method_name, &args)
}
+ Some(RustEntity::Operation(operation)) => {
+ dispatch_operation_method(&operation, method_name, &args)
+ }
None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))),
}
}
@@ -368,7 +396,13 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT
))),
}
};
- let entity = match class.as_str() {
+ let alias_package_arg =
+ |position: usize| -> Result<crate::package::AliasPackageHandle, PhpThrow> {
+ package_from_arg(&class, ctor_args.get(position))?
+ .as_alias()
+ .ok_or_else(|| runtime_throw(format!("{class} expects an AliasPackage")))
+ };
+ let package = match class.as_str() {
"Composer\\Package\\Package" => AnyPackage::Package(crate::package::Package::new(
string_arg(0)?,
string_arg(1)?,
@@ -413,6 +447,50 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT
string_arg(2)?,
))
}
+ // A solver operation carries no state beyond the packages it names, so a plugin-built
+ // one is a complete instance rather than a second view on a Rust-side service.
+ "Composer\\DependencyResolver\\Operation\\InstallOperation" => {
+ return Ok(operation_construction_result(AnyOperation::Install(
+ crate::dependency_resolver::operation::InstallOperation::new(package_from_arg(
+ &class,
+ ctor_args.first(),
+ )?),
+ )));
+ }
+ "Composer\\DependencyResolver\\Operation\\UpdateOperation" => {
+ return Ok(operation_construction_result(AnyOperation::Update(
+ crate::dependency_resolver::operation::UpdateOperation::new(
+ package_from_arg(&class, ctor_args.first())?,
+ package_from_arg(&class, ctor_args.get(1))?,
+ ),
+ )));
+ }
+ "Composer\\DependencyResolver\\Operation\\UninstallOperation" => {
+ return Ok(operation_construction_result(AnyOperation::Uninstall(
+ crate::dependency_resolver::operation::UninstallOperation::new(package_from_arg(
+ &class,
+ ctor_args.first(),
+ )?),
+ )));
+ }
+ "Composer\\DependencyResolver\\Operation\\MarkAliasInstalledOperation" => {
+ return Ok(operation_construction_result(
+ AnyOperation::MarkAliasInstalled(
+ crate::dependency_resolver::operation::MarkAliasInstalledOperation::new(
+ alias_package_arg(0)?,
+ ),
+ ),
+ ));
+ }
+ "Composer\\DependencyResolver\\Operation\\MarkAliasUninstalledOperation" => {
+ return Ok(operation_construction_result(
+ AnyOperation::MarkAliasUninstalled(
+ crate::dependency_resolver::operation::MarkAliasUninstalledOperation::new(
+ alias_package_arg(0)?,
+ ),
+ ),
+ ));
+ }
// TODO(plugin): the remaining proxied classes get a construction story on demand,
// driven by explicit errors from real plugins. Each one has to decide what a
// plugin-built instance means for the Rust-side graph, which is why none of them is
@@ -424,12 +502,20 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT
}
};
let rhandle = register_entity(RustEntity::Package(std::rc::Rc::new(
- std::cell::RefCell::new(entity),
+ std::cell::RefCell::new(package),
)));
- Ok(PluginValue::List(vec![
- PluginValue::Int(rhandle as i64),
- PluginValue::Int(0),
- ]))
+ Ok(construction_result(rhandle))
+}
+
+fn operation_construction_result(operation: AnyOperation) -> PluginValue {
+ construction_result(register_entity(RustEntity::Operation(std::rc::Rc::new(
+ operation,
+ ))))
+}
+
+/// The `[$rhandle, $epoch]` pair a proxy stub's constructor binds itself to.
+fn construction_result(rhandle: u64) -> PluginValue {
+ PluginValue::List(vec![PluginValue::Int(rhandle as i64), PluginValue::Int(0)])
}
/// Serves the `__clone` forwarder every proxy stub carries. Only entities whose Rust type
@@ -452,7 +538,8 @@ fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> {
| RustEntity::InstallationManager(_)
| RustEntity::RepositoryManager(_)
| RustEntity::Repository(_)
- | RustEntity::EventDispatcher(_) => {
+ | RustEntity::EventDispatcher(_)
+ | RustEntity::Operation(_) => {
return Err(runtime_throw(
"cloning this Rust-side entity over RPC is not supported".to_string(),
));
@@ -810,12 +897,7 @@ fn dispatch_repository_manager_method(
method_name: &str,
) -> Result<PluginValue, PhpThrow> {
match method_name {
- "getLocalRepository" => {
- let local = rm.borrow().get_local_repository();
- let class = repository_stub_class(&local)?;
- let rhandle = register_entity(RustEntity::Repository(local));
- Ok(rust_handle_value(rhandle, class))
- }
+ "getLocalRepository" => repository_handle_value(&rm.borrow().get_local_repository()),
// TODO(plugin): the remaining RepositoryManager surface is widened on demand, driven
// by explicit errors from real plugins.
other => Err(runtime_throw(format!(
@@ -1761,6 +1843,42 @@ fn dispatch_package_method(
}
}
+fn dispatch_operation_method(
+ operation: &AnyOperation,
+ method_name: &str,
+ args: &[PluginValue],
+) -> Result<PluginValue, PhpThrow> {
+ match (method_name, operation) {
+ ("getOperationType", _) => Ok(PluginValue::string(operation.get_operation_type())),
+ ("show", _) => Ok(PluginValue::string(
+ operation.show(bool_arg(method_name, args.first())?),
+ )),
+ ("__toString", _) => Ok(PluginValue::string(operation.to_string())),
+ ("getPackage", AnyOperation::Install(op)) => {
+ Ok(package_handle_value(op.get_package().as_rc()))
+ }
+ ("getPackage", AnyOperation::Uninstall(op)) => {
+ Ok(package_handle_value(op.get_package().as_rc()))
+ }
+ ("getPackage", AnyOperation::MarkAliasInstalled(op)) => {
+ Ok(package_handle_value(op.get_package().as_rc()))
+ }
+ ("getPackage", AnyOperation::MarkAliasUninstalled(op)) => {
+ Ok(package_handle_value(op.get_package().as_rc()))
+ }
+ ("getInitialPackage", AnyOperation::Update(op)) => {
+ Ok(package_handle_value(op.get_initial_package().as_rc()))
+ }
+ ("getTargetPackage", AnyOperation::Update(op)) => {
+ Ok(package_handle_value(op.get_target_package().as_rc()))
+ }
+ (other, _) => Err(runtime_throw(format!(
+ "the operation method `{other}` is not available on a {} over RPC yet",
+ operation_stub_class(operation)
+ ))),
+ }
+}
+
fn dispatch_installation_manager_method(
im: &std::rc::Rc<std::cell::RefCell<dyn InstallationManagerInterface>>,
method_name: &str,
diff --git a/crates/shirabe/tests/plugin/e2e_package_event_test.rs b/crates/shirabe/tests/plugin/e2e_package_event_test.rs
new file mode 100644
index 00000000..ef334685
--- /dev/null
+++ b/crates/shirabe/tests/plugin/e2e_package_event_test.rs
@@ -0,0 +1,105 @@
+//! Package event E2E compatibility check: upstream Composer and Shirabe each install a fixture
+//! project whose plugin subscribes to every `PackageEvents` constant and appends what each event
+//! exposes to a trace file. Upstream has no test that dispatches package events through a real
+//! plugin, so the whole fixture is Shirabe-authored (`fixtures/e2e-package-event/`) and nothing
+//! has to be fetched; the test skips only while the PHP runtime or the Composer checkout is
+//! missing.
+
+use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin};
+use crate::plugin_installer_test::{lock_php_worker, php_runtime_available};
+use std::path::{Path, PathBuf};
+use tempfile::TempDir;
+
+fn fixture_dir() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-package-event")
+}
+
+struct Run {
+ exit_code: i32,
+ trace: String,
+ repo_trace: String,
+}
+
+/// Runs `install` in a fresh copy of the fixture and returns the exit code with the traces the
+/// plugin wrote.
+fn install(program: &str, prefix_args: &[&str]) -> Run {
+ let work = TempDir::new().unwrap();
+ copy_dir(&fixture_dir(), work.path());
+ let project = work.path().join("project");
+ let output = std::process::Command::new(program)
+ .args(prefix_args)
+ .arg("install")
+ .current_dir(&project)
+ .env("COMPOSER_HOME", work.path().join("home"))
+ .env("COMPOSER_CACHE_DIR", work.path().join("cache"))
+ .env("COMPOSER_NO_INTERACTION", "1")
+ .env("COLUMNS", "120")
+ .env("LINES", "30")
+ .output()
+ .unwrap();
+ let read = |name: &str| std::fs::read_to_string(project.join(name)).unwrap_or_default();
+ Run {
+ exit_code: output.status.code().unwrap_or(-1),
+ trace: read("package-event-trace.txt"),
+ repo_trace: read("package-event-repo-trace.txt"),
+ }
+}
+
+#[test]
+fn test_package_events_match_upstream_composer() {
+ if !php_runtime_available() {
+ return;
+ }
+ let Some(composer_bin) = upstream_composer_bin() else {
+ return;
+ };
+ let _worker = lock_php_worker();
+ let composer_bin = composer_bin.to_str().unwrap().to_string();
+
+ let upstream = install("php", &[composer_bin.as_str()]);
+ let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]);
+
+ assert_eq!(0, upstream.exit_code, "upstream install must succeed");
+ assert_eq!(upstream.exit_code, shirabe.exit_code);
+ assert_eq!(upstream.trace, shirabe.trace);
+
+ // Pinned as well as compared, so a run where neither side dispatches anything cannot pass.
+ // The plugin is activated by the very batch it observes, hence the first line: its own
+ // post-package-install, deferred until after the batch's operations have run. The two
+ // packages of the next batch report their pre-events before either post-event for the same
+ // reason.
+ assert_eq!(
+ "\
+post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/package-event-recorder operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/package-event-recorder</info> (<comment>1.0.0</comment>)
+pre-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-a operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-a</info> (<comment>1.0.0</comment>)
+pre-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-b operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-b</info> (<comment>1.0.0</comment>)
+post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-a operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-a</info> (<comment>1.0.0</comment>)
+post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-b operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-b</info> (<comment>1.0.0</comment>)
+",
+ upstream.trace
+ );
+}
+
+// Upstream's operation chain starts running where it is built, because a `prepare()` that
+// returns null becomes an already-fulfilled React promise whose `then()` handlers run through
+// the immediately drained queue; the pre-event of the next operation therefore already sees the
+// previous one installed. Shirabe builds a lazy future per operation and only drives them in
+// wait_on_promises, so every pre-event of a batch sees the repository as it was before the
+// batch. Upstream: 1 / 1 / 2 / 3 / 3, Shirabe: 1 / 1 / 1 / 3 / 3.
+#[ignore = "operation chains run where they are built upstream but only in wait_on_promises here, so the repository state a package event observes differs (TODO(phase-c) promise cluster)"]
+#[test]
+fn test_local_repository_seen_by_package_events_matches_upstream_composer() {
+ if !php_runtime_available() {
+ return;
+ }
+ let Some(composer_bin) = upstream_composer_bin() else {
+ return;
+ };
+ let _worker = lock_php_worker();
+ let composer_bin = composer_bin.to_str().unwrap().to_string();
+
+ let upstream = install("php", &[composer_bin.as_str()]);
+ let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]);
+
+ assert_eq!(upstream.repo_trace, shirabe.repo_trace);
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json
new file mode 100644
index 00000000..50b2eece
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json
@@ -0,0 +1,5 @@
+{
+ "name": "shirabe-test/lib-a",
+ "version": "1.0.0",
+ "description": "Fixture package installed while the recorder plugin is active."
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json
new file mode 100644
index 00000000..96713f8d
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json
@@ -0,0 +1,5 @@
+{
+ "name": "shirabe-test/lib-b",
+ "version": "1.0.0",
+ "description": "Fixture dev package, so the recorded events carry devMode."
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json
new file mode 100644
index 00000000..7614dc95
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json
@@ -0,0 +1,17 @@
+{
+ "name": "shirabe-test/package-event-recorder",
+ "version": "1.0.0",
+ "type": "composer-plugin",
+ "description": "Fixture plugin recording every PackageEvent it is subscribed to.",
+ "autoload": {
+ "psr-4": {
+ "ShirabeTest\\PackageEvent\\": "src/"
+ }
+ },
+ "require": {
+ "composer-plugin-api": "^2.0"
+ },
+ "extra": {
+ "class": "ShirabeTest\\PackageEvent\\Plugin"
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php
new file mode 100644
index 00000000..d904c0e9
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace ShirabeTest\PackageEvent;
+
+use Composer\Composer;
+use Composer\DependencyResolver\Operation\InstallOperation;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\DependencyResolver\Operation\UninstallOperation;
+use Composer\DependencyResolver\Operation\UpdateOperation;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\IO\IOInterface;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
+use Composer\Plugin\PluginInterface;
+
+/**
+ * Appends one line per PackageEvent to package-event-trace.txt, so both the order the events
+ * arrive in and everything the event exposes are comparable between implementations.
+ *
+ * The local repository is observed into a second file, because its size at the moment an event
+ * fires reports how far the batch's operations have run rather than anything the event carries.
+ */
+class Plugin implements PluginInterface, EventSubscriberInterface
+{
+ /** @var IOInterface */
+ private $io;
+
+ public function activate(Composer $composer, IOInterface $io): void
+ {
+ $this->io = $io;
+ }
+
+ public function deactivate(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public function uninstall(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public static function getSubscribedEvents()
+ {
+ return [
+ PackageEvents::PRE_PACKAGE_INSTALL => 'onPackageEvent',
+ PackageEvents::POST_PACKAGE_INSTALL => 'onPackageEvent',
+ PackageEvents::PRE_PACKAGE_UPDATE => 'onPackageEvent',
+ PackageEvents::POST_PACKAGE_UPDATE => 'onPackageEvent',
+ PackageEvents::PRE_PACKAGE_UNINSTALL => 'onPackageEvent',
+ PackageEvents::POST_PACKAGE_UNINSTALL => 'onPackageEvent',
+ ];
+ }
+
+ public function onPackageEvent(PackageEvent $event): void
+ {
+ $operation = $event->getOperation();
+ $line = implode(' ', [
+ $event->getName(),
+ 'devMode=' . ($event->isDevMode() ? '1' : '0'),
+ 'class=' . get_class($operation),
+ 'type=' . $operation->getOperationType(),
+ 'packages=' . $this->packages($operation),
+ 'operations=' . count($event->getOperations()),
+ 'root=' . $event->getComposer()->getPackage()->getName(),
+ 'show=' . $operation->show(false),
+ ]);
+ $this->io->write('package-event: ' . $line);
+ file_put_contents('package-event-trace.txt', $line . "\n", FILE_APPEND);
+ file_put_contents(
+ 'package-event-repo-trace.txt',
+ $event->getName() . ' localRepo=' . count($event->getLocalRepo()->getPackages()) . "\n",
+ FILE_APPEND
+ );
+ }
+
+ private function packages(OperationInterface $operation): string
+ {
+ if ($operation instanceof UpdateOperation) {
+ return $operation->getInitialPackage()->getPrettyName()
+ . '->' . $operation->getTargetPackage()->getPrettyName();
+ }
+ if ($operation instanceof InstallOperation || $operation instanceof UninstallOperation) {
+ return $operation->getPackage()->getPrettyName();
+ }
+
+ return 'n/a';
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json
new file mode 100644
index 00000000..a0e63a15
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json
@@ -0,0 +1,35 @@
+{
+ "name": "shirabe/e2e-package-event",
+ "description": "E2E fixture project: record the package events a plugin receives during install.",
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../plugin",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "type": "path",
+ "url": "../packages/*",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "packagist.org": false
+ }
+ ],
+ "require": {
+ "shirabe-test/package-event-recorder": "1.0.0",
+ "shirabe-test/lib-a": "1.0.0"
+ },
+ "require-dev": {
+ "shirabe-test/lib-b": "1.0.0"
+ },
+ "config": {
+ "allow-plugins": {
+ "shirabe-test/package-event-recorder": true
+ }
+ }
+}
diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs
index 7e32a616..f1237731 100644
--- a/crates/shirabe/tests/plugin/main.rs
+++ b/crates/shirabe/tests/plugin/main.rs
@@ -9,6 +9,7 @@ mod e2e_extension_installer_test;
mod e2e_installer_test;
mod e2e_installers_test;
mod e2e_normalize_test;
+mod e2e_package_event_test;
mod plugin_installer_test;
mod subscriber_test;
mod value_round_trip_test;