diff options
Diffstat (limited to 'crates/shirabe/src')
| -rw-r--r-- | crates/shirabe/src/command/create_project_command.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/src/factory.rs | 9 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/installation_manager.rs | 76 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/installer_interface.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/library_installer.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/metapackage_installer.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/noop_installer.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/plugin_installer.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/project_installer.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 609 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/plugin_manager.rs | 78 |
11 files changed, 710 insertions, 88 deletions
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 |
