diff options
24 files changed, 883 insertions, 110 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; diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index c833bbea..9e49dec0 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -18,10 +18,9 @@ behavior. - A `socketpair(2)` (no Windows support for now). The parent keeps one end and installs the other on descriptor 3 in the child, from a `pre_exec` hook, so the worker opens it as `php://fd/3` rather than connecting anywhere. A bound path would have to fit in `sun_path` - (108 bytes), which a long `TMPDIR` overruns, and it needs a `bind(2)` that sandboxes commonly - deny. The pair is connected from the start, so nothing has to wait for an `accept` either: a - child that dies before reading surfaces as EOF on the first call, with its exit status - attached. + (108 bytes), which a long `TMPDIR` overruns. The pair is connected from the start, so nothing + has to wait for an `accept` either: a child that dies before reading surfaces as EOF on the + first call, with its exit status attached. - The PHP glue code (`php/worker.php`) and the proxy stub classes (`php/stubs/`) are embedded in the Rust binary and written to a `0700` temp dir at spawn time, so both halves of the protocol are always the same commit. diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md index 9743534a..68dde156 100644 --- a/docs/dev/plugin-stub-generation.md +++ b/docs/dev/plugin-stub-generation.md @@ -74,7 +74,8 @@ the generator's vendor directory or the classifier report is unavailable. * **Class constants, static methods and public static properties** are materialized verbatim from the real source (they read no instance state and run locally in the worker), together with any non-public static helpers the - methods call. + methods call. Constants keep their declared visibility, so a non-public one + stays unreadable from outside the stub as it is in the real class. * **Instance properties** are not declared on the stub, whatever their visibility: they are entity state. Every root stub instead carries `__get`/`__set`/`__isset`/`__unset` forwarders, so each access reaches the @@ -103,8 +104,7 @@ Generation fails — instead of emitting something quietly wrong — on: * an omitted override diverging from the inherited stub signature, * a subclass target listed before its base class, or extending a class that is neither a target nor provided by `php/runtime/`, -* a target whose FQCN is also provided by `php/runtime/`, -* non-public class constants (materializing them is unsupported so far). +* a target whose FQCN is also provided by `php/runtime/`. `generate-stubs` (in both modes) additionally fails when a `.php` file exists under the stubs directory that no target produces, or when `STUB_FILES` / diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs index 934577f3..5db83a27 100755 --- a/scripts/plugin-stub-generator/generate-stubs +++ b/scripts/plugin-stub-generator/generate-stubs @@ -117,8 +117,9 @@ foreach ($iterator as $entry) { } } -// The Rust side embeds every stub with include_str!; the two lists must not drift apart. -$libRsText = (string) file_get_contents($libRs); +// The Rust side embeds every stub with include_str!; the two lists must not drift apart. The +// whitespace is squeezed out first because rustfmt wraps a long include_str! across lines. +$libRsText = preg_replace('/\s+/', '', (string) file_get_contents($libRs)); foreach ($files as $relative => $_) { if (!str_contains($libRsText, "include_str!(\"../php/stubs/$relative\")")) { $problems[] = "STUB_FILES in $libRs does not embed $relative"; diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php index 12967787..dd62da60 100644 --- a/scripts/plugin-stub-generator/src/Generator.php +++ b/scripts/plugin-stub-generator/src/Generator.php @@ -220,12 +220,11 @@ final class Generator } } + // Constants are compile-time data with no entity behind them, so the declaration is + // copied verbatim, visibility included: a local copy cannot diverge from the entity, + // and nothing that was unreadable in the real class becomes readable here. $constants = []; foreach ($class->getConstants() as $constant) { - if (!$constant->isPublic()) { - $this->errors[] = "$fqcn declares a non-public constant; materializing it is not supported"; - continue; - } $constants[] = $file->verbatim($constant->getStartLine(), $constant->getEndLine()); } diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 347680d2..411b9e3b 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -26,3 +26,10 @@ Composer\Repository\FilesystemRepository Composer\Repository\InstalledFilesystemRepository Composer\Repository\RepositoryManager Composer\Installer\InstallationManager +Composer\Installer\PackageEvent +Composer\DependencyResolver\Operation\SolverOperation +Composer\DependencyResolver\Operation\InstallOperation +Composer\DependencyResolver\Operation\UpdateOperation +Composer\DependencyResolver\Operation\UninstallOperation +Composer\DependencyResolver\Operation\MarkAliasInstalledOperation +Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation |
