diff options
Diffstat (limited to 'crates')
24 files changed, 925 insertions, 144 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php index c0651081..30f8c0e8 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php @@ -44,6 +44,16 @@ class EventDispatcher implements \ShirabeRustStub ]; } + 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 setRunScripts(bool $runScripts = true): self { \ShirabeRpcRuntime::callRust($this->__rhandle, 'setRunScripts', [$runScripts]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php index 55da6415..b3e0fb51 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php @@ -42,6 +42,16 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub ]; } + 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 isInteractive() { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isInteractive', []); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php index 35682561..d458d30b 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php @@ -50,6 +50,16 @@ class InstallationManager implements \ShirabeRustStub ]; } + 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 reset(): void { \ShirabeRpcRuntime::callRust($this->__rhandle, 'reset', []); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php b/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php index e55c3fb5..de8037ca 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php @@ -42,6 +42,16 @@ abstract class BasePackage implements PackageInterface, \ShirabeRustStub ]; } + 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 const STABILITY_STABLE = 0; public const STABILITY_RC = 5; public const STABILITY_BETA = 10; @@ -75,13 +85,6 @@ abstract class BasePackage implements PackageInterface, \ShirabeRustStub \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]); } - public function __clone() - { - // Cloning a proxy is an open design question; fail instead of silently sharing - // the Rust-side entity between two stub instances. - throw new \RuntimeException('Shirabe does not support cloning ' . static::class . ' inside the plugin process yet'); - } - public static function packageNameToRegexp(string $allowPattern, string $wrap = '{^%s$}i'): string { $cleanedAllowPattern = str_replace('\\*', '.*', preg_quote($allowPattern)); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php index 588800dc..c60edbb2 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php @@ -46,6 +46,16 @@ class PartialComposer implements \ShirabeRustStub ]; } + 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 setPackage(RootPackageInterface $package): void { \ShirabeRpcRuntime::callRust($this->__rhandle, 'setPackage', [$package]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php index 33454fa2..51e6a462 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php @@ -42,6 +42,16 @@ class ArrayRepository implements RepositoryInterface, \ShirabeRustStub ]; } + 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 hasPackage(PackageInterface $package) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasPackage', [$package]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php index 252bab4d..5635c0dc 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php @@ -42,6 +42,16 @@ class RepositoryManager implements \ShirabeRustStub ]; } + 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 findPackage(string $name, $constraint): ?PackageInterface { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'findPackage', [$name, $constraint]); diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index 241dad57..12297c83 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -51,6 +51,16 @@ final class ShirabeRustObjectRegistry return $stub; } + /** + * Interns a stub the registry did not build: a `__clone` forwarder rebinds the copy PHP + * made to a freshly cloned entity, and that pairing has to be visible to later crossings + * of the same handle. + */ + public static function adopt(int $rhandle, object $stub): void + { + self::$internTable[$rhandle] = WeakReference::create($stub); + } + /** Invoked when an EpochBump frame arrives. No-op if the stub already died. */ public static function bumpEpoch(int $rhandle, int $epoch): void { diff --git a/crates/shirabe/src/command/create_project_command.rs b/crates/shirabe/src/command/create_project_command.rs index 1e289b08..756ebfac 100644 --- a/crates/shirabe/src/command/create_project_command.rs +++ b/crates/shirabe/src/command/create_project_command.rs @@ -1011,7 +1011,7 @@ impl CreateProjectCommand { { let mut im = installation_manager.borrow_mut(); im.set_output_progress(!no_progress); - im.add_installer(Box::new(project_installer)); + im.add_installer(std::rc::Rc::new(project_installer)); } let installed_repo = crate::repository::InstalledRepositoryInterfaceHandle::new( InstalledArrayRepository::new()?, diff --git a/crates/shirabe/src/factory.rs b/crates/shirabe/src/factory.rs index fe3b5289..a1fda63b 100644 --- a/crates/shirabe/src/factory.rs +++ b/crates/shirabe/src/factory.rs @@ -1291,7 +1291,7 @@ impl Factory { ))); im.borrow_mut() - .add_installer(Box::new(crate::installer::LibraryInstaller::new( + .add_installer(std::rc::Rc::new(crate::installer::LibraryInstaller::new( io.clone(), composer.clone(), None, @@ -1299,14 +1299,15 @@ impl Factory { Some(binary_installer.clone()), ))); im.borrow_mut() - .add_installer(Box::new(crate::installer::PluginInstaller::new( + .add_installer(std::rc::Rc::new(crate::installer::PluginInstaller::new( io.clone(), composer, Some(fs), Some(binary_installer), ))); - im.borrow_mut() - .add_installer(Box::new(crate::installer::MetapackageInstaller::new(io))); + im.borrow_mut().add_installer(std::rc::Rc::new( + crate::installer::MetapackageInstaller::new(io), + )); } fn purge_packages( diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs index 31bfaa2c..439026b0 100644 --- a/crates/shirabe/src/installer/installation_manager.rs +++ b/crates/shirabe/src/installer/installation_manager.rs @@ -33,12 +33,12 @@ pub struct InstallationManager { /// Rc rather than Box so `get_installer` can hand out shareable handles: the download/cleanup /// futures collected for Loop::wait must own their installer beyond the loop iteration that /// created them (PHP closures capture $installer the same way). - installers: Vec<std::rc::Rc<dyn InstallerInterface>>, - /// Maps a package type to the index of its installer in `installers`. PHP caches the installer - /// instance itself; here we store an index instead. The index never dangles because both - /// `add_installer` and `remove_installer` clear the cache whenever `installers` changes. + /// RefCell so a plugin activated from inside `execute` — which holds a shared borrow of this + /// manager for the whole run — can still register its own installer. + installers: std::cell::RefCell<Vec<std::rc::Rc<dyn InstallerInterface>>>, + /// Maps a package type to its installer. /// RefCell so lookups can populate the cache through `&self` from concurrent operation chains. - cache: std::cell::RefCell<IndexMap<String, usize>>, + cache: std::cell::RefCell<IndexMap<String, std::rc::Rc<dyn InstallerInterface>>>, /// RefCell so mark_for_notification works through `&self` from concurrent operation chains. notifiable_packages: std::cell::RefCell<IndexMap<String, Vec<PackageInterfaceHandle>>>, loop_: std::rc::Rc<std::cell::RefCell<Loop>>, @@ -69,7 +69,7 @@ impl InstallationManager { event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>, ) -> Self { Self { - installers: vec![], + installers: std::cell::RefCell::new(vec![]), cache: std::cell::RefCell::new(IndexMap::new()), notifiable_packages: std::cell::RefCell::new(IndexMap::new()), loop_, @@ -133,30 +133,25 @@ impl InstallationManager { } /// Adds installer - pub fn add_installer(&mut self, installer: Box<dyn InstallerInterface>) { - array_unshift(&mut self.installers, std::rc::Rc::from(installer)); - self.cache = std::cell::RefCell::new(IndexMap::new()); - } - - /// For testing only: adds an installer as a pre-built shared handle, so the caller keeps an - /// identity handle usable for PHP `assertSame`-style comparisons (`Rc::ptr_eq`) and for - /// `remove_installer`. `add_installer` cannot serve because `Rc::from(Box)` reallocates, - /// losing the caller's pointer identity. - pub fn __add_installer(&mut self, installer: std::rc::Rc<dyn InstallerInterface>) { - array_unshift(&mut self.installers, installer); - self.cache = std::cell::RefCell::new(IndexMap::new()); + /// + /// The installer is taken as a shared handle: PHP hands over an object reference and both + /// sides keep the same identity afterwards, which `removeInstaller` and the plugin + /// manager's `registeredPlugins` bookkeeping compare against. + pub fn add_installer(&self, installer: std::rc::Rc<dyn InstallerInterface>) { + array_unshift(&mut self.installers.borrow_mut(), installer); + self.cache.borrow_mut().clear(); } /// Removes installer - pub fn remove_installer(&mut self, installer: &dyn InstallerInterface) { + pub fn remove_installer(&self, installer: &dyn InstallerInterface) { let target = installer as *const dyn InstallerInterface as *const (); - let key = self - .installers + let mut installers = self.installers.borrow_mut(); + let key = installers .iter() .position(|inst| &**inst as *const dyn InstallerInterface as *const () == target); if let Some(k) = key { - array_splice(&mut self.installers, k as i64, Some(1), vec![]); - self.cache = std::cell::RefCell::new(IndexMap::new()); + array_splice(&mut installers, k as i64, Some(1), vec![]); + self.cache.borrow_mut().clear(); } } @@ -166,7 +161,9 @@ impl InstallationManager { /// disabling the PluginManager. This ensures that no third-party /// code is ever executed. pub fn disable_plugins(&mut self) { - for installer in self.installers.iter() { + // Cloned out: `disablePlugins` reaches into the plugin manager, which may reach back. + let installers = self.installers.borrow().clone(); + for installer in installers.iter() { if let Some(plugin_installer) = installer.as_plugin_installer() { plugin_installer.disable_plugins(); } @@ -180,17 +177,18 @@ impl InstallationManager { ) -> anyhow::Result<std::rc::Rc<dyn InstallerInterface>> { let r#type = strtolower(r#type); - if let Some(&index) = self.cache.borrow().get(&r#type) { - return Ok(self.installers[index].clone()); + if let Some(installer) = self.cache.borrow().get(&r#type) { + return Ok(installer.clone()); } - let index = self - .installers - .iter() - .position(|installer| installer.supports(&r#type)); - if let Some(index) = index { - self.cache.borrow_mut().insert(r#type, index); - return Ok(self.installers[index].clone()); + // Cloned out: a PHP-backed installer answers `supports` over RPC, and the plugin behind + // it can register a further installer from that call. + let installers = self.installers.borrow().clone(); + for installer in installers { + if installer.supports(&r#type)? { + self.cache.borrow_mut().insert(r#type, installer.clone()); + return Ok(installer); + } } Err(InvalidArgumentException { @@ -1020,8 +1018,8 @@ pub trait InstallationManagerInterface: std::fmt::Debug { unimplemented!("as_any is only implemented for the concrete InstallationManager") } - fn add_installer(&mut self, installer: Box<dyn InstallerInterface>); - fn remove_installer(&mut self, installer: &dyn InstallerInterface); + fn add_installer(&self, installer: std::rc::Rc<dyn InstallerInterface>); + fn remove_installer(&self, installer: &dyn InstallerInterface); fn disable_plugins(&mut self); fn is_package_installed( &mut self, @@ -1047,12 +1045,12 @@ impl InstallationManagerInterface for InstallationManager { self } - fn add_installer(&mut self, installer: Box<dyn InstallerInterface>) { - self.add_installer(installer); + fn add_installer(&self, installer: std::rc::Rc<dyn InstallerInterface>) { + InstallationManager::add_installer(self, installer); } - fn remove_installer(&mut self, installer: &dyn InstallerInterface) { - self.remove_installer(installer); + fn remove_installer(&self, installer: &dyn InstallerInterface) { + InstallationManager::remove_installer(self, installer); } fn disable_plugins(&mut self) { diff --git a/crates/shirabe/src/installer/installer_interface.rs b/crates/shirabe/src/installer/installer_interface.rs index bc464735..fc273916 100644 --- a/crates/shirabe/src/installer/installer_interface.rs +++ b/crates/shirabe/src/installer/installer_interface.rs @@ -8,7 +8,7 @@ use shirabe_php_shim::PhpMixed; #[async_trait::async_trait(?Send)] pub trait InstallerInterface: std::fmt::Debug { - fn supports(&self, package_type: &str) -> bool; + fn supports(&self, package_type: &str) -> anyhow::Result<bool>; fn is_installed( &self, diff --git a/crates/shirabe/src/installer/library_installer.rs b/crates/shirabe/src/installer/library_installer.rs index c5a9608e..479fc6d5 100644 --- a/crates/shirabe/src/installer/library_installer.rs +++ b/crates/shirabe/src/installer/library_installer.rs @@ -232,11 +232,11 @@ impl LibraryInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for LibraryInstaller { - fn supports(&self, package_type: &str) -> bool { - match &self.r#type { + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { + Ok(match &self.r#type { Some(t) => package_type == t, None => true, - } + }) } fn is_installed( diff --git a/crates/shirabe/src/installer/metapackage_installer.rs b/crates/shirabe/src/installer/metapackage_installer.rs index e2821fd3..52c152ea 100644 --- a/crates/shirabe/src/installer/metapackage_installer.rs +++ b/crates/shirabe/src/installer/metapackage_installer.rs @@ -24,8 +24,8 @@ impl MetapackageInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for MetapackageInstaller { - fn supports(&self, package_type: &str) -> bool { - package_type == "metapackage" + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { + Ok(package_type == "metapackage") } fn is_installed( diff --git a/crates/shirabe/src/installer/noop_installer.rs b/crates/shirabe/src/installer/noop_installer.rs index 68a2b981..95f31f2e 100644 --- a/crates/shirabe/src/installer/noop_installer.rs +++ b/crates/shirabe/src/installer/noop_installer.rs @@ -10,8 +10,8 @@ pub struct NoopInstaller; #[async_trait::async_trait(?Send)] impl InstallerInterface for NoopInstaller { - fn supports(&self, _package_type: &str) -> bool { - true + fn supports(&self, _package_type: &str) -> anyhow::Result<bool> { + Ok(true) } fn is_installed( diff --git a/crates/shirabe/src/installer/plugin_installer.rs b/crates/shirabe/src/installer/plugin_installer.rs index 1e7a2636..7683a7d5 100644 --- a/crates/shirabe/src/installer/plugin_installer.rs +++ b/crates/shirabe/src/installer/plugin_installer.rs @@ -73,8 +73,8 @@ impl PluginInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for PluginInstaller { - fn supports(&self, package_type: &str) -> bool { - package_type == "composer-plugin" || package_type == "composer-installer" + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { + Ok(package_type == "composer-plugin" || package_type == "composer-installer") } fn is_installed( diff --git a/crates/shirabe/src/installer/project_installer.rs b/crates/shirabe/src/installer/project_installer.rs index 6fee682f..59d44410 100644 --- a/crates/shirabe/src/installer/project_installer.rs +++ b/crates/shirabe/src/installer/project_installer.rs @@ -31,8 +31,8 @@ impl ProjectInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for ProjectInstaller { - fn supports(&self, _package_type: &str) -> bool { - true + fn supports(&self, _package_type: &str) -> anyhow::Result<bool> { + Ok(true) } fn is_installed( diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index d13019e3..701e83cc 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -20,8 +20,8 @@ use crate::plugin::capability::{Capability, CommandProvider}; use crate::plugin::capable::Capable; use crate::plugin::plugin_interface::PluginInterface; use crate::repository::{ - InstalledArrayRepository, InstalledFilesystemRepository, RepositoryInterfaceHandle, - RepositoryManagerInterface, + InstalledArrayRepository, InstalledFilesystemRepository, InstalledRepositoryInterfaceHandle, + RepositoryInterfaceHandle, RepositoryManagerInterface, }; use indexmap::IndexMap; use shirabe_external_packages::symfony::console::command::command::Command; @@ -292,6 +292,12 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { // The entity is cloned out so no table borrow is held while the handler runs (a // handler that re-enters register_*_entity would otherwise panic on the RefCell). let entity = R_TABLE.with(|table| table.borrow().get(&rhandle).cloned()); + if method_name == "__shirabeClone" { + return match entity { + Some(entity) => clone_entity(&entity), + None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), + }; + } match entity { Some(RustEntity::Io(io)) => dispatch_io_method(&io, method_name, &args), Some(RustEntity::Composer(composer)) => { @@ -317,6 +323,36 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { } } +/// Serves the `__clone` forwarder every proxy stub carries. Only entities whose Rust type +/// models the PHP clone answer; the rest are an explicit error, so a plugin cloning a live +/// service never silently ends up with two stubs over one entity. +fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> { + let rhandle = match entity { + // `AnyPackage::dup` carries `BasePackage::__clone` (repository reset, id = -1) and the + // `RootAliasPackage::__clone` override. + RustEntity::Package(package) => { + let cloned = package.borrow().dup(); + register_entity(RustEntity::Package(std::rc::Rc::new( + std::cell::RefCell::new(cloned), + ))) + } + RustEntity::Composer(_) + | RustEntity::Io(_) + | RustEntity::InstallationManager(_) + | RustEntity::RepositoryManager(_) + | RustEntity::Repository(_) + | RustEntity::EventDispatcher(_) => { + return Err(runtime_throw( + "cloning this Rust-side entity over RPC is not supported".to_string(), + )); + } + }; + Ok(PluginValue::List(vec![ + PluginValue::Int(rhandle as i64), + PluginValue::Int(0), + ])) +} + fn dispatch_composer_method( composer: &ComposerHandle, method_name: &str, @@ -437,22 +473,259 @@ fn dispatch_repository_method( } } +/// A PHP string-or-null wire value. +fn optional_string(value: Option<String>) -> PluginValue { + match value { + Some(value) => PluginValue::string(value), + None => PluginValue::Null, + } +} + +fn string_list(values: Vec<String>) -> PluginValue { + PluginValue::List(values.into_iter().map(PluginValue::string).collect()) +} + +/// `?list<array{url: string, preferred: bool}>` as PHP shapes it. +fn mirror_list(mirrors: Option<Vec<crate::package::Mirror>>) -> PluginValue { + match mirrors { + None => PluginValue::Null, + Some(mirrors) => PluginValue::List( + mirrors + .into_iter() + .map(|mirror| { + PluginValue::Array(IndexMap::from([ + (b"url".to_vec(), PluginValue::string(mirror.url)), + (b"preferred".to_vec(), PluginValue::Bool(mirror.preferred)), + ])) + }) + .collect(), + ), + } +} + +/// An `array<string, mixed>` as PHP shapes it: empty maps cross as a list, since an empty PHP +/// array is indistinguishable from an empty list on the wire. +fn string_keyed_map(map: IndexMap<String, PhpMixed>) -> PluginValue { + if map.is_empty() { + PluginValue::List(Vec::new()) + } else { + PluginValue::from_php_mixed(&PhpMixed::Array(map)) + } +} + +/// The inverse of `mirror_list`. +fn decode_mirrors( + method: &str, + value: Option<&PluginValue>, +) -> Result<Option<Vec<crate::package::Mirror>>, PhpThrow> { + let rows = match value { + None | Some(PluginValue::Null) => return Ok(None), + Some(PluginValue::List(rows)) => rows.clone(), + Some(PluginValue::Array(rows)) => rows.values().cloned().collect(), + other => { + return Err(runtime_throw(format!( + "{method} expects a list of mirrors or null, got {other:?}" + ))); + } + }; + let mut mirrors = Vec::with_capacity(rows.len()); + for row in rows { + let row = match row { + PluginValue::Array(row) => row, + other => { + return Err(runtime_throw(format!( + "{method} expects mirror maps, got {other:?}" + ))); + } + }; + let url = match row.get(b"url".as_slice()) { + Some(PluginValue::String(url)) => String::from_utf8_lossy(url).into_owned(), + other => { + return Err(runtime_throw(format!( + "{method} expects a string `url` in every mirror, got {other:?}" + ))); + } + }; + let preferred = matches!( + row.get(b"preferred".as_slice()), + Some(PluginValue::Bool(true)) + ); + mirrors.push(crate::package::Mirror { url, preferred }); + } + Ok(Some(mirrors)) +} + +fn decode_optional_string( + method: &str, + value: Option<&PluginValue>, +) -> Result<Option<String>, PhpThrow> { + match value { + None | Some(PluginValue::Null) => Ok(None), + Some(PluginValue::String(bytes)) => Ok(Some(String::from_utf8_lossy(bytes).into_owned())), + other => Err(runtime_throw(format!( + "{method} expects a string or null, got {other:?}" + ))), + } +} + fn dispatch_package_method( package: &std::rc::Rc<std::cell::RefCell<AnyPackage>>, method_name: &str, args: &[PluginValue], ) -> Result<PluginValue, PhpThrow> { - let package = package.borrow(); - let package = package.as_package_interface(); + // The link getters return `array<string, Link>`; only the empty case has a wire image so + // far (an empty PHP array crosses as a list). + // + // TODO(plugin): Link is a rust-snapshot value whose constraint field must materialize as a + // real composer/semver object in the child; the snapshot encoding does not exist yet. + let links = |links: IndexMap<String, crate::package::Link>| -> Result<PluginValue, PhpThrow> { + if links.is_empty() { + Ok(PluginValue::List(Vec::new())) + } else { + Err(runtime_throw(format!( + "the package method `{method_name}` returns Link values, whose encoding over RPC is not implemented yet" + ))) + } + }; + + // Mutators borrow mutably and must not hold the borrow across the shared-borrow arms. + match method_name { + "setId" => { + let id = match args.first() { + Some(PluginValue::Int(id)) => *id, + other => { + return Err(runtime_throw(format!( + "setId expects an int, got {other:?}" + ))); + } + }; + package.borrow_mut().as_package_interface_mut().set_id(id); + return Ok(PluginValue::Null); + } + "setInstallationSource" + | "setSourceReference" + | "setSourceUrl" + | "setDistUrl" + | "setDistType" + | "setDistReference" + | "setSourceDistReferences" => { + let value = decode_optional_string(method_name, args.first())?; + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_package_interface_mut(); + match method_name { + "setInstallationSource" => package.set_installation_source(value), + "setSourceReference" => package.set_source_reference(value), + "setSourceUrl" => package.set_source_url(value), + "setDistUrl" => package.set_dist_url(value), + "setDistType" => package.set_dist_type(value), + "setDistReference" => package.set_dist_reference(value), + _ => package.set_source_dist_references(value.ok_or_else(|| { + runtime_throw("setSourceDistReferences expects a reference string".to_string()) + })?), + } + return Ok(PluginValue::Null); + } + "setSourceMirrors" | "setDistMirrors" => { + let mirrors = decode_mirrors(method_name, args.first())?; + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_package_interface_mut(); + if method_name == "setSourceMirrors" { + package.set_source_mirrors(mirrors); + } else { + package.set_dist_mirrors(mirrors); + } + return Ok(PluginValue::Null); + } + "setRepository" => { + let repository = match args.first() { + Some(PluginValue::RustHandle(handle)) => { + match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) { + Some(RustEntity::Repository(repository)) => repository, + _ => { + return Err(runtime_throw(format!( + "setRepository expects a repository handle, got Rust handle {}", + handle.rhandle + ))); + } + } + } + other => { + return Err(runtime_throw(format!( + "setRepository expects a repository argument, got {other:?}" + ))); + } + }; + package + .borrow_mut() + .as_package_interface_mut() + .set_repository(repository) + .map_err(|error| runtime_throw(format!("setRepository failed: {error}")))?; + return Ok(PluginValue::Null); + } + "setTransportOptions" => { + let options = match args.first() { + Some(value) => match value.to_php_mixed().map_err(|error| { + runtime_throw(format!( + "setTransportOptions could not decode its argument: {error:#}" + )) + })? { + PhpMixed::Array(options) => options, + PhpMixed::List(items) if items.is_empty() => IndexMap::new(), + other => { + return Err(runtime_throw(format!( + "setTransportOptions expects an array, got {other:?}" + ))); + } + }, + None => IndexMap::new(), + }; + package + .borrow_mut() + .as_package_interface_mut() + .set_transport_options(options); + return Ok(PluginValue::Null); + } + _ => {} + } + + let borrowed = package.borrow(); + let package = borrowed.as_package_interface(); match method_name { "getName" => Ok(PluginValue::string(package.get_name().to_string())), + "getPrettyName" => Ok(PluginValue::string(package.get_pretty_name().to_string())), + "getNames" => { + let provides = match args.first() { + None => true, + Some(PluginValue::Bool(provides)) => *provides, + other => { + return Err(runtime_throw(format!( + "getNames expects a bool provides flag, got {other:?}" + ))); + } + }; + Ok(string_list(package.get_names(provides))) + } + "getId" => Ok(PluginValue::Int(package.get_id())), + "isDev" => Ok(PluginValue::Bool(package.is_dev())), "getType" => Ok(PluginValue::string(package.get_type())), + "getTargetDir" => Ok(optional_string(package.get_target_dir())), + "getExtra" => Ok(string_keyed_map(package.get_extra())), + "getInstallationSource" => Ok(optional_string(package.get_installation_source())), + "getSourceType" => Ok(optional_string(package.get_source_type())), + "getSourceUrl" => Ok(optional_string(package.get_source_url())), + "getSourceUrls" => Ok(string_list(package.get_source_urls())), + "getSourceReference" => Ok(optional_string(package.get_source_reference())), + "getSourceMirrors" => Ok(mirror_list(package.get_source_mirrors())), + "getDistType" => Ok(optional_string(package.get_dist_type())), + "getDistUrl" => Ok(optional_string(package.get_dist_url())), + "getDistUrls" => Ok(string_list(package.get_dist_urls())), + "getDistReference" => Ok(optional_string(package.get_dist_reference())), + "getDistSha1Checksum" => Ok(optional_string(package.get_dist_sha1_checksum())), + "getDistMirrors" => Ok(mirror_list(package.get_dist_mirrors())), + "getVersion" => Ok(PluginValue::string(package.get_version().to_string())), "getPrettyVersion" => Ok(PluginValue::string( package.get_pretty_version().to_string(), )), - "getExtra" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( - package.get_extra(), - ))), "getFullPrettyVersion" => { let truncate = match args.first() { None => true, @@ -477,22 +750,56 @@ fn dispatch_package_method( package.get_full_pretty_version(truncate, display_mode), )) } - "getRequires" => { - let requires = package.get_requires(); - if requires.is_empty() { - // An empty PHP array crosses the wire as a list. + "getStability" => Ok(PluginValue::string(package.get_stability().to_string())), + "getRequires" => links(package.get_requires()), + "getConflicts" => links(package.get_conflicts()), + "getProvides" => links(package.get_provides()), + "getReplaces" => links(package.get_replaces()), + "getDevRequires" => links(package.get_dev_requires()), + "getSuggests" => { + let suggests = package.get_suggests(); + if suggests.is_empty() { Ok(PluginValue::List(Vec::new())) } else { - // TODO(plugin): Link is a rust-snapshot value whose constraint field must - // materialize as a real composer/semver object in the child; the snapshot - // encoding does not exist yet. - Err(runtime_throw( - "encoding Link values over RPC is not implemented yet".to_string(), + Ok(PluginValue::Array( + suggests + .into_iter() + .map(|(name, description)| { + (name.into_bytes(), PluginValue::string(description)) + }) + .collect(), )) } } - // TODO(plugin): the remaining PackageInterface surface (setters included) is widened - // on demand, driven by explicit errors from real plugins. + "getAutoload" => Ok(string_keyed_map(package.get_autoload())), + "getDevAutoload" => Ok(string_keyed_map(package.get_dev_autoload())), + "getIncludePaths" => Ok(string_list(package.get_include_paths())), + "getPhpExt" => Ok(match package.get_php_ext() { + Some(config) => string_keyed_map(config), + None => PluginValue::Null, + }), + "getRepository" => match package.get_repository() { + Some(repository) => repository_handle_value(&repository), + None => Ok(PluginValue::Null), + }, + "getBinaries" => Ok(string_list(package.get_binaries())), + "getUniqueName" => Ok(PluginValue::string(package.get_unique_name())), + "getNotificationUrl" => Ok(optional_string(package.get_notification_url())), + "__toString" => Ok(PluginValue::string(package.get_unique_name())), + "getPrettyString" => Ok(PluginValue::string(package.get_pretty_string())), + "isDefaultBranch" => Ok(PluginValue::Bool(package.is_default_branch())), + "getTransportOptions" => Ok(string_keyed_map(package.get_transport_options())), + "getReleaseDate" => match package.get_release_date() { + None => Ok(PluginValue::Null), + // TODO(plugin): a \DateTimeInterface has to materialize as a real PHP object in the + // child, which needs a snapshot encoding for value objects. + Some(_) => Err(runtime_throw( + "encoding the release date over RPC is not implemented yet".to_string(), + )), + }, + // TODO(plugin): the concrete-class surface below PackageInterface (`Package`'s setters, + // `CompletePackage`'s metadata, `RootPackage`'s root-only state) is widened on demand, + // driven by explicit errors from real plugins. other => Err(runtime_throw(format!( "the package method `{other}` is not available over RPC yet" ))), @@ -532,6 +839,35 @@ fn dispatch_installation_manager_method( None => PluginValue::Null, }) } + "addInstaller" | "removeInstaller" => { + let handle = match args.first() { + Some(PluginValue::PhpHandle(handle)) => handle.clone(), + other => { + return Err(runtime_throw(format!( + "{method_name} expects an installer object, got {other:?}" + ))); + } + }; + if !php_is_a(&handle, "Composer\\Installer\\InstallerInterface").map_err(|error| { + runtime_throw(format!( + "{method_name} could not type-check its argument: {error:#}" + )) + })? { + return Err(runtime_throw(format!( + "{method_name} expects a Composer\\Installer\\InstallerInterface, got {}", + handle.class + ))); + } + let phandle = handle.phandle; + let installer = php_installer_proxy(handle); + if method_name == "addInstaller" { + im.borrow().add_installer(installer); + } else { + im.borrow().remove_installer(&*installer); + forget_php_installer_proxy(phandle); + } + Ok(PluginValue::Null) + } // TODO(plugin): the remaining InstallationManager surface is widened on demand, // driven by explicit errors from real plugins. other => Err(runtime_throw(format!( @@ -942,6 +1278,243 @@ pub(crate) fn php_is_a(handle: &PhpObjHandle, class: &str) -> anyhow::Result<boo Ok(matches!(value, PluginValue::Bool(true))) } +/// Wire value handing a Rust-side repository to the child, interned in the R table. +pub(crate) fn repository_handle_value( + repository: &RepositoryInterfaceHandle, +) -> Result<PluginValue, PhpThrow> { + let class = repository_stub_class(repository)?; + let rhandle = register_entity(RustEntity::Repository(repository.clone())); + Ok(rust_handle_value(rhandle, class)) +} + +/// `InstallerInterface` adapter for an installer entity living in the PHP child process: the +/// installer a plugin hands to `InstallationManager::addInstaller`, or the class a legacy +/// `composer-installer` package names. Every call is forwarded as a `CallPhpMethod` RPC. +#[derive(Debug)] +pub struct PhpInstallerProxy { + pub(crate) handle: PhpObjHandle, +} + +impl PhpInstallerProxy { + pub(crate) fn new(handle: PhpObjHandle) -> Self { + Self { handle } + } + + fn call(&self, method: &str, args: Vec<PluginValue>) -> anyhow::Result<PluginValue> { + unwrap_php_result(call_php_method( + self.handle.phandle, + method, + args, + Some(&mut PluginRpcDispatcher::default()), + )) + } + + fn package_arg(package: &PackageInterfaceHandle) -> anyhow::Result<PluginValue> { + Ok(package_handle_value(package.as_rc())?) + } + + fn optional_package_arg( + package: &Option<PackageInterfaceHandle>, + ) -> anyhow::Result<PluginValue> { + match package { + Some(package) => Self::package_arg(package), + None => Ok(PluginValue::Null), + } + } + + fn repo_arg(repo: &InstalledRepositoryInterfaceHandle) -> anyhow::Result<PluginValue> { + Ok(repository_handle_value(&repo.as_repository_handle())?) + } + + /// The `?PromiseInterface` half of the installer contract. A plugin installer that returns + /// a real promise needs the promise machinery the RPC boundary does not carry yet, so it is + /// an explicit error rather than a silently dropped continuation. + fn promise_result(&self, method: &str, value: PluginValue) -> anyhow::Result<Option<PhpMixed>> { + match value { + PluginValue::Null => Ok(None), + other => Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "{}::{method}() returned a promise, which cannot cross the RPC boundary yet: {other:?}", + self.handle.class + ), + code: 0, + })), + } + } + + fn unsupported_shape(&self, method: &str, value: &PluginValue) -> anyhow::Error { + anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "{}::{method}() returned an unsupported shape over RPC: {value:?}", + self.handle.class + ), + code: 0, + }) + } +} + +#[async_trait::async_trait(?Send)] +impl crate::installer::InstallerInterface for PhpInstallerProxy { + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { + match self.call("supports", vec![PluginValue::string(package_type)])? { + PluginValue::Bool(supports) => Ok(supports), + other => Err(self.unsupported_shape("supports", &other)), + } + } + + fn is_installed( + &self, + repo: &InstalledRepositoryInterfaceHandle, + package: PackageInterfaceHandle, + ) -> anyhow::Result<bool> { + let args = vec![Self::repo_arg(repo)?, Self::package_arg(&package)?]; + match self.call("isInstalled", args)? { + PluginValue::Bool(installed) => Ok(installed), + other => Err(self.unsupported_shape("isInstalled", &other)), + } + } + + async fn download( + &self, + package: PackageInterfaceHandle, + prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![ + Self::package_arg(&package)?, + Self::optional_package_arg(&prev_package)?, + ]; + let value = self.call("download", args)?; + self.promise_result("download", value) + } + + async fn prepare( + &self, + r#type: &str, + package: PackageInterfaceHandle, + prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![ + PluginValue::string(r#type), + Self::package_arg(&package)?, + Self::optional_package_arg(&prev_package)?, + ]; + let value = self.call("prepare", args)?; + self.promise_result("prepare", value) + } + + async fn install( + &self, + repo: &InstalledRepositoryInterfaceHandle, + package: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![Self::repo_arg(repo)?, Self::package_arg(&package)?]; + let value = self.call("install", args)?; + self.promise_result("install", value) + } + + async fn update( + &self, + repo: &InstalledRepositoryInterfaceHandle, + initial: PackageInterfaceHandle, + target: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![ + Self::repo_arg(repo)?, + Self::package_arg(&initial)?, + Self::package_arg(&target)?, + ]; + let value = self.call("update", args)?; + self.promise_result("update", value) + } + + async fn uninstall( + &self, + repo: &InstalledRepositoryInterfaceHandle, + package: PackageInterfaceHandle, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![Self::repo_arg(repo)?, Self::package_arg(&package)?]; + let value = self.call("uninstall", args)?; + self.promise_result("uninstall", value) + } + + async fn cleanup( + &self, + r#type: &str, + package: PackageInterfaceHandle, + prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + let args = vec![ + PluginValue::string(r#type), + Self::package_arg(&package)?, + Self::optional_package_arg(&prev_package)?, + ]; + let value = self.call("cleanup", args)?; + self.promise_result("cleanup", value) + } + + fn get_install_path(&self, package: PackageInterfaceHandle) -> Option<String> { + // PHP declares `getInstallPath(): string`; a failure here is a plugin error the + // infallible signature cannot carry, so it aborts rather than answering a path that + // would silently install the package in the wrong place. + let args = vec![Self::package_arg(&package).unwrap_or_else(|error| { + panic!( + "{}::getInstallPath argument failed: {error:#}", + self.handle.class + ) + })]; + let value = self.call("getInstallPath", args).unwrap_or_else(|error| { + panic!( + "{}::getInstallPath failed over RPC: {error:#}", + self.handle.class + ) + }); + match value { + PluginValue::Null => None, + PluginValue::String(path) => Some(String::from_utf8_lossy(&path).into_owned()), + other => panic!("{}", self.unsupported_shape("getInstallPath", &other)), + } + } +} + +impl Drop for PhpInstallerProxy { + fn drop(&mut self) { + let _ = release_php_handle(self.handle.phandle); + } +} + +thread_local! { + /// The installer adapters handed to `InstallationManager::addInstaller` over RPC, keyed by + /// the entity's phandle. `removeInstaller` arrives carrying the same entity, and the + /// manager compares installers by identity, so the adapter it was given has to be found + /// again rather than rebuilt. + static PHP_INSTALLER_PROXIES: std::cell::RefCell<IndexMap<u64, std::rc::Rc<dyn crate::installer::InstallerInterface>>> = + std::cell::RefCell::new(IndexMap::new()); +} + +/// The adapter for an installer entity, building it on first sight. +pub(crate) fn php_installer_proxy( + handle: PhpObjHandle, +) -> std::rc::Rc<dyn crate::installer::InstallerInterface> { + PHP_INSTALLER_PROXIES.with(|proxies| { + let mut proxies = proxies.borrow_mut(); + if let Some(existing) = proxies.get(&handle.phandle) { + return existing.clone(); + } + let phandle = handle.phandle; + let proxy: std::rc::Rc<dyn crate::installer::InstallerInterface> = + std::rc::Rc::new(PhpInstallerProxy::new(handle)); + proxies.insert(phandle, proxy.clone()); + proxy + }) +} + +/// Drops the adapter bookkeeping for an installer entity that left the manager. +fn forget_php_installer_proxy(phandle: u64) { + PHP_INSTALLER_PROXIES.with(|proxies| { + proxies.borrow_mut().shift_remove(&phandle); + }); +} + /// `Capability` adapter for a capability entity living in the PHP child process, for /// capability interfaces that add no methods of their own (the plain /// `Composer\Plugin\Capability\Capability` marker). diff --git a/crates/shirabe/src/plugin/plugin_manager.rs b/crates/shirabe/src/plugin/plugin_manager.rs index 9e52c01f..8f005079 100644 --- a/crates/shirabe/src/plugin/plugin_manager.rs +++ b/crates/shirabe/src/plugin/plugin_manager.rs @@ -47,12 +47,13 @@ pub struct PluginManager { allow_plugin_rules: Option<IndexMap<String, bool>>, allow_global_plugin_rules: Option<IndexMap<String, bool>>, running_in_global_dir: bool, + plugin_api_version_override: Option<String>, } #[derive(Debug)] pub enum PluginOrInstaller { Plugin(std::rc::Rc<std::cell::RefCell<dyn PluginInterface>>), - Installer(Box<dyn InstallerInterface>), + Installer(std::rc::Rc<dyn InstallerInterface>), } /// PHP `private static $classCounter = 0;`. @@ -101,9 +102,17 @@ impl PluginManager { allow_plugin_rules, allow_global_plugin_rules, running_in_global_dir: false, + plugin_api_version_override: None, } } + /// For testing only: makes `get_plugin_api_version` report `version` instead of the + /// compiled-in constant, the seam PHPUnit obtains from + /// `getMockBuilder(PluginManager::class)->onlyMethods(['getPluginApiVersion'])`. + pub fn __set_plugin_api_version(&mut self, version: &str) { + self.plugin_api_version_override = Some(version.to_string()); + } + pub fn set_running_in_global_dir(&mut self, running_in_global_dir: bool) { self.running_in_global_dir = running_in_global_dir; } @@ -460,16 +469,46 @@ impl PluginManager { } if old_installer_plugin { - // TODO(plugin): legacy composer-installer plugins need the InstallerInterface - // reverse adapter, which does not exist yet; explicit error until then. - return Err(RuntimeException { - message: format!( - "Shirabe cannot load \"{}\": legacy composer-installer plugins are not supported yet", - package.get_name() - ), - code: 0, + if !self.php_runtime_is_a(&class, "Composer\\Installer\\InstallerInterface")? { + return Err(RuntimeException { + message: format!( + "Could not activate plugin \"{}\" as \"{}\" does not implement Composer\\Installer\\InstallerInterface", + package.get_name(), + class + ), + code: 0, + } + .into()); } - .into()); + self.io.write_error(&format!( + "<warning>Loading \"{}\" {}which is a legacy composer-installer built for Composer 1.x, it is likely to cause issues as you are running Composer 2.x.</warning>", + package.get_name(), + if is_global_plugin || self.running_in_global_dir { + "(installed globally) " + } else { + "" + } + )); + let composer = self.composer_full(); + let handle = self.php_runtime_new_object_with_args( + &class, + vec![ + crate::plugin::io_handle_value(&self.io)?, + crate::plugin::composer_handle_value(&composer), + ], + )?; + let installer = crate::plugin::php_installer_proxy(handle); + // A shared borrow: this runs inside `InstallationManager::execute`, which holds + // one of its own for the whole run. + composer + .borrow() + .get_installation_manager() + .borrow() + .add_installer(installer.clone()); + self.registered_plugins + .entry(package.get_name().to_string()) + .or_default() + .push(PluginOrInstaller::Installer(installer)); } else if self.php_runtime_class_exists(&class, true)? { if !self.php_runtime_is_a(&class, "Composer\\Plugin\\PluginInterface")? { return Err(RuntimeException { @@ -569,9 +608,17 @@ impl PluginManager { /// PHP `new $class()` in the worker, returning the P-table handle of the new entity. fn php_runtime_new_object(&self, class: &str) -> anyhow::Result<shirabe_php_rpc::PhpObjHandle> { + self.php_runtime_new_object_with_args(class, vec![]) + } + + fn php_runtime_new_object_with_args( + &self, + class: &str, + args: Vec<PluginValue>, + ) -> anyhow::Result<shirabe_php_rpc::PhpObjHandle> { let value = unwrap_php_result(shirabe_php_rpc::new_object( class, - vec![], + args, Some(&mut PluginRpcDispatcher::default()), ))?; match value { @@ -610,7 +657,7 @@ impl PluginManager { if let PluginOrInstaller::Installer(inst) = &self.registered_plugins.get(&name).unwrap()[index] { - installation_manager.borrow_mut().remove_installer(&**inst); + installation_manager.borrow().remove_installer(&**inst); } } } @@ -648,7 +695,7 @@ impl PluginManager { if let PluginOrInstaller::Installer(inst) = &self.registered_plugins.get(&name).unwrap()[index] { - installation_manager.borrow_mut().remove_installer(&**inst); + installation_manager.borrow().remove_installer(&**inst); } } } @@ -659,7 +706,10 @@ impl PluginManager { /// Returns the version of the internal composer-plugin-api package. pub(crate) fn get_plugin_api_version(&self) -> String { - plugin_interface::PLUGIN_API_VERSION.to_string() + match &self.plugin_api_version_override { + Some(version) => version.clone(), + None => plugin_interface::PLUGIN_API_VERSION.to_string(), + } } /// Adds a plugin, activates it and registers it with the event dispatcher diff --git a/crates/shirabe/tests/autoload/autoload_generator_test.rs b/crates/shirabe/tests/autoload/autoload_generator_test.rs index 85e98d81..81f508cc 100644 --- a/crates/shirabe/tests/autoload/autoload_generator_test.rs +++ b/crates/shirabe/tests/autoload/autoload_generator_test.rs @@ -33,8 +33,8 @@ struct InstallPathStubInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for InstallPathStubInstaller { - fn supports(&self, _package_type: &str) -> bool { - true + fn supports(&self, _package_type: &str) -> anyhow::Result<bool> { + Ok(true) } fn is_installed( @@ -155,8 +155,8 @@ fn make_installation_manager( ))); let loop_ = std::rc::Rc::new(std::cell::RefCell::new(Loop::new(http_downloader, None))); - let mut im = InstallationManager::new(loop_, io, None); - im.add_installer(Box::new(InstallPathStubInstaller { + let im = InstallationManager::new(loop_, io, None); + im.add_installer(std::rc::Rc::new(InstallPathStubInstaller { vendor_dir: vendor_dir.to_string(), })); im @@ -491,7 +491,7 @@ fn test_vendor_dir_same_as_working_dir() { let mut s = set_up(); s.vendor_dir = s.working_dir.clone(); // Re-register the install-path stub so getInstallPath uses the new vendor dir. - s.im.add_installer(Box::new(InstallPathStubInstaller { + s.im.add_installer(std::rc::Rc::new(InstallPathStubInstaller { vendor_dir: s.vendor_dir.clone(), })); @@ -551,7 +551,7 @@ fn test_root_package_autoloading_alternative_vendor_dir() { ])); s.vendor_dir = format!("{}/subdir", s.vendor_dir); - s.im.add_installer(Box::new(InstallPathStubInstaller { + s.im.add_installer(std::rc::Rc::new(InstallPathStubInstaller { vendor_dir: s.vendor_dir.clone(), })); diff --git a/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs b/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs index 5a6c93e5..674c4a80 100644 --- a/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs +++ b/crates/shirabe/tests/event_dispatcher/event_dispatcher_test.rs @@ -421,8 +421,8 @@ mockall::mock! { #[derive(Debug)] pub InstallationManager {} impl InstallationManagerInterface for InstallationManager { - fn add_installer(&mut self, installer: Box<dyn InstallerInterface>); - fn remove_installer(&mut self, installer: &dyn InstallerInterface); + fn add_installer(&self, installer: std::rc::Rc<dyn InstallerInterface>); + fn remove_installer(&self, installer: &dyn InstallerInterface); fn disable_plugins(&mut self); fn is_package_installed( &mut self, diff --git a/crates/shirabe/tests/installer/installation_manager_test.rs b/crates/shirabe/tests/installer/installation_manager_test.rs index 8f2d7d4f..c511703e 100644 --- a/crates/shirabe/tests/installer/installation_manager_test.rs +++ b/crates/shirabe/tests/installer/installation_manager_test.rs @@ -67,8 +67,8 @@ mockall::mock! { #[async_trait::async_trait(?Send)] impl InstallerInterface for MockInstaller { - fn supports(&self, package_type: &str) -> bool { - MockInstaller::supports(self, package_type) + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { + Ok(MockInstaller::supports(self, package_type)) } fn is_installed( @@ -163,12 +163,12 @@ impl BinaryInstaller { #[async_trait::async_trait(?Send)] impl InstallerInterface for BinaryInstaller { - fn supports(&self, package_type: &str) -> bool { + fn supports(&self, package_type: &str) -> anyhow::Result<bool> { self.calls .borrow_mut() .supports_args .push(package_type.to_string()); - package_type == "library" + Ok(package_type == "library") } fn is_installed( @@ -272,10 +272,10 @@ fn test_add_get_installer() { .times(2) .returning(|arg| arg == "vendor"); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(installer)); + manager.add_installer(std::rc::Rc::new(installer)); assert!(manager.get_installer("vendor").is_ok()); assert!(manager.get_installer("unregistered").is_err()); @@ -290,7 +290,7 @@ fn test_add_remove_installer() { .times(2) .returning(|arg| arg == "vendor"); // The manager stores installers as Rc, so the PHP object-identity semantics (assertSame, - // removeInstaller) map to Rc::ptr_eq on a handle registered via __add_installer. + // removeInstaller) map to Rc::ptr_eq on the handle the caller keeps. let installer: std::rc::Rc<dyn InstallerInterface> = std::rc::Rc::new(installer); let mut installer2 = MockInstaller::new(); @@ -300,15 +300,15 @@ fn test_add_remove_installer() { .returning(|arg| arg == "vendor"); let installer2: std::rc::Rc<dyn InstallerInterface> = std::rc::Rc::new(installer2); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.__add_installer(installer.clone()); + manager.add_installer(installer.clone()); assert!(std::rc::Rc::ptr_eq( &installer, &manager.get_installer("vendor").unwrap() )); - manager.__add_installer(installer2.clone()); + manager.add_installer(installer2.clone()); assert!(std::rc::Rc::ptr_eq( &installer2, &manager.get_installer("vendor").unwrap() @@ -352,9 +352,9 @@ fn test_install() { .withf_st(move |package| same_handle(package, &expected)) .returning(|_| Ok(None)); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(installer)); + manager.add_installer(std::rc::Rc::new(installer)); let operation = InstallOperation::new(package); @@ -385,9 +385,9 @@ fn test_update_with_equal_types() { }) .returning(|_, _| Ok(None)); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(installer)); + manager.add_installer(std::rc::Rc::new(installer)); let operation = UpdateOperation::new(initial, target); @@ -427,10 +427,10 @@ fn test_update_with_not_equal_types() { .withf_st(move |package| same_handle(package, &expected_target)) .returning(|_| Ok(None)); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(lib_installer)); - manager.add_installer(Box::new(bundle_installer)); + manager.add_installer(std::rc::Rc::new(lib_installer)); + manager.add_installer(std::rc::Rc::new(bundle_installer)); let operation = UpdateOperation::new(initial, target); @@ -457,9 +457,9 @@ fn test_uninstall() { .withf_st(move |package| same_handle(package, &expected)) .returning(|_| Ok(None)); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(installer)); + manager.add_installer(std::rc::Rc::new(installer)); let operation = UninstallOperation::new(package); @@ -472,9 +472,9 @@ fn test_uninstall() { fn test_install_binary() { let set_up = set_up(); let (installer, calls) = BinaryInstaller::new(); - let mut manager = + let manager = shirabe::installer::InstallationManager::new(set_up.loop_.clone(), set_up.io.clone(), None); - manager.add_installer(Box::new(installer)); + manager.add_installer(std::rc::Rc::new(installer)); let package = get_package("test/pkg", "1.0.0"); manager.ensure_binaries_presence(package.clone()); diff --git a/crates/shirabe/tests/plugin/plugin_installer_test.rs b/crates/shirabe/tests/plugin/plugin_installer_test.rs index 35088c8d..06cd2ce9 100644 --- a/crates/shirabe/tests/plugin/plugin_installer_test.rs +++ b/crates/shirabe/tests/plugin/plugin_installer_test.rs @@ -16,7 +16,9 @@ use shirabe::io::IOInterface; use shirabe::io::buffer_io::BufferIO; use shirabe::json::JsonFile; use shirabe::package::loader::{ArrayLoader, JsonLoader, JsonLoaderInput}; -use shirabe::package::{Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle}; +use shirabe::package::{ + CompletePackageHandle, Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle, +}; use shirabe::plugin::plugin_interface::PluginInterface; use shirabe::plugin::{Capable, PluginManager, composer_handle_value, io_handle_value}; use shirabe::repository::{ @@ -30,6 +32,7 @@ use shirabe::util::process_executor::ProcessExecutor; use shirabe_external_packages::symfony::console::output::output_interface::VERBOSITY_NORMAL; use shirabe_external_packages::symfony::process::PhpExecutableFinder; use shirabe_php_shim::PhpMixed; +use shirabe_semver::VersionParser; use tempfile::TempDir; /// The register/activate flow runs the plugin in the real PHP worker; without a PHP binary the @@ -166,9 +169,9 @@ impl RepositoryManagerInterface for MockRepositoryManager { struct MockInstallationManager; impl InstallationManagerInterface for MockInstallationManager { - fn add_installer(&mut self, _installer: Box<dyn InstallerInterface>) {} + fn add_installer(&self, _installer: std::rc::Rc<dyn InstallerInterface>) {} - fn remove_installer(&mut self, _installer: &dyn InstallerInterface) {} + fn remove_installer(&self, _installer: &dyn InstallerInterface) {} fn disable_plugins(&mut self) {} @@ -606,31 +609,114 @@ fn test_register_plugin_only_one_time() { assert_eq!("activate v1\n", set_up.io.borrow().get_output()); } -// PluginManager::get_plugin_api_version returns a hardcoded constant -// (plugin_interface::PLUGIN_API_VERSION) with no seam to override it per-test the way PHP's -// `getMockBuilder(PluginManager::class)->onlyMethods(['getPluginApiVersion'])` does. -#[ignore = "Requires mocking getPluginApiVersion; PluginManager has no such seam (TODO(plugin))"] +/// PHP `setPluginApiVersionWithPlugins`: swaps in a plugin manager reporting +/// `new_plugin_api_version` (PHP mocks `getPluginApiVersion`; the Rust seam is +/// `__set_plugin_api_version`) and a local repository holding the internal composer-plugin-api +/// package plus `plugins` (PHP mocks `getPackages`), then loads the installed plugins. +fn set_plugin_api_version_with_plugins( + set_up: &SetUp, + new_plugin_api_version: &str, + plugins: Vec<PackageInterfaceHandle>, +) -> std::rc::Rc<std::cell::RefCell<PluginManager>> { + let pm = std::rc::Rc::new(std::cell::RefCell::new(PluginManager::new( + set_up.io_dyn.clone(), + set_up.composer.downgrade(), + None, + DisablePlugins::None, + ))); + pm.borrow_mut() + .__set_plugin_api_version(new_plugin_api_version); + set_up.composer.borrow_mut().set_plugin_manager(pm.clone()); + + let plug_api_internal_package: PackageInterfaceHandle = CompletePackageHandle::new( + "composer-plugin-api".to_string(), + VersionParser + .normalize(new_plugin_api_version, None) + .unwrap(), + new_plugin_api_version.to_string(), + ) + .into(); + let repository = + InstalledRepositoryInterfaceHandle::new(InstalledArrayRepository::new().unwrap()); + repository + .borrow_mut() + .add_package(plug_api_internal_package) + .unwrap(); + for plugin in plugins { + repository.borrow_mut().add_package(plugin).unwrap(); + } + set_up + .composer + .borrow() + .get_repository_manager() + .borrow_mut() + .set_local_repository(repository.as_repository_handle()); + + pm.borrow_mut().load_installed_plugins().unwrap(); + pm +} + #[test] fn test_star_plugin_version_works_with_any_api_version() { - // TODO(phase-d): requires mocking getPluginApiVersion; PluginManager has no such seam - // (TODO(plugin)). - todo!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + let star_version_plugin = || vec![PackageInterfaceHandle::dup(&set_up.packages[4])]; + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.0.0", star_version_plugin()); + assert_eq!(1, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.9.9", star_version_plugin()); + assert_eq!(1, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "2.0.0-dev", star_version_plugin()); + assert_eq!(1, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "100.0.0-stable", star_version_plugin()); + assert_eq!(1, pm.borrow().get_plugins().len()); } -#[ignore = "Requires mocking getPluginApiVersion; PluginManager has no such seam (TODO(plugin))"] #[test] fn test_plugin_constraint_works_only_with_certain_api_version() { - // TODO(phase-d): requires mocking getPluginApiVersion; PluginManager has no such seam - // (TODO(plugin)). - todo!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + let plugin_with_api_constraint = || vec![PackageInterfaceHandle::dup(&set_up.packages[5])]; + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.0.0", plugin_with_api_constraint()); + assert_eq!(0, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.1.9", plugin_with_api_constraint()); + assert_eq!(0, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.2.0", plugin_with_api_constraint()); + assert_eq!(1, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.9.9", plugin_with_api_constraint()); + assert_eq!(1, pm.borrow().get_plugins().len()); } -#[ignore = "Requires mocking getPluginApiVersion; PluginManager has no such seam (TODO(plugin))"] #[test] fn test_plugin_range_constraints_work_only_with_certain_api_version() { - // TODO(phase-d): requires mocking getPluginApiVersion; PluginManager has no such seam - // (TODO(plugin)). - todo!() + if !php_runtime_available() { + return; + } + let _worker = lock_php_worker(); + let set_up = set_up(); + let plugin_with_api_constraint = || vec![PackageInterfaceHandle::dup(&set_up.packages[6])]; + + let pm = set_plugin_api_version_with_plugins(&set_up, "1.0.0", plugin_with_api_constraint()); + assert_eq!(0, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "3.0.0", plugin_with_api_constraint()); + assert_eq!(1, pm.borrow().get_plugins().len()); + + let pm = set_plugin_api_version_with_plugins(&set_up, "5.5.0", plugin_with_api_constraint()); + assert_eq!(0, pm.borrow().get_plugins().len()); } #[test] diff --git a/crates/shirabe/tests/repository/filesystem_repository_test.rs b/crates/shirabe/tests/repository/filesystem_repository_test.rs index bcdb766e..3279f29e 100644 --- a/crates/shirabe/tests/repository/filesystem_repository_test.rs +++ b/crates/shirabe/tests/repository/filesystem_repository_test.rs @@ -93,8 +93,8 @@ mockall::mock! { #[derive(Debug)] pub InstallationManager {} impl InstallationManagerInterface for InstallationManager { - fn add_installer(&mut self, installer: Box<dyn InstallerInterface>); - fn remove_installer(&mut self, installer: &dyn InstallerInterface); + fn add_installer(&self, installer: std::rc::Rc<dyn InstallerInterface>); + fn remove_installer(&self, installer: &dyn InstallerInterface); fn disable_plugins(&mut self); fn is_package_installed( &mut self, |
