diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-05 02:48:41 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-05 02:48:41 +0900 |
| commit | ee5d0e84c2060f10c20a4b3e519bf0f928fd170d (patch) | |
| tree | b7ab50ac5674cebc88bea60e5a2c33a59b4482d2 /crates/shirabe | |
| parent | 11cdaae87c37e479fea6c39447041c312410b101 (diff) | |
| download | php-shirabe-ee5d0e84c2060f10c20a4b3e519bf0f928fd170d.tar.gz php-shirabe-ee5d0e84c2060f10c20a4b3e519bf0f928fd170d.tar.zst php-shirabe-ee5d0e84c2060f10c20a4b3e519bf0f928fd170d.zip | |
feat(plugin): discover and list plugin-provided commands
Port Application::getPluginCommands: resolve the local composer with
plugins force-enabled, fall back to Factory::createGlobal, and collect
commands from CommandProvider capability adapters. PhpCommandProxy now
mirrors name/description/aliases/hidden over RPC so `list` output
matches upstream; input definitions remain TODO(plugin).
PhpClass::php_class_name returns an owned String because PHP-backed
proxies only know their class at runtime (the override-skip warning
prints get_class). CommandProvider::getCommands hands out shared
Rc<RefCell> handles since the commands are stored in the application.
Factory::createGlobal now propagates createConfig errors instead of
swallowing them, and Application::getComposer only catches the
exception classes upstream catches, so a ParsingException reaches
doRun's GithubActionError path as in Composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/command/base_command.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 75 | ||||
| -rw-r--r-- | crates/shirabe/src/factory.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/capability/command_provider.rs | 3 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 94 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/vcs.rs | 3 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/vcs_repository.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/plugin_installer_test.rs | 2 |
8 files changed, 143 insertions, 46 deletions
diff --git a/crates/shirabe/src/command/base_command.rs b/crates/shirabe/src/command/base_command.rs index 77850e9e..f9682de9 100644 --- a/crates/shirabe/src/command/base_command.rs +++ b/crates/shirabe/src/command/base_command.rs @@ -283,7 +283,7 @@ impl Command for BaseCommandData { } impl PhpClass for BaseCommandData { - fn php_class_name(&self) -> &'static str { + fn php_class_name(&self) -> String { // Forwards to the base state's panicking implementation; concrete commands supply // their own class name through `impl_php_class!`. self.inner.php_class_name() @@ -852,7 +852,7 @@ pub fn base_command_initialize( crate::factory::DisablePlugins::None }; let composer = if composer.is_none() { - Factory::create_global(io.clone(), disable_plugins_kind, disable_scripts) + Factory::create_global(io.clone(), disable_plugins_kind, disable_scripts)? } else { composer }; diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index e6cfad35..cf57a5bf 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -416,14 +416,10 @@ impl Application { match Factory::create(io_for_factory, None, disable_plugins_enum, disable_scripts) { Ok(c) => self.composer = Some(c.upcast()), Err(e) => { - if e.downcast_ref::<JsonValidationException>().is_some() - || e.downcast_ref::<RuntimeException>().is_some() + if e.downcast_ref::<shirabe_php_shim::InvalidArgumentException>() + .is_some() { if required { - return Err(e); - } - } else { - if required { self.io.write_error(&e.to_string()); if self.are_exceptions_caught() { // PHP calls `exit(1)` here, terminating before parent::run() can @@ -433,6 +429,19 @@ impl Application { } return Err(e); } + } else if e.downcast_ref::<JsonValidationException>().is_some() + || e.downcast_ref::<RuntimeException>().is_some() + // PHP's `catch (RuntimeException)` also catches subclasses; + // NoSslException is the one Factory::create raises. + || e.downcast_ref::<NoSslException>().is_some() + { + if required { + return Err(e); + } + } else { + // Anything else (e.g. seld/jsonlint's ParsingException) propagates + // regardless of $required, feeding doRun's GithubActionError path. + return Err(e); } } } @@ -584,8 +593,53 @@ impl Application { fn get_plugin_commands( &mut self, ) -> anyhow::Result<Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> { - // TODO(plugin): plugin command discovery is part of the plugin API - Ok(vec![]) + let mut commands: Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>> = vec![]; + + let mut composer = self.get_composer(false, Some(false), None)?; + if composer.is_none() { + let disable_plugins = if self.disable_plugins_by_default { + crate::factory::DisablePlugins::All + } else { + crate::factory::DisablePlugins::None + }; + composer = Factory::create_global( + self.io.clone(), + disable_plugins, + self.disable_scripts_by_default, + )?; + } + + if let Some(composer) = composer { + let composer = composer + .as_full() + .expect("Factory::create and Factory::create_global build a full Composer"); + let pm = composer.borrow().get_plugin_manager(); + let mut ctor_args: IndexMap<String, shirabe_php_rpc::PluginValue> = IndexMap::new(); + ctor_args.insert( + "composer".to_string(), + crate::plugin::composer_handle_value(&composer), + ); + ctor_args.insert("io".to_string(), crate::plugin::io_handle_value(&self.io)?); + let capabilities = pm.borrow().get_plugin_capabilities( + "Composer\\Plugin\\Capability\\CommandProvider", + ctor_args, + )?; + for capability in capabilities { + let provider = capability.as_command_provider().expect( + "get_plugin_capability builds a CommandProvider adapter for this capability class", + ); + // The is_array / instanceof BaseCommand checks PHP performs on the raw + // getCommands value live in the adapter. + let new_commands = provider.get_commands()?; + commands.extend( + new_commands.into_iter().map(|command| { + command as std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>> + }), + ); + } + } + + Ok(commands) } /// Get the working directory at startup time @@ -2141,10 +2195,7 @@ impl ApplicationHandle { for command in plugin_commands { let cmd_name = command.borrow().get_name().unwrap_or_default(); if application.borrow_mut().has(&cmd_name) { - // TODO(plugin): PHP uses get_class($command) for the skipped-command class - // name. Plugin command discovery (get_plugin_commands) is unimplemented, so - // this loop never runs; wire the concrete class name with the plugin API. - let cls = String::new(); + let cls = command.borrow().php_class_name(); io.write_error(&format!("<warning>Plugin command {} ({}) would override a Composer command and has been skipped</warning>", cmd_name, cls)); } else { self.add(command)?; diff --git a/crates/shirabe/src/factory.rs b/crates/shirabe/src/factory.rs index 9a84117e..fe3b5289 100644 --- a/crates/shirabe/src/factory.rs +++ b/crates/shirabe/src/factory.rs @@ -903,11 +903,11 @@ impl Factory { io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, disable_plugins: DisablePlugins, disable_scripts: bool, - ) -> Option<PartialComposerHandle> { + ) -> anyhow::Result<Option<PartialComposerHandle>> { let factory = Self::default(); - let config = Self::create_config(Some(io.clone()), None).ok()?; - factory.create_global_composer(io, &config, disable_plugins, disable_scripts, true) + let config = Self::create_config(Some(io.clone()), None)?; + Ok(factory.create_global_composer(io, &config, disable_plugins, disable_scripts, true)) } fn add_local_repository( diff --git a/crates/shirabe/src/plugin/capability/command_provider.rs b/crates/shirabe/src/plugin/capability/command_provider.rs index 2453860a..9deb1bce 100644 --- a/crates/shirabe/src/plugin/capability/command_provider.rs +++ b/crates/shirabe/src/plugin/capability/command_provider.rs @@ -9,5 +9,6 @@ use crate::plugin::capability::Capability; /// The sole implementor is the PHP capability proxy (Composer itself never implements a /// capability), so the method is fallible: the answer crosses the RPC boundary. pub trait CommandProvider: Capability { - fn get_commands(&self) -> anyhow::Result<Vec<Box<dyn BaseCommand>>>; + fn get_commands(&self) + -> anyhow::Result<Vec<std::rc::Rc<std::cell::RefCell<dyn BaseCommand>>>>; } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 9cccb1af..71182477 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -910,7 +910,9 @@ impl Capability for PhpCommandProviderProxy { } impl CommandProvider for PhpCommandProviderProxy { - fn get_commands(&self) -> anyhow::Result<Vec<Box<dyn BaseCommand>>> { + fn get_commands( + &self, + ) -> anyhow::Result<Vec<std::rc::Rc<std::cell::RefCell<dyn BaseCommand>>>> { let value = unwrap_php_result(call_php_method( self.handle.phandle, "getCommands", @@ -936,7 +938,7 @@ impl CommandProvider for PhpCommandProviderProxy { )); } }; - let mut commands: Vec<Box<dyn BaseCommand>> = Vec::new(); + let mut commands: Vec<std::rc::Rc<std::cell::RefCell<dyn BaseCommand>>> = Vec::new(); for item in items { let command_handle = match item { PluginValue::PhpHandle(handle) => handle, @@ -945,7 +947,9 @@ impl CommandProvider for PhpCommandProviderProxy { if !php_is_a(&command_handle, "Composer\\Command\\BaseCommand")? { return Err(invalid_command_error(&self.handle)); } - commands.push(Box::new(PhpCommandProxy::new(command_handle)?)); + commands.push(std::rc::Rc::new(std::cell::RefCell::new( + PhpCommandProxy::new(command_handle)?, + ))); } Ok(commands) } @@ -968,9 +972,10 @@ impl Drop for PhpCommandProviderProxy { } /// `BaseCommand` adapter for a command entity living in the PHP child process. The Rust-side -/// command state mirrors the child's (the name is read back over RPC at construction, after -/// the PHP constructor ran `configure()`); running the command needs the PHP-side Symfony -/// Application and is an explicit error until that exists. +/// command state mirrors the child's list metadata (name, description, aliases, hidden flag — +/// read back over RPC at construction, after the PHP constructor ran `configure()`); running +/// the command needs the PHP-side Symfony Application and is an explicit error until that +/// exists. #[derive(Debug)] pub struct PhpCommandProxy { base_command_data: crate::command::BaseCommandData, @@ -980,32 +985,74 @@ pub struct PhpCommandProxy { impl PhpCommandProxy { pub(crate) fn new(handle: PhpObjHandle) -> anyhow::Result<Self> { let data = crate::command::BaseCommandData::new(None); - let name = unwrap_php_result(call_php_method( - handle.phandle, - "getName", - Vec::new(), - Some(&mut PluginRpcDispatcher::default()), - ))?; + // TODO(plugin): the input definition (arguments/options) is not read back yet; + // `help` rendering and input parsing for this command need it. + let name = Self::call_metadata_getter(&handle, "getName")?; match name { PluginValue::Null => {} PluginValue::String(bytes) => { Command::set_name(&data, &String::from_utf8_lossy(&bytes))?; } - other => { - return Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { - message: format!( - "{}::getName() returned an unsupported shape over RPC: {other:?}", - handle.class - ), - code: 0, - })); + other => return Err(Self::unsupported_shape(&handle, "getName", &other)), + } + let description = Self::call_metadata_getter(&handle, "getDescription")?; + match description { + PluginValue::String(bytes) => { + Command::set_description(&data, &String::from_utf8_lossy(&bytes)); + } + other => return Err(Self::unsupported_shape(&handle, "getDescription", &other)), + } + let aliases = Self::call_metadata_getter(&handle, "getAliases")?; + let alias_items = match aliases { + PluginValue::List(items) => items, + PluginValue::Array(map) => map.into_values().collect(), + other => return Err(Self::unsupported_shape(&handle, "getAliases", &other)), + }; + let mut alias_names = Vec::new(); + for alias in alias_items { + match alias { + PluginValue::String(bytes) => { + alias_names.push(String::from_utf8_lossy(&bytes).into_owned()); + } + other => return Err(Self::unsupported_shape(&handle, "getAliases", &other)), } } + Command::set_aliases(&data, alias_names)?; + let hidden = Self::call_metadata_getter(&handle, "isHidden")?; + match hidden { + PluginValue::Bool(hidden) => { + Command::set_hidden(&data, hidden); + } + other => return Err(Self::unsupported_shape(&handle, "isHidden", &other)), + } Ok(Self { base_command_data: data, handle, }) } + + fn call_metadata_getter(handle: &PhpObjHandle, method: &str) -> anyhow::Result<PluginValue> { + unwrap_php_result(call_php_method( + handle.phandle, + method, + Vec::new(), + Some(&mut PluginRpcDispatcher::default()), + )) + } + + fn unsupported_shape( + handle: &PhpObjHandle, + method: &str, + value: &PluginValue, + ) -> anyhow::Error { + anyhow::anyhow!(shirabe_php_shim::RuntimeException { + message: format!( + "{}::{method}() returned an unsupported shape over RPC: {value:?}", + handle.class + ), + code: 0, + }) + } } impl Command for PhpCommandProxy { @@ -1037,11 +1084,8 @@ impl BaseCommand for PhpCommandProxy { } impl shirabe_php_shim::PhpClass for PhpCommandProxy { - fn php_class_name(&self) -> &'static str { - // TODO(plugin): PhpClass reports only &'static str, but this command's PHP class name - // is runtime data (`self.handle.class`); callers needing get_class() must read the - // handle instead. - panic!("PhpCommandProxy has no static PHP class name; read the handle's class instead") + fn php_class_name(&self) -> String { + self.handle.class.clone() } } diff --git a/crates/shirabe/src/repository/vcs.rs b/crates/shirabe/src/repository/vcs.rs index 920fefa3..cb20735c 100644 --- a/crates/shirabe/src/repository/vcs.rs +++ b/crates/shirabe/src/repository/vcs.rs @@ -140,7 +140,7 @@ impl VcsDriverKind { impl PhpClass for VcsDriverKind { /// Used as the fallback driver name in `getRepoName()`. - fn php_class_name(&self) -> &'static str { + fn php_class_name(&self) -> String { match self { VcsDriverKind::GitHub => r"Composer\Repository\Vcs\GitHubDriver", VcsDriverKind::GitLab => r"Composer\Repository\Vcs\GitLabDriver", @@ -152,5 +152,6 @@ impl PhpClass for VcsDriverKind { VcsDriverKind::Fossil => r"Composer\Repository\Vcs\FossilDriver", VcsDriverKind::Svn => r"Composer\Repository\Vcs\SvnDriver", } + .to_string() } } diff --git a/crates/shirabe/src/repository/vcs_repository.rs b/crates/shirabe/src/repository/vcs_repository.rs index dd7b4b06..7ce00ce5 100644 --- a/crates/shirabe/src/repository/vcs_repository.rs +++ b/crates/shirabe/src/repository/vcs_repository.rs @@ -178,7 +178,7 @@ impl VcsRepository { .iter() .find(|(_, v)| **v == kind) .map(|(name, _)| name.clone()) - .unwrap_or_else(|| kind.php_class_name().to_string()), + .unwrap_or_else(|| kind.php_class_name()), None => String::new(), }; diff --git a/crates/shirabe/tests/plugin/plugin_installer_test.rs b/crates/shirabe/tests/plugin/plugin_installer_test.rs index ec5f4fe3..35088c8d 100644 --- a/crates/shirabe/tests/plugin/plugin_installer_test.rs +++ b/crates/shirabe/tests/plugin/plugin_installer_test.rs @@ -670,7 +670,7 @@ fn test_command_provider_capability() { let commands = provider.get_commands().unwrap(); assert_eq!(1, commands.len()); // PHP: assertInstanceOf('Composer\Command\BaseCommand', $commands[0]) is witnessed by the - // element type of Vec<Box<dyn BaseCommand>>. + // element type of Vec<Rc<RefCell<dyn BaseCommand>>>. } // A hand-written stub is used in place of PHPUnit's |
