From ffd3e239a56172d2a336fb4d6253c01f6b1a0100 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 22 Jun 2026 04:38:26 +0900 Subject: refactor(console): introduce ApplicationHandle shared-ownership newtype Collapse the dual Application API (static methods taking `&Rc>` plus `&mut self` bridges via `shared()`) into a single handle type `ApplicationHandle(Rc>)`, mirroring the Composer handle pattern. Methods that invoke command callbacks (add, run, do_run, base_run, base_do_run, do_run_command, add_commands, init) move to `impl ApplicationHandle` with short-scoped borrows; data-only methods and `impl BaseApplication` stay on `Application`. `shared()` now returns the handle, and `new_shared`/`init_shared` fold into `ApplicationHandle::new`/`init`. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/shirabe/src/console/application.rs | 4744 ++++++++++++++--------------- 1 file changed, 2341 insertions(+), 2403 deletions(-) (limited to 'crates/shirabe/src/console') diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index d0e82ae..5dd2eeb 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -132,10 +132,9 @@ pub struct Application { terminal: Terminal, default_command: String, single_command: bool, - initialized: bool, signal_registry: Option, signals_to_dispatch_event: Vec, - + // $initialized is omitted. See ApplicationHandle::init(). pub(crate) composer: Option, pub(crate) io: std::rc::Rc>, has_plugin_commands: bool, @@ -150,7 +149,13 @@ pub struct Application { } impl Application { - const LOGO: &'static str = " ______\n / ____/___ ____ ___ ____ ____ ________ _____\n / / / __ \\/ __ `__ \\/ __ \\/ __ \\/ ___/ _ \\/ ___/\n/ /___/ /_/ / / / / / / /_/ / /_/ (__ ) __/ /\n\\____/\\____/_/ /_/ /_/ .___/\\____/____/\\___/_/\n /_/\n"; + const LOGO: &'static str = r#" ______ + / ____/___ ____ ___ ____ ____ ________ _____ + / / / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/ +/ /___/ /_/ / / / / / / /_/ / /_/ (__ ) __/ / +\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/ + /_/ +"#; pub fn new(name: String, mut version: String) -> Self { static SHUTDOWN_REGISTERED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); @@ -205,7 +210,6 @@ impl Application { terminal: Terminal::new(), default_command: "list".to_string(), single_command: false, - initialized: false, signal_registry: None, signals_to_dispatch_event: Vec::new(), composer: None, @@ -228,1600 +232,1655 @@ impl Application { this } - /// Builds the application inside a shared `Rc>` and registers the default commands. - /// - /// Commands hold a back-pointer to their application, which shares this very `RefCell`. So the - /// whole run flow is driven through the `Rc` (never a long-lived `borrow_mut`), and command - /// registration happens here — before any borrow is taken — so `set_application` can borrow the - /// application without a re-entrant conflict. - pub fn new_shared( - name: String, - version: String, - ) -> anyhow::Result>> { - let application = - std::rc::Rc::new(std::cell::RefCell::new(Application::new(name, version))); - application.borrow_mut().me = std::rc::Rc::downgrade(&application); - Application::init_shared(&application)?; - Ok(application) - } - - /// Returns the shared handle to this application set up by `new_shared`. Proxy commands use it to - /// re-enter the run flow (PHP's `$this->getApplication()->run(...)`). - pub fn shared(&self) -> std::rc::Rc> { - self.me - .upgrade() - .expect("Application must be constructed through new_shared") + /// Returns the shared handle to this application set up by `ApplicationHandle::new`. Proxy + /// commands use it to re-enter the run flow (PHP's `$this->getApplication()->run(...)`). + pub fn shared(&self) -> ApplicationHandle { + ApplicationHandle( + self.me + .upgrade() + .expect("Application must be constructed through ApplicationHandle::new"), + ) } - /// Registers the default commands on a shared application (PHP's lazy `init()`), executed once - /// with no application borrow held so each `set_application` can borrow back safely. - fn init_shared( - application: &std::rc::Rc>, - ) -> anyhow::Result<()> { + fn get_new_working_dir( + &self, + input: std::rc::Rc>, + ) -> anyhow::Result> { + let working_dir = input + .borrow() + .get_parameter_option( + PhpMixed::from(vec!["--working-dir", "-d"]), + PhpMixed::Null, + true, + ) + .as_string() + .map(|s| s.to_string()); + if let Some(ref wd) = working_dir + && !is_dir(wd) { - let mut app = application.borrow_mut(); - if app.initialized { - return Ok(()); - } - app.initialized = true; - } - - let commands = application.borrow().get_default_commands(); - for command in commands { - Application::add_shared(application, command)?; - } - - Ok(()) - } - - /// Adds a command object to a shared application (PHP's `add()`), without holding an application - /// borrow while calling into the command, so `set_application` can borrow back safely. - pub fn add_shared( - application: &std::rc::Rc>, - command: std::rc::Rc>, - ) -> anyhow::Result>>> { - command.borrow_mut().set_application(Some( - application.clone() as std::rc::Rc> - )); - - if !command.borrow().is_enabled() { - command.borrow_mut().set_application(None); - - return Ok(None); - } - - // LazyCommand is intentionally not ported. - command.borrow().get_definition(); - - if command.borrow().get_name().is_none() { - return Err(ConsoleLogicException(shirabe_php_shim::LogicException { + return Err(RuntimeException { message: format!( - "The command defined in \"{}\" cannot have an empty name.", - PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command)), + "Invalid working directory specified, {} does not exist.", + wd ), code: 0, - }) + } .into()); } - let name = command.borrow().get_name().unwrap(); - application - .borrow_mut() - .commands - .insert(name, command.clone()); - - for alias in command.borrow().get_aliases() { - application - .borrow_mut() - .commands - .insert(alias, command.clone()); - } - - Ok(Some(command)) - } - - pub fn run( - application: &std::rc::Rc>, - input: Option>>, - output: Option>>, - ) -> anyhow::Result { - let output = match output { - Some(output) => Some(output), - None => Some( - std::rc::Rc::new(std::cell::RefCell::new(Factory::create_output())) - as std::rc::Rc>, - ), - }; - - Application::base_run(application, input, output) + Ok(working_dir) } - pub fn do_run( - application: &std::rc::Rc>, - input: std::rc::Rc>, + fn hint_common_errors( + &mut self, + exception: &anyhow::Error, output: std::rc::Rc>, - ) -> anyhow::Result { - application.borrow_mut().disable_plugins_by_default = input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--no-plugins"]), false); - application.borrow_mut().disable_scripts_by_default = input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--no-scripts"]), false); - - let stdin = shirabe_php_shim::STDIN; - if Platform::get_env("COMPOSER_TESTS_ARE_RUNNING").as_deref() != Some("1") - && (Platform::get_env("COMPOSER_NO_INTERACTION").is_some() - || !Platform::is_tty(Some(stdin))) + ) { + let is_logic_or_error = exception.downcast_ref::().is_some(); + if is_logic_or_error + && output.borrow().get_verbosity() < output_interface::VERBOSITY_VERBOSE { - input.borrow_mut().set_interactive(false); + output + .borrow_mut() + .set_verbosity(output_interface::VERBOSITY_VERBOSE); } - let mut helpers: IndexMap< - HelperSetKey, - std::rc::Rc>, - > = IndexMap::new(); - helpers.insert( - HelperSetKey::Int(0), - std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default())), - ); - let helper_set = std::rc::Rc::new(std::cell::RefCell::new(HelperSet::default())); - HelperSet::new(&helper_set, helpers); - application.borrow_mut().io = std::rc::Rc::new(std::cell::RefCell::new(ConsoleIO::new( - input.clone(), - output.clone(), - helper_set.borrow().clone(), - ))); - // Cache the IO so the rest of the flow does not re-borrow the application; command callbacks - // (e.g. mergeApplicationDefinition) need the application's RefCell free. - let io = application.borrow().io.clone(); + Silencer::suppress(None); + // Compute the disk-space hint message first; emit it via io afterwards to + // avoid overlapping borrows of self (get_composer needs &mut self). + let disk_hint_msg: Option = (|| -> anyhow::Result> { + let composer = self.get_composer(false, Some(true), None)?; + if let Some(composer) = composer && function_exists("disk_free_space") { + let composer = composer.borrow_partial(); + let config = composer.get_config(); - // Register error handler again to pass it the IO instance - ErrorHandler::register(Some(io.clone())); + let min_space_free: f64 = 100.0 * 1024.0 * 1024.0; + let mut dir = config + .borrow_mut() + .get("home") + .as_string() + .unwrap_or("") + .to_string(); + let df = disk_free_space(&dir); + let mut hit = df.map(|d| d < min_space_free).unwrap_or(false); + if !hit { + dir = config + .borrow_mut() + .get("vendor-dir") + .as_string() + .unwrap_or("") + .to_string(); + let df = disk_free_space(&dir); + hit = df.map(|d| d < min_space_free).unwrap_or(false); + } + if !hit { + dir = sys_get_temp_dir(); + let df = disk_free_space(&dir); + hit = df.map(|d| d < min_space_free).unwrap_or(false); + } + if hit { + return Ok(Some(format!("The disk hosting {} has less than 100MiB of free space, this may be the cause of the following exception", dir))); + } + } + Ok(None) + })() + .ok() + .flatten(); + Silencer::restore(); - if input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--no-cache"]), false) - { - io.write_error3("Disabling cache usage", true, io_interface::DEBUG); - Platform::put_env( - "COMPOSER_CACHE_DIR", - if Platform::is_windows() { - "nul" - } else { - "/dev/null" - }, - ); + let io = self.get_io(); + if let Some(msg) = &disk_hint_msg { + io.write_error3(msg, true, io_interface::QUIET); } - // switch working dir - let new_work_dir = application.borrow().get_new_working_dir(input.clone())?; - let mut old_working_dir: Option = None; - if let Some(ref nwd) = new_work_dir { - old_working_dir = Some(Platform::get_cwd(true).unwrap_or_default()); - chdir(nwd); - application.borrow_mut().initial_working_directory = getcwd(); - let cwd = Platform::get_cwd(true).unwrap_or_default(); + let message = exception.to_string(); + if exception.downcast_ref::().is_some() + && str_contains(&message, "Unable to use a proxy") + { io.write_error3( - &format!( - "Changed CWD to {}", - if !cwd.is_empty() { - cwd.clone() - } else { - nwd.clone() - } - ), + "The following exception indicates your proxy is misconfigured", true, - io_interface::DEBUG, + io_interface::QUIET, ); + io.write_error3("Check https://getcomposer.org/doc/faqs/how-to-use-composer-behind-a-proxy.md for details", true, io_interface::QUIET); } - // determine command name to be executed without including plugin commands - let mut command_name: Option = Some(String::new()); - let raw_command_name = application - .borrow_mut() - .get_command_name_before_binding(input.clone())?; - if let Some(ref raw) = raw_command_name { - let find_result = application.borrow_mut().find(raw); - match find_result { - Ok(cmd) => { - command_name = cmd.borrow().get_name(); - } - Err(e) => { - if e.downcast_ref::().is_some() { - // we'll check command validity again later after plugins are loaded - command_name = None; - } - // PHP also catches \InvalidArgumentException here without action - } + if Platform::is_windows() + && exception.downcast_ref::().is_some() + && str_contains(&message, "unable to get local issuer certificate") + { + let avast_detect = glob("C:\\Program Files\\Avast*"); + let avast_detect_pm = PhpMixed::List( + avast_detect + .iter() + .map(|s| PhpMixed::String(s.clone())) + .collect(), + ); + if is_array(&avast_detect_pm) && !avast_detect.is_empty() { + io.write_error3("The following exception indicates a possible issue with the Avast Firewall", true, io_interface::QUIET); + io.write_error3( + "Check https://getcomposer.org/local-issuer for details", + true, + io_interface::QUIET, + ); + } else { + io.write_error3("The following exception indicates a possible issue with a Firewall/Antivirus", true, io_interface::QUIET); + io.write_error3( + "Check https://getcomposer.org/local-issuer for details", + true, + io_interface::QUIET, + ); } } - // prompt user for dir change if no composer.json is present in current dir - let no_composer_json_commands = vec![ - "".to_string(), - "list".to_string(), - "init".to_string(), - "about".to_string(), - "help".to_string(), - "diagnose".to_string(), - "self-update".to_string(), - "global".to_string(), - "create-project".to_string(), - "outdated".to_string(), - ]; - let use_parent_dir_if_no_json_available = - application.borrow().get_use_parent_dir_config_value(); - let no_composer_json_commands_pm = PhpMixed::List( - no_composer_json_commands - .iter() - .map(|s| PhpMixed::String(s.clone())) - .collect(), - ); - if new_work_dir.is_none() - && !in_array( - command_name.as_deref().unwrap_or("").into(), - &no_composer_json_commands_pm, + if Platform::is_windows() + && strpos(&message, "The system cannot find the path specified").is_some() + { + io.write_error3("The following exception may be caused by a stale entry in your cmd.exe AutoRun", true, io_interface::QUIET); + io.write_error3("Check https://getcomposer.org/doc/articles/troubleshooting.md#-the-system-cannot-find-the-path-specified-windows- for details", true, io_interface::QUIET); + } + + if strpos(&message, "fork failed - Cannot allocate memory").is_some() { + io.write_error3("The following exception is caused by a lack of memory or swap, or not having swap configured", true, io_interface::QUIET); + io.write_error3("Check https://getcomposer.org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details", true, io_interface::QUIET); + } + + if exception + .downcast_ref::() + .is_some() + { + io.write_error3( + "The following exception is caused by a process timeout", true, - ) - && !file_exists(&Factory::get_composer_file().unwrap_or_default()) - && use_parent_dir_if_no_json_available.as_bool() != Some(false) - && (command_name.as_deref() != Some("config") - || (!input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--file"]), true) - && !input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["-f"]), true))) - && !input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--help"]), true) - && !input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["-h"]), true) + io_interface::QUIET, + ); + io.write_error3("Check https://getcomposer.org/doc/06-config.md#process-timeout for details", true, io_interface::QUIET); + } + + if self.get_disable_plugins_by_default() + && self.is_running_as_root() + && !self.io.is_interactive() { - let mut dir = dirname(&Platform::get_cwd(true).unwrap_or_default()); - let home_value = Platform::get_env("HOME") - .or_else(|| Platform::get_env("USERPROFILE")) - .unwrap_or_else(|| "/".to_string()); - let home = realpath(&home_value).unwrap_or_default(); + io.write_error3("Plugins have been disabled automatically as you are running as root, this may be the cause of the following exception. See also https://getcomposer.org/root", true, io_interface::QUIET); + } else if exception + .downcast_ref::() + .is_some() + && self.get_disable_plugins_by_default() + { + io.write_error3("Plugins have been disabled, which may be why some commands are missing, unless you made a typo", true, io_interface::QUIET); + } - // abort when we reach the home dir or top of the filesystem - while dirname(&dir) != dir && dir != home { - if file_exists(&format!( - "{}/{}", - dir, - Factory::get_composer_file().unwrap_or_default() - )) { - if use_parent_dir_if_no_json_available.as_bool() != Some(true) - && !io.is_interactive() - { - io.write_error(&format!("No composer.json in current directory, to use the one at {} run interactively or set config.use-parent-dir to true", dir)); - break; - } - if use_parent_dir_if_no_json_available.as_bool() == Some(true) - || io.ask_confirmation(format!("No composer.json in current directory, do you want to use the one at {}? [y,n]? ", dir), true) + let hints = HttpDownloader::get_exception_hints(exception).unwrap_or_default(); + if !hints.is_empty() { + for hint in &hints { + io.write_error3(hint, true, io_interface::QUIET); + } + } + } + + pub fn get_composer( + &mut self, + required: bool, + disable_plugins: Option, + disable_scripts: Option, + ) -> anyhow::Result> { + let disable_plugins = disable_plugins.unwrap_or(self.disable_plugins_by_default); + let disable_scripts = disable_scripts.unwrap_or(self.disable_scripts_by_default); + + if self.composer.is_none() { + let io_for_factory: std::rc::Rc> = + if Platform::is_input_completion_process() { + std::rc::Rc::new(std::cell::RefCell::new(NullIO::new())) + } else { + self.io.clone() + }; + let disable_plugins_enum = if disable_plugins { + crate::factory::DisablePlugins::All + } else { + crate::factory::DisablePlugins::None + }; + 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::().is_some() + || e.downcast_ref::().is_some() { - if use_parent_dir_if_no_json_available.as_bool() == Some(true) { - io.write_error(&format!("No composer.json in current directory, changing working directory to {}", dir)); - } else { - io.write_error("Always want to use the parent dir? Use \"composer config --global use-parent-dir true\" to change the default."); + 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 + // re-render the exception. Propagate it as an ExitException so the + // top-level handler turns it into exit code 1 without re-rendering. + return Err(shirabe_php_shim::ExitException { code: 1 }.into()); + } + return Err(e); } - old_working_dir = Some(Platform::get_cwd(true).unwrap_or_default()); - chdir(&dir); } - break; } - dir = dirname(&dir); } - drop((dir, home)); } - let needs_sudo_check = !Platform::is_windows() - && function_exists("exec") - && Platform::get_env("COMPOSER_ALLOW_SUPERUSER").is_none() - && !Platform::is_docker(); - let mut is_non_allowed_root = false; + Ok(self.composer.clone()) + } - // Clobber sudo credentials if COMPOSER_ALLOW_SUPERUSER is not set before loading plugins - if needs_sudo_check { - is_non_allowed_root = application.borrow().is_running_as_root(); + /// Removes the cached composer instance + pub fn reset_composer(&mut self) { + self.composer = None; + let io = self.get_io(); + if let Some(base_io) = io.borrow_mut().as_base_io_mut() { + base_io.reset_authentications(); + } + } - if is_non_allowed_root { - let uid: i64 = Platform::get_env("SUDO_UID") - .map(|v| v.parse().unwrap_or(0)) - .unwrap_or(0); - if uid != 0 { - // Silently clobber any sudo credentials on the invoking user to avoid privilege escalations later on - // ref. https://github.com/composer/composer/issues/5119 - let _ = Silencer::call(|| { - shirabe_php_shim::exec( - &format!("sudo -u \\#{} sudo -K > /dev/null 2>&1", uid), - None, - None, - ); - Ok(()) - }); + pub fn get_io(&self) -> std::rc::Rc> { + self.io.clone() + } + + pub fn get_help(&self) -> String { + format!("{}{}", Self::LOGO, self.base_get_help()) + } + + /// Initializes all the composer commands. + pub(crate) fn get_default_commands( + &self, + ) -> Vec>> { + let mut commands = self.base_get_default_commands(); + let composer_commands: Vec>> = vec![ + std::rc::Rc::new(std::cell::RefCell::new(AboutCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ConfigCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(DependsCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ProhibitsCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(InitCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(InstallCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(CreateProjectCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(UpdateCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(SearchCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ValidateCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(AuditCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ShowCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(SuggestsCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(RequireCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(DumpAutoloadCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(StatusCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ArchiveCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(DiagnoseCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(RunScriptCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(LicensesCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(GlobalCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ClearCacheCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(RemoveCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(HomeCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ExecCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(OutdatedCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(CheckPlatformReqsCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(FundCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(ReinstallCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(BumpCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(RepositoryCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new(SelfUpdateCommand::new())), + ]; + commands.extend(composer_commands); + commands + } + + /// This ensures we can find the correct command name even if a global input option is present before it + /// + /// e.g. "composer -d foo bar" should detect bar as the command name, and not foo + fn get_command_name_before_binding( + &mut self, + input: std::rc::Rc>, + ) -> anyhow::Result> { + let input = input.borrow().dup(); + // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. + match input.borrow_mut().bind(&self.get_definition().borrow()) { + Ok(()) => {} + Err(e) => { + // Errors must be ignored, full binding/validation happens later when the command is known. + if !is_exception_interface(&e) { + return Err(e); } } - - // Silently clobber any remaining sudo leases on the current user as well to avoid privilege escalations - let _ = Silencer::call(|| { - shirabe_php_shim::exec("sudo -K > /dev/null 2>&1", None, None); - Ok(()) - }); } - // avoid loading plugins/initializing the Composer instance earlier than necessary if no plugin command is needed - // if showing the version, we never need plugin commands - let mnp_list = PhpMixed::List(vec![ - PhpMixed::String("".to_string()), - PhpMixed::String("list".to_string()), - PhpMixed::String("help".to_string()), - ]); - let may_need_plugin_command = !input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), false) - && (command_name.is_none() - || in_array( - command_name.as_deref().unwrap_or("").into(), - &mnp_list, - true, - ) - || (command_name.as_deref() == Some("_complete") && !is_non_allowed_root)); - - let may_need_script_command = may_need_plugin_command - || command_name.as_deref() == Some("run-script") - || raw_command_name != command_name; + Ok(input.borrow().get_first_argument()) + } - if may_need_plugin_command - && !application.borrow().disable_plugins_by_default - && !application.borrow().has_plugin_commands + pub fn get_long_version(&self) -> String { + let mut branch_alias_string = String::new(); + if !composer::BRANCH_ALIAS_VERSION.is_empty() + && composer::BRANCH_ALIAS_VERSION != "@package_branch_alias_version@" { - // at this point plugins are needed, so if we are running as root and it is not allowed we need to prompt - // if interactive, and abort otherwise - if is_non_allowed_root { - io.write_error("Do not run Composer as root/super user! See https://getcomposer.org/root for details"); + branch_alias_string = format!(" ({})", composer::BRANCH_ALIAS_VERSION,); + } - if io.is_interactive() - && io.ask_confirmation( - "Continue as root/super user [yes]? " - .to_string(), - true, - ) - { - // avoid a second prompt later - is_non_allowed_root = false; - } else { - io.write_error("Aborting as no plugin should be loaded if running as super user is not explicitly allowed"); + format!( + "{} version {}{} {}", + self.get_name(), + self.get_version(), + branch_alias_string, + composer::RELEASE_DATE, + ) + } - return Ok(1); - } - } + pub(crate) fn get_default_input_definition(&self) -> anyhow::Result { + let mut definition = self.base_get_default_input_definition(); + definition.add_option(InputOption::new( + "--profile", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Display timing and memory usage information".to_string(), + PhpMixed::Null, + )?)?; + definition.add_option(InputOption::new( + "--no-plugins", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Whether to disable plugins.".to_string(), + PhpMixed::Null, + )?)?; + definition.add_option(InputOption::new( + "--no-scripts", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Skips the execution of all scripts defined in composer.json file.".to_string(), + PhpMixed::Null, + )?)?; + definition.add_option(InputOption::new( + "--working-dir", + PhpMixed::from("-d"), + Some(InputOption::VALUE_REQUIRED), + "If specified, use the given directory as working directory.".to_string(), + PhpMixed::Null, + )?)?; + definition.add_option(InputOption::new( + "--no-cache", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Prevent use of the cache".to_string(), + PhpMixed::Null, + )?)?; - // TODO(phase-b): the original PHP catches plugin discovery exceptions in a - // try/catch. The Rust port keeps the loop but skips IO error reporting - // because get_plugin_commands borrows &mut self, conflicting with io. - let mut plugin_warnings: Vec = Vec::new(); - match (|| -> anyhow::Result<()> { - let plugin_commands = application.borrow_mut().get_plugin_commands()?; - for command in plugin_commands { - let cmd_name = command.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(); - plugin_warnings.push(format!("Plugin command {} ({}) would override a Composer command and has been skipped", cmd_name, cls)); - } else { - // Compatibility layer for symfony/console <7.4 - // TODO(phase-c): registering a plugin command needs the Symfony - // Application's typed add()/addCommand(); the external-package stub keeps - // its registry as PhpMixed/todo!() per the "Symfony stays todo!()" policy. - let _ = command; - } - } - Ok(()) - })() { - Ok(_) => {} - Err(e) => { - if e.downcast_ref::().is_some() { - // suppress these as they are not relevant at this point - } else if let Some(pe) = e.downcast_ref::() { - let details = pe.get_details(); + Ok(definition) + } - let file = realpath(&Factory::get_composer_file().unwrap_or_default()); + fn get_plugin_commands(&mut self) -> anyhow::Result>> { + // TODO(plugin): plugin command discovery is part of the plugin API + let commands: Vec> = vec![]; - let line = details.line; + // TODO(phase-c): discovering plugin-provided commands walks the PluginManager and + // downcasts each plugin's CommandProvider capability — this is the Plugin API surface, + // which is intentionally unimplemented (see TODO(plugin) above). Returns an empty list + // until the plugin capability model exists. - let mut ghe = GithubActionError::new(io.clone()); - ghe.emit(&pe.message, file.as_deref(), line); + Ok(commands) + } - return Err(e); - } else { - return Err(e); - } - } - } - for warning in &plugin_warnings { - io.write_error(warning); - } + /// Get the working directory at startup time + pub fn get_initial_working_directory(&self) -> Option { + self.initial_working_directory.clone() + } - application.borrow_mut().has_plugin_commands = true; - } + pub fn get_disable_plugins_by_default(&self) -> bool { + self.disable_plugins_by_default + } - if !application.borrow().disable_plugins_by_default - && is_non_allowed_root - && !io.is_interactive() - { - io.write_error("Composer plugins have been disabled for safety in this non-interactive session."); - io.write_error("Set COMPOSER_ALLOW_SUPERUSER=1 if you want to allow plugins to run as root/super user."); - application.borrow_mut().disable_plugins_by_default = true; - } + pub fn get_disable_scripts_by_default(&self) -> bool { + self.disable_scripts_by_default + } - // determine command name to be executed incl plugin commands, and check if it's a proxy command - let mut is_proxy_command = false; - let resolved_command_name = application - .borrow_mut() - .get_command_name_before_binding(input.clone())?; - if let Some(ref name) = resolved_command_name { - let find_result = application.borrow_mut().find(name); - if let Ok(command) = find_result { - command_name = command.borrow().get_name(); - // PHP: $command instanceof Command\BaseCommand && $command->isProxyCommand(). - // The Symfony `Command` trait exposes `is_proxy_command` (default false) so the - // `dyn SymfonyCommand` registry can answer this; Composer proxy commands override it. - is_proxy_command = command.borrow().is_proxy_command(); - } - } + fn get_use_parent_dir_config_value(&self) -> PhpMixed { + let config = match Factory::create_config(Some(self.io.clone()), None) { + Ok(c) => c, + Err(_) => return PhpMixed::Bool(false), + }; - if !is_proxy_command { - io.write_error3( - &format!( - "Running {} ({}) with {} on {}", - composer::get_version(), - composer::RELEASE_DATE, - (if defined("HHVM_VERSION") { - format!("HHVM {}", shirabe_php_shim::HHVM_VERSION.unwrap_or("")) - } else { - format!("PHP {}", PHP_VERSION) - }), - (if function_exists("php_uname") { - format!("{} / {}", php_uname("s"), php_uname("r")) - } else { - "Unknown OS".to_string() - }), - ), - true, - io_interface::DEBUG, - ); + config.get("use-parent-dir").clone() + } - if PHP_VERSION_ID < 70205 { - io.write_error(&format!("Composer supports PHP 7.2.5 and above, you will most likely encounter problems with your PHP {}. Upgrading is strongly recommended but you can use Composer 2.2.x LTS as a fallback.", PHP_VERSION)); - } + fn is_running_as_root(&self) -> bool { + function_exists("posix_getuid") && posix_getuid() == 0 + } - if XdebugHandler::is_xdebug_active() - && Platform::get_env("COMPOSER_DISABLE_XDEBUG_WARN").is_none() - { - io.write_error("Composer is operating slower than normal because you have Xdebug enabled. See https://getcomposer.org/xdebug"); - } + pub fn set_command_loader(&mut self, command_loader: Box) { + self.command_loader = Some(command_loader); + } - if defined("COMPOSER_DEV_WARNING_TIME") - && command_name.as_deref() != Some("self-update") - && command_name.as_deref() != Some("selfupdate") - && time() > shirabe_php_shim::composer_dev_warning_time() - { - io.write_error(&format!( - "Warning: This development build of Composer is over 60 days old. It is recommended to update it by running \"{} self-update\" to get the latest version.", - shirabe_php_shim::server_get("PHP_SELF").unwrap_or_default(), - )); - } + pub fn get_signal_registry(&self) -> anyhow::Result<&SignalRegistry> { + match &self.signal_registry { + None => Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { + message: "Signals are not supported. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), + code: 0, + }) + .into()), + Some(signal_registry) => Ok(signal_registry), + } + } - if is_non_allowed_root - && command_name.as_deref() != Some("self-update") - && command_name.as_deref() != Some("selfupdate") - && command_name.as_deref() != Some("_complete") - { - io.write_error("Do not run Composer as root/super user! See https://getcomposer.org/root for details"); + pub fn set_signals_to_dispatch_event(&mut self, signals_to_dispatch_event: Vec) { + self.signals_to_dispatch_event = signals_to_dispatch_event; + } - if io.is_interactive() - && !io.ask_confirmation( - "Continue as root/super user [yes]? " - .to_string(), - true, - ) - { - return Ok(1); - } - } - - // Check system temp folder for usability as it can cause weird runtime issues otherwise - let tempfile_msg: Option = Silencer::call(|| -> anyhow::Result> { - let pid = if function_exists("getmypid") { - format!("{}-", getmypid()) - } else { - String::new() - }; - let tempfile = format!( - "{}/temp-{}{}", - sys_get_temp_dir(), - pid, - bin2hex(&random_bytes(5)) - ); - if !(file_put_contents(&tempfile, file!().as_bytes()).is_some_and(|n| n > 0) - && file_get_contents(&tempfile).as_deref() == Some(file!()) - && unlink(&tempfile) - && !file_exists(&tempfile)) - { - return Ok(Some(format!("PHP temp directory ({}) does not exist or is not writable to Composer. Set sys_temp_dir in your php.ini", sys_get_temp_dir()))); - } - Ok(None) - }) - .ok() - .flatten(); - if let Some(msg) = tempfile_msg { - io.write_error(&msg); - } - - // add non-standard scripts as own commands - let file = Factory::get_composer_file().unwrap_or_default(); - if may_need_script_command && is_file(&file) && Filesystem::is_readable(&file) { - let composer_json: PhpMixed = - json_decode(&file_get_contents(&file).unwrap_or_default(), true) - .unwrap_or(PhpMixed::Null); - if let Some(arr) = composer_json.as_array() - && let Some(scripts) = arr.get("scripts").and_then(|v| v.as_array()) - { - for (script, dummy) in scripts { - let script_event_const = format!( - "Composer\\Script\\ScriptEvents::{}", - str_replace("-", "_", &strtoupper(script)) - ); - if !defined(&script_event_const) { - if application.borrow_mut().has(script) { - io.write_error(&format!("A script named {} would override a Composer command and has been skipped", script)); - } else { - let mut description = format!( - "Runs the {} script as defined in composer.json", - script - ); + pub fn set_helper_set(&mut self, helper_set: std::rc::Rc>) { + self.helper_set = Some(helper_set); + } - if let Some(desc) = arr - .get("scripts-descriptions") - .and_then(|v| v.as_array()) - .and_then(|a| a.get(script)) - .and_then(|v| v.as_string()) - { - description = desc.to_string(); - } + /// Get the helper set associated with the command. + pub fn get_helper_set(&mut self) -> std::rc::Rc> { + if self.helper_set.is_none() { + self.helper_set = Some(self.get_default_helper_set()); + } - let aliases: Vec = arr - .get("scripts-aliases") - .and_then(|v| v.as_array()) - .and_then(|a| a.get(script)) - .and_then(|v| v.as_list()) - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); + self.helper_set.as_ref().unwrap().clone() + } - let composer_opt = - application.borrow_mut().get_composer(false, None, None)?; - if let Some(composer) = composer_opt { - let composer = crate::command::composer_full(&composer); - let root_package = composer.get_package(); - let generator = composer.get_autoload_generator().clone(); - let generator = generator.borrow(); + pub fn set_definition(&mut self, definition: std::rc::Rc>) { + self.definition = Some(definition); + } - let installation_manager = composer.get_installation_manager(); - let package_map = generator.build_package_map( - &mut installation_manager.borrow_mut(), - root_package.clone(), - vec![], - )?; - let map = generator.parse_autoloads( - package_map, - root_package.clone(), - PhpMixed::Bool(false), - ); + /// Gets the InputDefinition related to this Application. + pub fn get_definition(&mut self) -> std::rc::Rc> { + if self.definition.is_none() { + // `get_default_input_definition` is the Composer override (returns a Result because the + // Rust `InputOption::new` is fallible); the option modes are constants that cannot fail, + // so unwrapping mirrors the PHP call that never throws here. + self.definition = Some(std::rc::Rc::new(std::cell::RefCell::new( + self.get_default_input_definition().unwrap(), + ))); + } - let loader = generator.create_loader( - &map, - composer - .get_config() - .borrow() - .get("vendor-dir") - .as_string() - .map(|s| s.to_string()), - ); - loader.register(false); - } + if self.single_command { + let input_definition = self.definition.as_ref().unwrap().clone(); + input_definition + .borrow_mut() + .set_arguments(Vec::new()) + .unwrap(); - // if the command is not an array of commands, and points to a valid SymfonyCommand subclass, import its details directly - let dummy_str = dummy.as_string().unwrap_or("").to_string(); - let cmd: PhpMixed = if is_string(dummy) - && shirabe_php_shim::class_exists(&dummy_str) - && is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\Command\\Command", - true, - ) { - if is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\SingleCommandApplication", - true, - ) { - io.write_error(&format!("The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.", script)); - } - let mut cmd = shirabe_php_shim::instantiate_class( - &dummy_str, - vec![PhpMixed::String(script.clone())], - ); - // TODO(phase-c): the script's command class is built by - // reflection (instantiate_class) and stays PhpMixed; the - // SingleCommandApplication / SymfonyCommand typed registry it - // belongs to is an external-package todo!() stub. - // let _ = SingleCommandApplication::new; + return input_definition; + } - // makes sure the command is find()'able by the name defined in composer.json, and the name isn't overridden in its configure() - // TODO(phase-c): cmd is the PhpMixed result of reflection - // instantiation; reading/overriding its - // name/description requires the typed SymfonyCommand model that - // the Symfony stub does not yet provide. - let _ = description.clone(); - let _ = &mut cmd; - cmd - } else { - // fallback to usual aliasing behavior - // TODO(phase-c): ScriptAliasCommand is a typed BaseCommand - // but this code path stores commands as PhpMixed; it can - // only be carried as a typed trait object once the Symfony - // command registry is modelled. - let _ = ScriptAliasCommand::new( - script.clone(), - Some(description.clone()), - aliases, - ); - PhpMixed::Null - }; + self.definition.as_ref().unwrap().clone() + } - // Compatibility layer for symfony/console <7.4 - // TODO(phase-c): Application::add() takes Rc> - // but `cmd` here is the PhpMixed result of reflection-based - // plugin command instantiation; registering it as a typed - // command instance is blocked on the Symfony command-registry - // model (external-package todo!() stub). - let _ = &cmd; - todo!( - "plugin: register reflection-instantiated command on Application::add" - ); - } - } - } + /// Adds suggestions to `suggestions` for the current completion input (e.g. option or argument). + pub fn complete( + &mut self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + if CompletionInput::TYPE_ARGUMENT_VALUE == input.get_completion_type() + && input.get_completion_name().as_deref() == Some("command") + { + let mut command_names: Vec = Vec::new(); + for (name, command) in self.all(None)? { + // skip hidden commands and aliased commands as they already get added below + if command.borrow().is_hidden() || command.borrow().get_name() != Some(name.clone()) + { + continue; + } + command_names.push(PhpMixed::from( + command.borrow().get_name().unwrap_or_default(), + )); + for name in command.borrow().get_aliases() { + command_names.push(PhpMixed::from(name)); } } - } + // array_filter($commandNames) + let filtered: Vec = + command_names + .into_iter() + .filter(shirabe_php_shim::php_truthy) + .map(|n| { + shirabe_external_packages::symfony::console::completion::completion_suggestions::StringOrSuggestion::String( + shirabe_php_shim::php_to_string(&n), + ) + }) + .collect(); + suggestions.suggest_values(filtered); - let mut start_time: Option = None; - let result_outcome: anyhow::Result = (|| -> anyhow::Result { - if input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--profile"]), false) - { - start_time = Some(microtime()); - // PHP: $this->io->enableDebugging($startTime). - // TODO(phase-c): enableDebugging exists only on ConsoleIO, not on IOInterface, - // and self.io is still the NullIO because the ConsoleIO construction above is - // deferred (Symfony HelperSet/Helper modelling). Once self.io is the real - // ConsoleIO this becomes a concrete-type call on it. - let _ = start_time.unwrap(); - } + return Ok(()); + } - let result = Application::base_do_run(application, input.clone(), output.clone())?; + if CompletionInput::TYPE_OPTION_NAME == input.get_completion_type() { + // $suggestions->suggestOptions($this->getDefinition()->getOptions()); + // TODO(review): get_options() yields Rc (shared, non-Clone) while + // suggest_options() consumes owned InputOption values; an ownership/clone strategy + // for InputOption is needed. + suggestions.suggest_options(todo!("owned options from get_definition().get_options()")); - if input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), true) - { - io.write_error(&format!( - "PHP version {} ({})", - PHP_VERSION, PHP_BINARY, - )); - io.write_error( - "Run the \"diagnose\" command to get more detailed diagnostics output.", - ); - } - - // chdir back to oldWorkingDir if set - if let Some(ref owd) = old_working_dir - && !owd.is_empty() - { - let owd = owd.clone(); - let _ = Silencer::call(|| { - chdir(&owd); - Ok(()) - }); - } - - if let Some(st) = start_time { - io.write_error(&format!( - "Memory usage: {}MiB (peak: {}MiB), time: {}s", - round((memory_get_usage() as f64) / 1024.0 / 1024.0, 2), - round((memory_get_peak_usage(false) as f64) / 1024.0 / 1024.0, 2), - round(microtime() - st, 2) - )); - } + return Ok(()); + } - Ok(result) - })(); + Ok(()) + } - let outcome = match result_outcome { - Ok(r) => Ok(r), - Err(e) => { - // PHP's `exit` bypasses parent::doRun()'s catch entirely; re-raise it untouched so - // the GitHub Actions annotation and error hints below are skipped. - if e.downcast_ref::() - .is_some() - { - return Err(e); - } - if let Some(see) = e.downcast_ref::() { - if application.borrow().get_disable_plugins_by_default() - && application.borrow().is_running_as_root() - && !io.is_interactive() - { - io.write_error3("Plugins have been disabled automatically as you are running as root, this may be the cause of the script failure.", true, io_interface::QUIET); - io.write_error3( - "See also https://getcomposer.org/root", - true, - io_interface::QUIET, - ); - } + /// Gets the help message (Symfony base; `parent::getHelp`). + pub fn base_get_help(&self) -> String { + self.get_long_version() + } - Ok(see.get_code() as i32) - } else { - let mut ghe = GithubActionError::new(io.clone()); - ghe.emit(&e.to_string(), None, None); + /// Gets whether to catch exceptions or not during commands execution. + pub fn are_exceptions_caught(&self) -> bool { + self.catch_exceptions + } - application.borrow_mut().hint_common_errors(&e, output); + /// Sets whether to catch exceptions or not during commands execution. + pub fn set_catch_exceptions(&mut self, boolean: bool) { + self.catch_exceptions = boolean; + } - // override TransportException's code for the purpose of parent::run() using it as process exit code - // as http error codes are all beyond the 255 range of permitted exit codes - if e.downcast_ref::().is_some() { - // PHP: ReflectionProperty $reflProp = new \ReflectionProperty($e, 'code'); - // $reflProp->setValue($e, Installer::ERROR_TRANSPORT_EXCEPTION); - // TODO: reflection-based mutation of the existing exception is not portable; - // we surface the rewritten code via a fresh TransportException at the call site. - let _ = Installer::ERROR_TRANSPORT_EXCEPTION; - } + /// Gets the name of the application. + pub fn get_name(&self) -> String { + self.name.clone() + } - Err(e) - } - } - }; + /// Sets the application name. + pub fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } - restore_error_handler(); + /// Gets the application version. + pub fn get_version(&self) -> String { + self.version.clone() + } - outcome + /// Sets the application version. + pub fn set_version(&mut self, version: &str) { + self.version = version.to_string(); } - fn get_new_working_dir( - &self, - input: std::rc::Rc>, - ) -> anyhow::Result> { - let working_dir = input - .borrow() - .get_parameter_option( - PhpMixed::from(vec!["--working-dir", "-d"]), - PhpMixed::Null, - true, + /// Returns a registered command by name or alias. + /// + /// Throws CommandNotFoundException when given command name does not exist. + pub fn get( + &mut self, + name: &str, + ) -> anyhow::Result>> { + if !self.has(name) { + return Err(CommandNotFoundException::new( + format!( + "The command \"{}\" does not exist.", + PhpMixed::from(name.to_string()), + ), + Vec::new(), + 0, ) - .as_string() - .map(|s| s.to_string()); - if let Some(ref wd) = working_dir - && !is_dir(wd) - { - return Err(RuntimeException { - message: format!( - "Invalid working directory specified, {} does not exist.", - wd + .into()); + } + + // When the command has a different name than the one used at the command loader level + if !self.commands.contains_key(name) { + return Err(CommandNotFoundException::new( + format!( + "The \"{}\" command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or \"setName()\".", + PhpMixed::from(name.to_string()), ), - code: 0, - } + Vec::new(), + 0, + ) .into()); } - Ok(working_dir) - } + let command = self.commands[name].clone(); - fn hint_common_errors( - &mut self, - exception: &anyhow::Error, - output: std::rc::Rc>, - ) { - let is_logic_or_error = exception.downcast_ref::().is_some(); - if is_logic_or_error - && output.borrow().get_verbosity() < output_interface::VERBOSITY_VERBOSE - { - output + if self.want_helps { + self.want_helps = false; + + let help_command = self.get("help")?; + help_command .borrow_mut() - .set_verbosity(output_interface::VERBOSITY_VERBOSE); - } + .as_any_mut() + .downcast_mut::() + .expect("the help command is a HelpCommand instance") + .set_command(command); - Silencer::suppress(None); - // Compute the disk-space hint message first; emit it via io afterwards to - // avoid overlapping borrows of self (get_composer needs &mut self). - let disk_hint_msg: Option = (|| -> anyhow::Result> { - let composer = self.get_composer(false, Some(true), None)?; - if let Some(composer) = composer && function_exists("disk_free_space") { - let composer = composer.borrow_partial(); - let config = composer.get_config(); + return Ok(help_command); + } - let min_space_free: f64 = 100.0 * 1024.0 * 1024.0; - let mut dir = config - .borrow_mut() - .get("home") - .as_string() - .unwrap_or("") - .to_string(); - let df = disk_free_space(&dir); - let mut hit = df.map(|d| d < min_space_free).unwrap_or(false); - if !hit { - dir = config - .borrow_mut() - .get("vendor-dir") - .as_string() - .unwrap_or("") - .to_string(); - let df = disk_free_space(&dir); - hit = df.map(|d| d < min_space_free).unwrap_or(false); - } - if !hit { - dir = sys_get_temp_dir(); - let df = disk_free_space(&dir); - hit = df.map(|d| d < min_space_free).unwrap_or(false); - } - if hit { - return Ok(Some(format!("The disk hosting {} has less than 100MiB of free space, this may be the cause of the following exception", dir))); - } - } - Ok(None) - })() - .ok() - .flatten(); - Silencer::restore(); + Ok(command) + } - let io = self.get_io(); - if let Some(msg) = &disk_hint_msg { - io.write_error3(msg, true, io_interface::QUIET); + /// Returns true if the command exists, false otherwise. + pub fn has(&mut self, name: &str) -> bool { + if self.commands.contains_key(name) { + return true; } - let message = exception.to_string(); - if exception.downcast_ref::().is_some() - && str_contains(&message, "Unable to use a proxy") + if let Some(command_loader) = &self.command_loader + && command_loader.has(name) { - io.write_error3( - "The following exception indicates your proxy is misconfigured", - true, - io_interface::QUIET, - ); - io.write_error3("Check https://getcomposer.org/doc/faqs/how-to-use-composer-behind-a-proxy.md for details", true, io_interface::QUIET); + let command = command_loader.get(name); + // $this->add($this->commandLoader->get($name)) + // TODO(review): command_loader.get() returns Box while add() expects + // Rc>; the loader return type needs reconciliation. + let _ = command; + return self + .shared() + .add(todo!( + "Rc> from command_loader.get(name)" + )) + .map(|c| c.is_some()) + .unwrap_or(false); } - if Platform::is_windows() - && exception.downcast_ref::().is_some() - && str_contains(&message, "unable to get local issuer certificate") - { - let avast_detect = glob("C:\\Program Files\\Avast*"); - let avast_detect_pm = PhpMixed::List( - avast_detect - .iter() - .map(|s| PhpMixed::String(s.clone())) - .collect(), + false + } + + /// Returns an array of all unique namespaces used by currently registered commands. + /// + /// It does not return the global namespace which always exists. + pub fn get_namespaces(&mut self) -> anyhow::Result> { + let mut namespaces: Vec> = Vec::new(); + for command in self.all(None)?.values() { + if command.borrow().is_hidden() { + continue; + } + + namespaces.push( + self.extract_all_namespaces(&command.borrow().get_name().unwrap_or_default()), ); - if is_array(&avast_detect_pm) && !avast_detect.is_empty() { - io.write_error3("The following exception indicates a possible issue with the Avast Firewall", true, io_interface::QUIET); - io.write_error3( - "Check https://getcomposer.org/local-issuer for details", - true, - io_interface::QUIET, - ); - } else { - io.write_error3("The following exception indicates a possible issue with a Firewall/Antivirus", true, io_interface::QUIET); - io.write_error3( - "Check https://getcomposer.org/local-issuer for details", - true, - io_interface::QUIET, - ); + + for alias in command.borrow().get_aliases() { + namespaces.push(self.extract_all_namespaces(&alias)); } } - if Platform::is_windows() - && strpos(&message, "The system cannot find the path specified").is_some() - { - io.write_error3("The following exception may be caused by a stale entry in your cmd.exe AutoRun", true, io_interface::QUIET); - io.write_error3("Check https://getcomposer.org/doc/articles/troubleshooting.md#-the-system-cannot-find-the-path-specified-windows- for details", true, io_interface::QUIET); + // array_values(array_unique(array_filter(array_merge([], ...$namespaces)))) + let mut merged: Vec = Vec::new(); + for ns in namespaces { + merged.extend(ns); } + let merged: Vec = merged.into_iter().filter(|s| !s.is_empty()).collect(); + let mut seen = std::collections::HashSet::new(); + let unique: Vec = merged + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect(); - if strpos(&message, "fork failed - Cannot allocate memory").is_some() { - io.write_error3("The following exception is caused by a lack of memory or swap, or not having swap configured", true, io_interface::QUIET); - io.write_error3("Check https://getcomposer.org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details", true, io_interface::QUIET); - } + Ok(unique) + } - if exception - .downcast_ref::() - .is_some() - { - io.write_error3( - "The following exception is caused by a process timeout", - true, - io_interface::QUIET, + /// Finds a registered namespace by a name or an abbreviation. + /// + /// Throws NamespaceNotFoundException when namespace is incorrect or ambiguous. + pub fn find_namespace(&mut self, namespace: &str) -> anyhow::Result { + let all_namespaces = self.get_namespaces()?; + // implode('[^:]*:', array_map('preg_quote', explode(':', $namespace))).'[^:]*' + let parts: Vec = shirabe_php_shim::explode(":", namespace) + .into_iter() + .map(|p| shirabe_php_shim::preg_quote(&p, None)) + .collect(); + let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); + let namespaces = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_namespaces); + + if namespaces.is_empty() { + let mut message = format!( + "There are no commands defined in the \"{}\" namespace.", + PhpMixed::from(namespace.to_string()), ); - io.write_error3("Check https://getcomposer.org/doc/06-config.md#process-timeout for details", true, io_interface::QUIET); + + let alternatives = self.find_alternatives(namespace, &all_namespaces); + if !alternatives.is_empty() { + if alternatives.len() == 1 { + message.push_str("\n\nDid you mean this?\n "); + } else { + message.push_str("\n\nDid you mean one of these?\n "); + } + + message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); + } + + return Err(NamespaceNotFoundException(CommandNotFoundException::new( + message, + alternatives, + 0, + )) + .into()); } - if self.get_disable_plugins_by_default() - && self.is_running_as_root() - && !self.io.is_interactive() - { - io.write_error3("Plugins have been disabled automatically as you are running as root, this may be the cause of the following exception. See also https://getcomposer.org/root", true, io_interface::QUIET); - } else if exception - .downcast_ref::() - .is_some() - && self.get_disable_plugins_by_default() - { - io.write_error3("Plugins have been disabled, which may be why some commands are missing, unless you made a typo", true, io_interface::QUIET); + let exact = namespaces.iter().any(|n| n == namespace); + if namespaces.len() > 1 && !exact { + return Err(NamespaceNotFoundException(CommandNotFoundException::new( + format!( + "The namespace \"{}\" is ambiguous.\nDid you mean one of these?\n{}.", + PhpMixed::from(namespace.to_string()), + PhpMixed::from(self.get_abbreviation_suggestions(&namespaces)), + ), + namespaces.clone(), + 0, + )) + .into()); } - let hints = HttpDownloader::get_exception_hints(exception).unwrap_or_default(); - if !hints.is_empty() { - for hint in &hints { - io.write_error3(hint, true, io_interface::QUIET); - } + // $exact ? $namespace : reset($namespaces) + if exact { + Ok(namespace.to_string()) + } else { + Ok(namespaces[0].clone()) } } - pub fn get_composer( + /// Finds a command by name or alias. + /// + /// Contrary to get, this command tries to find the best match if you give it an + /// abbreviation of a name or alias. + /// + /// Throws CommandNotFoundException when command name is incorrect or ambiguous. + pub fn find( &mut self, - required: bool, - disable_plugins: Option, - disable_scripts: Option, - ) -> anyhow::Result> { - let disable_plugins = disable_plugins.unwrap_or(self.disable_plugins_by_default); - let disable_scripts = disable_scripts.unwrap_or(self.disable_scripts_by_default); + name: &str, + ) -> anyhow::Result>> { + let mut aliases: IndexMap = IndexMap::new(); - if self.composer.is_none() { - let io_for_factory: std::rc::Rc> = - if Platform::is_input_completion_process() { - std::rc::Rc::new(std::cell::RefCell::new(NullIO::new())) - } else { - self.io.clone() - }; - let disable_plugins_enum = if disable_plugins { - crate::factory::DisablePlugins::All - } else { - crate::factory::DisablePlugins::None + let commands_snapshot: Vec>> = + self.commands.values().cloned().collect(); + for command in &commands_snapshot { + // A command's run() can re-enter find() (e.g. HelpCommand looks up the command it + // describes). That command is mutably borrowed for the duration of its run(), so it + // cannot be borrowed here. It is safe to skip: find() always completes this alias pass + // before returning a command, so any command currently executing already had its + // aliases registered in the earlier find() call that located it. + // TODO: this work-around could be solved. + let Ok(borrowed) = command.try_borrow() else { + continue; }; - 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::().is_some() - || e.downcast_ref::().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 - // re-render the exception. Propagate it as an ExitException so the - // top-level handler turns it into exit code 1 without re-rendering. - return Err(shirabe_php_shim::ExitException { code: 1 }.into()); - } - return Err(e); - } - } + let aliases = borrowed.get_aliases(); + drop(borrowed); + for alias in aliases { + if !self.has(&alias) { + self.commands.insert(alias, command.clone()); } } } - Ok(self.composer.clone()) - } + if self.has(name) { + return self.get(name); + } - /// Removes the cached composer instance - pub fn reset_composer(&mut self) { - self.composer = None; - let io = self.get_io(); - if let Some(base_io) = io.borrow_mut().as_base_io_mut() { - base_io.reset_authentications(); + // $allCommands = commandLoader ? array_merge(loader->getNames(), array_keys(commands)) : array_keys(commands) + let all_commands: Vec = match &self.command_loader { + Some(command_loader) => { + let mut all = command_loader.get_names(); + all.extend(self.commands.keys().cloned()); + all + } + None => self.commands.keys().cloned().collect(), + }; + + let parts: Vec = shirabe_php_shim::explode(":", name) + .into_iter() + .map(|p| shirabe_php_shim::preg_quote(&p, None)) + .collect(); + let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); + let mut commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_commands); + + if commands.is_empty() { + commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}i", expr), &all_commands); } - } - pub fn get_io(&self) -> std::rc::Rc> { - self.io.clone() - } + // if no commands matched or we just matched namespaces + if commands.is_empty() + || shirabe_php_shim::preg_grep(&format!("{{^{}$}}i", expr), &commands).is_empty() + { + if let Some(pos) = shirabe_php_shim::strrpos(name, ":") { + // check if a namespace exists and contains commands + self.find_namespace(&name[..pos])?; + } - pub fn get_help(&self) -> String { - format!("{}{}", Self::LOGO, self.base_get_help()) - } + let mut message = format!( + "SymfonyCommand \"{}\" is not defined.", + PhpMixed::from(name.to_string()), + ); - /// Initializes all the composer commands. - pub(crate) fn get_default_commands( - &self, - ) -> Vec>> { - let mut commands = self.base_get_default_commands(); - let composer_commands: Vec>> = vec![ - std::rc::Rc::new(std::cell::RefCell::new(AboutCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ConfigCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(DependsCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ProhibitsCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(InitCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(InstallCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(CreateProjectCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(UpdateCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(SearchCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ValidateCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(AuditCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ShowCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(SuggestsCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(RequireCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(DumpAutoloadCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(StatusCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ArchiveCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(DiagnoseCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(RunScriptCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(LicensesCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(GlobalCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ClearCacheCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(RemoveCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(HomeCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ExecCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(OutdatedCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(CheckPlatformReqsCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(FundCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(ReinstallCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(BumpCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(RepositoryCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new(SelfUpdateCommand::new())), - ]; - commands.extend(composer_commands); - commands - } + let mut alternatives = self.find_alternatives(name, &all_commands); + if !alternatives.is_empty() { + // remove hidden commands + let mut filtered: Vec = Vec::new(); + for alt in alternatives { + if !self.get(&alt)?.borrow().is_hidden() { + filtered.push(alt); + } + } + alternatives = filtered; - /// This ensures we can find the correct command name even if a global input option is present before it - /// - /// e.g. "composer -d foo bar" should detect bar as the command name, and not foo - fn get_command_name_before_binding( - &mut self, - input: std::rc::Rc>, - ) -> anyhow::Result> { - let input = input.borrow().dup(); - // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. - match input.borrow_mut().bind(&self.get_definition().borrow()) { - Ok(()) => {} - Err(e) => { - // Errors must be ignored, full binding/validation happens later when the command is known. - if !is_exception_interface(&e) { - return Err(e); + if alternatives.len() == 1 { + message.push_str("\n\nDid you mean this?\n "); + } else { + message.push_str("\n\nDid you mean one of these?\n "); } + message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); } + + return Err(CommandNotFoundException::new(message, alternatives, 0).into()); } - Ok(input.borrow().get_first_argument()) - } + // filter out aliases for commands which are already on the list + if commands.len() > 1 { + // $commandList = commandLoader ? array_merge(array_flip(loader->getNames()), commands) : commands + // TODO(review): $commandList mixes flipped loader names (string => int) with + // SymfonyCommand + // instances; this heterogeneous PHP array needs a typed representation. The alias + // de-duplication and the loader->get() lazy materialization are left to design. + let mut command_list: IndexMap< + String, + std::rc::Rc>, + > = self.commands.clone(); - pub fn get_long_version(&self) -> String { - let mut branch_alias_string = String::new(); - if !composer::BRANCH_ALIAS_VERSION.is_empty() - && composer::BRANCH_ALIAS_VERSION != "@package_branch_alias_version@" - { - branch_alias_string = format!(" ({})", composer::BRANCH_ALIAS_VERSION,); - } + let commands_clone = commands.clone(); + let mut new_commands: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for name_or_alias in commands { + if !command_list.contains_key(&name_or_alias) { + let loaded = self.command_loader.as_ref().unwrap().get(&name_or_alias); + let _ = loaded; + command_list.insert( + name_or_alias.clone(), + todo!( + "Rc> from command_loader.get(name_or_alias)" + ), + ); + } - format!( - "{} version {}{} {}", - self.get_name(), - self.get_version(), - branch_alias_string, - composer::RELEASE_DATE, - ) - } + let command_name = command_list[&name_or_alias] + .borrow() + .get_name() + .unwrap_or_default(); - pub(crate) fn get_default_input_definition(&self) -> anyhow::Result { - let mut definition = self.base_get_default_input_definition(); - definition.add_option(InputOption::new( - "--profile", - PhpMixed::Null, - Some(InputOption::VALUE_NONE), - "Display timing and memory usage information".to_string(), - PhpMixed::Null, - )?)?; - definition.add_option(InputOption::new( - "--no-plugins", - PhpMixed::Null, - Some(InputOption::VALUE_NONE), - "Whether to disable plugins.".to_string(), - PhpMixed::Null, - )?)?; - definition.add_option(InputOption::new( - "--no-scripts", - PhpMixed::Null, - Some(InputOption::VALUE_NONE), - "Skips the execution of all scripts defined in composer.json file.".to_string(), - PhpMixed::Null, - )?)?; - definition.add_option(InputOption::new( - "--working-dir", - PhpMixed::from("-d"), - Some(InputOption::VALUE_REQUIRED), - "If specified, use the given directory as working directory.".to_string(), - PhpMixed::Null, - )?)?; - definition.add_option(InputOption::new( - "--no-cache", - PhpMixed::Null, - Some(InputOption::VALUE_NONE), - "Prevent use of the cache".to_string(), - PhpMixed::Null, - )?)?; + aliases.insert(name_or_alias.clone(), command_name.clone()); - Ok(definition) - } + let keep = command_name == name_or_alias || !commands_clone.contains(&command_name); + if keep && seen.insert(name_or_alias.clone()) { + new_commands.push(name_or_alias); + } + } + commands = new_commands; - fn get_plugin_commands(&mut self) -> anyhow::Result>> { - // TODO(plugin): plugin command discovery is part of the plugin API - let commands: Vec> = vec![]; + if commands.len() > 1 { + let usable_width = self.terminal.get_width() - 10; + let abbrevs: Vec = commands.clone(); + let mut max_len: i64 = 0; + for abbrev in &abbrevs { + max_len = std::cmp::max(Helper::width(abbrev), max_len); + } + let mut formatted_abbrevs: Vec = Vec::new(); + for cmd in commands.clone() { + if command_list[&cmd].borrow().is_hidden() { + // unset($commands[array_search($cmd, $commands)]) + if let Some(idx) = commands.iter().position(|c| *c == cmd) { + commands.remove(idx); + } + formatted_abbrevs.push(PhpMixed::Bool(false)); + continue; + } - // TODO(phase-c): discovering plugin-provided commands walks the PluginManager and - // downcasts each plugin's CommandProvider capability — this is the Plugin API surface, - // which is intentionally unimplemented (see TODO(plugin) above). Returns an empty list - // until the plugin capability model exists. + let abbrev = format!( + "{} {}", + shirabe_php_shim::str_pad( + &cmd, + max_len as usize, + " ", + shirabe_php_shim::STR_PAD_RIGHT + ), + command_list[&cmd].borrow().get_description() + ); - Ok(commands) - } + if Helper::width(&abbrev) > usable_width { + formatted_abbrevs.push(PhpMixed::from(format!( + "{}...", + Helper::substr(&abbrev, 0, Some(usable_width - 3)) + ))); + } else { + formatted_abbrevs.push(PhpMixed::from(abbrev)); + } + } - /// Get the working directory at startup time - pub fn get_initial_working_directory(&self) -> Option { - self.initial_working_directory.clone() - } + if commands.len() > 1 { + let filtered: Vec = formatted_abbrevs + .iter() + .filter(|a| shirabe_php_shim::php_truthy(a)) + .map(shirabe_php_shim::php_to_string) + .collect(); + let suggestions = self.get_abbreviation_suggestions(&filtered); - pub fn get_disable_plugins_by_default(&self) -> bool { - self.disable_plugins_by_default - } + return Err(CommandNotFoundException::new( + format!( + "SymfonyCommand \"{}\" is ambiguous.\nDid you mean one of these?\n{}.", + PhpMixed::from(name.to_string()), + PhpMixed::from(suggestions), + ), + commands.clone(), + 0, + ) + .into()); + } + } + } - pub fn get_disable_scripts_by_default(&self) -> bool { - self.disable_scripts_by_default - } + // $command = $this->get(reset($commands)); + let command = self.get(&commands[0])?; - fn get_use_parent_dir_config_value(&self) -> PhpMixed { - let config = match Factory::create_config(Some(self.io.clone()), None) { - Ok(c) => c, - Err(_) => return PhpMixed::Bool(false), - }; + if command.borrow().is_hidden() { + return Err(CommandNotFoundException::new( + format!( + "The command \"{}\" does not exist.", + PhpMixed::from(name.to_string()), + ), + Vec::new(), + 0, + ) + .into()); + } - config.get("use-parent-dir").clone() + Ok(command) } - fn is_running_as_root(&self) -> bool { - function_exists("posix_getuid") && posix_getuid() == 0 - } -} + /// Gets the commands (registered in the given namespace if provided). + /// + /// The array keys are the full names and the values the command instances. + pub fn all( + &mut self, + namespace: Option<&str>, + ) -> anyhow::Result>>> { + if namespace.is_none() { + if self.command_loader.is_none() { + return Ok(self.commands.clone()); + } -/// Methods inherited from `Symfony\Component\Console\Application`. They live in the same `impl` -/// surface as the Composer overrides above so that polymorphic `self.*` calls dispatch to the -/// Composer version when one exists. Methods that Composer overrides while still calling `parent::` -/// are carried here under a `base_` prefix (`base_run`, `base_do_run`, `base_get_help`, -/// `base_get_default_input_definition`, `base_get_default_commands`). -impl Application { - pub fn set_command_loader(&mut self, command_loader: Box) { - self.command_loader = Some(command_loader); - } + let mut commands = self.commands.clone(); + let names = self.command_loader.as_ref().unwrap().get_names(); + for name in names { + if !commands.contains_key(&name) && self.has(&name) { + commands.insert(name.clone(), self.get(&name)?); + } + } - pub fn get_signal_registry(&self) -> anyhow::Result<&SignalRegistry> { - match &self.signal_registry { - None => Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { - message: "Signals are not supported. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), - code: 0, - }) - .into()), - Some(signal_registry) => Ok(signal_registry), + return Ok(commands); } - } - - pub fn set_signals_to_dispatch_event(&mut self, signals_to_dispatch_event: Vec) { - self.signals_to_dispatch_event = signals_to_dispatch_event; - } - /// Runs the current application (Symfony base; `parent::run`). - pub fn base_run( - application: &std::rc::Rc>, - input: Option>>, - output: Option>>, - ) -> anyhow::Result { - if shirabe_php_shim::function_exists("putenv") { - let (height, width) = { - let app = application.borrow(); - (app.terminal.get_height(), app.terminal.get_width()) - }; - shirabe_php_shim::putenv(&format!("LINES={}", height)); - shirabe_php_shim::putenv(&format!("COLUMNS={}", width)); + let namespace = namespace.unwrap(); + let mut commands: IndexMap>> = + IndexMap::new(); + let entries: Vec<(String, std::rc::Rc>)> = self + .commands + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (name, command) in entries { + if namespace + == self.extract_namespace( + &name, + Some(shirabe_php_shim::substr_count(namespace, ":") + 1), + ) + { + commands.insert(name, command); + } } - let input: std::rc::Rc> = match input { - None => std::rc::Rc::new(std::cell::RefCell::new(ArgvInput::new(None, None)?)), - Some(input) => input, - }; - - let output: std::rc::Rc> = match output { - None => std::rc::Rc::new(std::cell::RefCell::new(ConsoleOutput::new( - None, None, None, - )?)), - Some(output) => output, - }; - - // TODO: PHP installs a temporary `set_exception_handler($renderException)` and cooperates - // with Symfony's ErrorHandler to keep/restore it. PHP's process-global exception handler - // stack has no Rust equivalent; the rendering itself is invoked directly in the catch - // branch below. Review needed for the handler save/restore dance. - let render_exception = - |this: &Application, - e: &anyhow::Error, - output: &std::rc::Rc>| { - // if ($output instanceof ConsoleOutputInterface) render to its error output - // TODO(review): downcasting a `dyn OutputInterface` to `ConsoleOutputInterface` - // is not directly expressible; the ConsoleOutputInterface branch needs design. - this.render_throwable(e, output.clone()); - }; - - let result = (|| -> anyhow::Result { - application.borrow_mut().configure_io(&input, &output)?; - - let exit_code = Application::do_run(application, input.clone(), output.clone())?; - - Ok(exit_code) - })(); - - let exit_code = match result { - Ok(exit_code) => exit_code, - Err(e) => { - // PHP's `exit` bypasses Symfony's try/catch: it never renders and forces the exit - // code it carries. The message, if any, has already been written at the exit site. - if let Some(exit) = e.downcast_ref::() { - return Ok(exit.code as i32); - } - - if !application.borrow().catch_exceptions { - return Err(e); + if self.command_loader.is_some() { + let names = self.command_loader.as_ref().unwrap().get_names(); + for name in names { + if !commands.contains_key(&name) + && namespace + == self.extract_namespace( + &name, + Some(shirabe_php_shim::substr_count(namespace, ":") + 1), + ) + && self.has(&name) + { + commands.insert(name.clone(), self.get(&name)?); } + } + } - render_exception(&application.borrow(), &e, &output); + Ok(commands) + } - // $exitCode = $e->getCode(); - // is_numeric($exitCode) ? max(1, (int) $exitCode) : 1 - // TODO(review): anyhow::Error has no PHP-style getCode(); the exit code derived - // from the exception's `code` field needs the downcast strategy decided. - let exit_code = shirabe_php_shim::php_exception_get_code(&e); - if shirabe_php_shim::is_numeric_string(&exit_code.to_string()) { - if exit_code <= 0 { 1 } else { exit_code } - } else { - 1 - } + /// Returns an array of possible abbreviations given a set of names. + pub fn get_abbreviations(names: Vec) -> IndexMap> { + let mut abbrevs: IndexMap> = IndexMap::new(); + for name in names { + let mut len = shirabe_php_shim::strlen(&name); + while len > 0 { + let abbrev = shirabe_php_shim::substr(&name, 0, Some(len)); + abbrevs.entry(abbrev).or_default().push(name.clone()); + len -= 1; } - }; - - // finally: handler restore. See TODO above; no-op here. + } - Ok(exit_code) + abbrevs } - /// Runs the current application (Symfony base; `parent::doRun`). - pub fn base_do_run( - application: &std::rc::Rc>, - input: std::rc::Rc>, + pub fn render_throwable( + &self, + e: &anyhow::Error, output: std::rc::Rc>, - ) -> anyhow::Result { - if input.borrow().has_parameter_option( - PhpMixed::from(vec![ - PhpMixed::from("--version".to_string()), - PhpMixed::from("-V".to_string()), - ]), - true, - ) { - let long_version = application.borrow().get_long_version(); + ) { + output + .borrow() + .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); + + self.do_render_throwable(e, output.clone()); + + if let Some(running_command) = &self.running_command { + // PHP: sprintf('%s', OutputFormatter::escape(sprintf($synopsis, $this->getName()))) + // A command synopsis carries no printf conversion specifier, so the inner sprintf is an + // identity over the synopsis and the application-name argument is never substituted. + let synopsis = running_command.borrow_mut().get_synopsis(false); + output.borrow().writeln( + &[format!( + "{}", + OutputFormatter::escape(&synopsis).unwrap(), + )], + output_interface::VERBOSITY_QUIET, + ); output .borrow() - .writeln(&[long_version], output_interface::OUTPUT_NORMAL); - - return Ok(0); + .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); } + } - // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. - let definition = application.borrow_mut().get_definition(); - match input.borrow_mut().bind(&definition.borrow()) { - Ok(()) => {} - Err(e) => { - // Errors must be ignored, full binding/validation happens later when the command is known. - if !is_exception_interface(&e) { - return Err(e); - } - } - } + pub fn do_render_throwable( + &self, + e: &anyhow::Error, + output: std::rc::Rc>, + ) { + // do { ... } while ($e = $e->getPrevious()); + // PHP walks getPrevious(); anyhow models the exception chain as the source chain, which + // `anyhow::Error::chain()` yields head-first. + for e in e.chain() { + let message = shirabe_php_shim::trim(&e.to_string(), None); + let verbosity = output.borrow().get_verbosity(); - let mut input = input; - let mut name = application.borrow().get_command_name(&*input.borrow()); - if input.borrow().has_parameter_option( - PhpMixed::from(vec![ - PhpMixed::from("--help".to_string()), - PhpMixed::from("-h".to_string()), - ]), - true, - ) { - if name.is_none() { - name = Some("help".to_string()); - let default_command = application.borrow().default_command.clone(); - input = std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new( - vec![( - PhpMixed::from("command_name".to_string()), - PhpMixed::from(default_command), - )], - None, - )?)); + let mut len; + let mut title = String::new(); + if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { + let class = throwable_debug_type(e); + let code = throwable_get_code(e); + title = format!( + " [{}{}] ", + class, + if code != 0 { + format!(" ({})", code) + } else { + String::new() + } + ); + len = Helper::width(&title); } else { - application.borrow_mut().want_helps = true; + len = 0; } - } - let name = match name { - Some(name) => name, - None => { - let name = application.borrow().default_command.clone(); - let definition = application.borrow_mut().get_definition(); - let command_description = definition - .borrow() - .get_argument(&PhpMixed::from("command".to_string()))? - .get_description() - .to_string(); - let new_command_argument = InputArgument::new( - "command".to_string(), - Some(InputArgument::OPTIONAL), - command_description, - PhpMixed::from(name.clone()), - )?; - // $definition->setArguments(array_merge($definition->getArguments(), - // ['command' => new InputArgument('command', InputArgument::OPTIONAL, ...)])) - let mut merged_arguments: Vec = Vec::new(); - let mut replaced_command = false; - for (key, argument) in definition.borrow().get_arguments() { - if key == "command" { - merged_arguments.push(new_command_argument.clone()); - replaced_command = true; - } else { - merged_arguments.push((**argument).clone()); - } - } - if !replaced_command { - merged_arguments.push(new_command_argument); - } - definition.borrow_mut().set_arguments(merged_arguments)?; + // PHP rewrites `@anonymous\0` markers via class_exists/get_parent_class/class_implements. + // Rust error messages never carry PHP's anonymous-class marker and those reflection + // primitives have no Rust equivalent, so the branch is unreachable here. + // TODO(review): port the @anonymous rewrite if it ever becomes relevant. - name + let width = if self.terminal.get_width() != 0 { + self.terminal.get_width() - 1 + } else { + i64::MAX + }; + let mut lines: Vec<(String, i64)> = Vec::new(); + let split = if !message.is_empty() { + shirabe_php_shim::preg_split(r"/\r?\n/", &message) + } else { + Vec::new() + }; + for line in split { + for line in self.split_string_by_width(&line, width - 4) { + // pre-format lines to get the right string length + let line_length = Helper::width(&line) + 4; + lines.push((line, line_length)); + len = std::cmp::max(line_length, len); + } } - }; - - let command: std::rc::Rc>; - application.borrow_mut().running_command = None; - // the command name MUST be the first element of the input - let find_result = application.borrow_mut().find(&name); - match find_result { - Ok(c) => { - command = c; + let mut messages: Vec = Vec::new(); + if !throwable_is_exception_interface(e) + || output_interface::VERBOSITY_VERBOSE <= verbosity + { + // TODO(review): anyhow::Error carries no PHP file/line, so getFile()/getLine() take + // the 'n/a' fallback PHP itself uses when they are unavailable. The real source + // location cannot be reproduced (it would be a Rust path, not Composer's PHP path). + messages.push(format!( + "{}", + OutputFormatter::escape(&format!("In {} line {}:", "n/a", "n/a")).unwrap() + )); } - Err(e) => { - // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) - // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) - let alternatives: Option> = downcast_command_not_found(&e) - .filter(|_| !is_namespace_not_found(&e)) - .map(|cnf| cnf.get_alternatives().clone()); + let empty_line = format!( + "{}", + shirabe_php_shim::str_repeat(" ", len as usize) + ); + messages.push(empty_line.clone()); + if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { + messages.push(format!( + "{}{}", + title, + shirabe_php_shim::str_repeat( + " ", + (len - Helper::width(&title)).max(0) as usize + ) + )); + } + for (line, line_length) in &lines { + messages.push(format!( + " {} {}", + OutputFormatter::escape(line).unwrap(), + shirabe_php_shim::str_repeat(" ", (len - line_length) as usize) + )); + } + messages.push(empty_line); + messages.push(String::new()); - let single_alternative = match &alternatives { - Some(alts) if alts.len() == 1 => Some(alts[0].clone()), - _ => None, - }; + output + .borrow() + .writeln(&messages, output_interface::VERBOSITY_QUIET); - if single_alternative.is_none() || !input.borrow().is_interactive() { - return Err(e); - } + // PHP renders the `Exception trace:` block (getTrace()) at -v or higher. A PHP backtrace + // has no faithful Rust equivalent, so this block is intentionally never ported; verbose + // output simply omits the trace. + } + } - let alternative = single_alternative.unwrap(); + /// Configures the input and output instances based on the user arguments and options. + pub fn configure_io( + &self, + input: &std::rc::Rc>, + output: &std::rc::Rc>, + ) -> anyhow::Result<()> { + if input.borrow().has_parameter_option( + PhpMixed::from(vec![PhpMixed::from("--ansi".to_string())]), + true, + ) { + output.borrow().set_decorated(true); + } else if input.borrow().has_parameter_option( + PhpMixed::from(vec![PhpMixed::from("--no-ansi".to_string())]), + true, + ) { + output.borrow().set_decorated(false); + } - let mut style = SymfonyStyle::new(input.clone(), output.clone()); + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--no-interaction".to_string()), + PhpMixed::from("-n".to_string()), + ]), + true, + ) { + input.borrow_mut().set_interactive(false); + } + + let mut shell_verbosity = shirabe_php_shim::getenv("SHELL_VERBOSITY").unwrap_or_default(); + let shell_verbosity_int: i64 = shell_verbosity.parse().unwrap_or(0); + let mut shell_verbosity: i64 = shell_verbosity_int; + match shell_verbosity_int { + -1 => { output .borrow() - .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); - let formatted_block = FormatterHelper::default().format_block( - FormatBlockMessages::String(format!( - "Command \"{}\" is not defined.", - PhpMixed::from(name.clone()), - )), - "error", - true, - ); + .set_verbosity(output_interface::VERBOSITY_QUIET); + } + 1 => { output .borrow() - .writeln(&[formatted_block], output_interface::OUTPUT_NORMAL); - if !style.confirm( - &format!( - "Do you want to run \"{}\" instead? ", - PhpMixed::from(alternative.clone()), - ), - false, - ) { - return Ok(1); - } - - command = application.borrow_mut().find(&alternative)?; + .set_verbosity(output_interface::VERBOSITY_VERBOSE); + } + 2 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); + } + 3 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_DEBUG); + } + _ => { + shell_verbosity = 0; } } - // LazyCommand is intentionally not ported. + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--quiet".to_string()), + PhpMixed::from("-q".to_string()), + ]), + true, + ) { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_QUIET); + shell_verbosity = -1; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-vvv".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=3".to_string()), true) + || input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + ) == PhpMixed::from(3i64) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_DEBUG); + shell_verbosity = 3; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-vv".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=2".to_string()), true) + || input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + ) == PhpMixed::from(2i64) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); + shell_verbosity = 2; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-v".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=1".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose".to_string()), true) + || shirabe_php_shim::php_truthy(&input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + )) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERBOSE); + shell_verbosity = 1; + } - application.borrow_mut().running_command = Some(command.clone()); - // do_run_command invokes the command's run(), which calls back into the application - // (mergeApplicationDefinition etc.); it must run with no application borrow held. - let exit_code = Application::do_run_command( - application, - command.clone(), - input.clone(), - output.clone(), - )?; - application.borrow_mut().running_command = None; - - Ok(exit_code) - } - - pub fn set_helper_set(&mut self, helper_set: std::rc::Rc>) { - self.helper_set = Some(helper_set); - } + if shell_verbosity == -1 { + input.borrow_mut().set_interactive(false); + } - /// Get the helper set associated with the command. - pub fn get_helper_set(&mut self) -> std::rc::Rc> { - if self.helper_set.is_none() { - self.helper_set = Some(self.get_default_helper_set()); + if shirabe_php_shim::function_exists("putenv") { + shirabe_php_shim::putenv(&format!("SHELL_VERBOSITY={}", shell_verbosity)); } + shirabe_php_shim::env_set("SHELL_VERBOSITY", shell_verbosity.to_string()); + shirabe_php_shim::server_set("SHELL_VERBOSITY", shell_verbosity.to_string()); - self.helper_set.as_ref().unwrap().clone() - } + let _ = &mut shell_verbosity; - pub fn set_definition(&mut self, definition: std::rc::Rc>) { - self.definition = Some(definition); + Ok(()) } - /// Gets the InputDefinition related to this Application. - pub fn get_definition(&mut self) -> std::rc::Rc> { - if self.definition.is_none() { - // `get_default_input_definition` is the Composer override (returns a Result because the - // Rust `InputOption::new` is fallible); the option modes are constants that cannot fail, - // so unwrapping mirrors the PHP call that never throws here. - self.definition = Some(std::rc::Rc::new(std::cell::RefCell::new( - self.get_default_input_definition().unwrap(), - ))); - } - + /// Gets the name of the command based on input. + pub fn get_command_name(&self, input: &dyn InputInterface) -> Option { if self.single_command { - let input_definition = self.definition.as_ref().unwrap().clone(); - input_definition - .borrow_mut() - .set_arguments(Vec::new()) - .unwrap(); - - return input_definition; + Some(self.default_command.clone()) + } else { + input.get_first_argument() } - - self.definition.as_ref().unwrap().clone() } - /// Adds suggestions to `suggestions` for the current completion input (e.g. option or argument). - pub fn complete( - &mut self, - input: &CompletionInput, - suggestions: &mut CompletionSuggestions, - ) -> anyhow::Result<()> { - if CompletionInput::TYPE_ARGUMENT_VALUE == input.get_completion_type() - && input.get_completion_name().as_deref() == Some("command") - { - let mut command_names: Vec = Vec::new(); - for (name, command) in self.all(None)? { - // skip hidden commands and aliased commands as they already get added below - if command.borrow().is_hidden() || command.borrow().get_name() != Some(name.clone()) - { - continue; - } - command_names.push(PhpMixed::from( - command.borrow().get_name().unwrap_or_default(), - )); - for name in command.borrow().get_aliases() { - command_names.push(PhpMixed::from(name)); - } - } - // array_filter($commandNames) - let filtered: Vec = - command_names - .into_iter() - .filter(shirabe_php_shim::php_truthy) - .map(|n| { - shirabe_external_packages::symfony::console::completion::completion_suggestions::StringOrSuggestion::String( - shirabe_php_shim::php_to_string(&n), - ) - }) - .collect(); - suggestions.suggest_values(filtered); - - return Ok(()); - } - - if CompletionInput::TYPE_OPTION_NAME == input.get_completion_type() { - // $suggestions->suggestOptions($this->getDefinition()->getOptions()); - // TODO(review): get_options() yields Rc (shared, non-Clone) while - // suggest_options() consumes owned InputOption values; an ownership/clone strategy - // for InputOption is needed. - suggestions.suggest_options(todo!("owned options from get_definition().get_options()")); + /// Gets the default input definition (Symfony base; `parent::getDefaultInputDefinition`). + pub fn base_get_default_input_definition(&self) -> InputDefinition { + use shirabe_external_packages::symfony::console::input::input_definition::DefinitionItem; + InputDefinition::new(vec![ + DefinitionItem::InputArgument( + InputArgument::new( + "command".to_string(), + Some(InputArgument::REQUIRED), + "The command to execute".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--help", + PhpMixed::from("-h".to_string()), + Some(InputOption::VALUE_NONE), + format!( + "Display help for the given command. When no command is given display help for the {} command", + self.default_command + ), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--quiet", + PhpMixed::from("-q".to_string()), + Some(InputOption::VALUE_NONE), + "Do not output any message".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--verbose", + PhpMixed::from("-v|vv|vvv".to_string()), + Some(InputOption::VALUE_NONE), + "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--version", + PhpMixed::from("-V".to_string()), + Some(InputOption::VALUE_NONE), + "Display this application version".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--ansi", + PhpMixed::from("".to_string()), + Some(InputOption::VALUE_NEGATABLE), + "Force (or disable --no-ansi) ANSI output".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--no-interaction", + PhpMixed::from("-n".to_string()), + Some(InputOption::VALUE_NONE), + "Do not ask any interactive question".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + ]) + .unwrap() + } - return Ok(()); - } + /// Gets the default commands that should always be available (Symfony base; + /// `parent::getDefaultCommands`). + pub fn base_get_default_commands( + &self, + ) -> Vec>> { + use shirabe_external_packages::symfony::console::command::complete_command::CompleteCommand; + use shirabe_external_packages::symfony::console::command::dump_completion_command::DumpCompletionCommand; + use shirabe_external_packages::symfony::console::command::help_command::HelpCommand; + use shirabe_external_packages::symfony::console::command::list_command::ListCommand; - Ok(()) + vec![ + std::rc::Rc::new(std::cell::RefCell::new(HelpCommand::new())) + as std::rc::Rc>, + std::rc::Rc::new(std::cell::RefCell::new(ListCommand::new())), + std::rc::Rc::new(std::cell::RefCell::new( + CompleteCommand::new(IndexMap::new()) + .expect("CompleteCommand with default outputs is valid"), + )), + std::rc::Rc::new(std::cell::RefCell::new(DumpCompletionCommand::new())), + ] } - /// Gets the help message (Symfony base; `parent::getHelp`). - pub fn base_get_help(&self) -> String { - self.get_long_version() + /// Gets the default helper set with the helpers that should always be available. + pub fn get_default_helper_set(&self) -> std::rc::Rc> { + let helper_set = std::rc::Rc::new(std::cell::RefCell::new(HelperSet::default())); + let helpers: IndexMap>> = { + let mut m: IndexMap< + HelperSetKey, + std::rc::Rc>, + > = IndexMap::new(); + m.insert( + HelperSetKey::Int(0), + std::rc::Rc::new(std::cell::RefCell::new(FormatterHelper::default())), + ); + m.insert( + HelperSetKey::Int(1), + std::rc::Rc::new(std::cell::RefCell::new(DebugFormatterHelper::default())), + ); + m.insert( + HelperSetKey::Int(2), + std::rc::Rc::new(std::cell::RefCell::new(ProcessHelper::default())), + ); + m.insert( + HelperSetKey::Int(3), + std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default())), + ); + m + }; + HelperSet::new(&helper_set, helpers); + helper_set } - /// Gets whether to catch exceptions or not during commands execution. - pub fn are_exceptions_caught(&self) -> bool { - self.catch_exceptions + /// Returns abbreviated suggestions in string format. + fn get_abbreviation_suggestions(&self, abbrevs: &[String]) -> String { + format!(" {}", shirabe_php_shim::implode("\n ", abbrevs)) } - /// Sets whether to catch exceptions or not during commands execution. - pub fn set_catch_exceptions(&mut self, boolean: bool) { - self.catch_exceptions = boolean; - } + /// Returns the namespace part of the command name. + /// + /// This method is not part of public API and should not be used directly. + pub fn extract_namespace(&self, name: &str, limit: Option) -> String { + // $parts = explode(':', $name, -1); + let parts = shirabe_php_shim::explode_limit(":", name, -1); - /// Gets the name of the application. - pub fn get_name(&self) -> String { - self.name.clone() + // implode(':', null === $limit ? $parts : array_slice($parts, 0, $limit)) + match limit { + None => shirabe_php_shim::implode(":", &parts), + Some(limit) => { + let sliced: Vec = parts.into_iter().take(limit.max(0) as usize).collect(); + shirabe_php_shim::implode(":", &sliced) + } + } } - /// Sets the application name. - pub fn set_name(&mut self, name: &str) { - self.name = name.to_string(); + /// Finds alternative of $name among $collection, if nothing is found in + /// $collection, try in $abbrevs. + fn find_alternatives(&self, name: &str, collection: &[String]) -> Vec { + let threshold = 1e3; + let mut alternatives: IndexMap = IndexMap::new(); + + let mut collection_parts: IndexMap> = IndexMap::new(); + for item in collection { + collection_parts.insert(item.clone(), shirabe_php_shim::explode(":", item)); + } + + for (i, subname) in shirabe_php_shim::explode(":", name).into_iter().enumerate() { + for (collection_name, parts) in &collection_parts { + let exists = alternatives.contains_key(collection_name); + if parts.get(i).is_none() && exists { + *alternatives.get_mut(collection_name).unwrap() += threshold; + continue; + } else if parts.get(i).is_none() { + continue; + } + + let lev = shirabe_php_shim::levenshtein(&subname, &parts[i]) as f64; + if lev <= shirabe_php_shim::strlen(&subname) as f64 / 3.0 + || (!subname.is_empty() && parts[i].contains(&subname)) + { + let v = if exists { + alternatives[collection_name] + lev + } else { + lev + }; + alternatives.insert(collection_name.clone(), v); + } else if exists { + *alternatives.get_mut(collection_name).unwrap() += threshold; + } + } + } + + for item in collection { + let lev = shirabe_php_shim::levenshtein(name, item) as f64; + if lev <= shirabe_php_shim::strlen(name) as f64 / 3.0 || item.contains(name) { + let v = if alternatives.contains_key(item) { + alternatives[item] - lev + } else { + lev + }; + alternatives.insert(item.clone(), v); + } + } + + // array_filter($alternatives, fn($lev) => $lev < 2 * $threshold) + alternatives.retain(|_, lev| *lev < 2.0 * threshold); + // ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE) + let mut keys: Vec = alternatives.keys().cloned().collect(); + shirabe_php_shim::sort_natural_flag_case(&mut keys); + + keys } - /// Gets the application version. - pub fn get_version(&self) -> String { - self.version.clone() + /// Sets the default SymfonyCommand name. + pub fn set_default_command( + &mut self, + command_name: &str, + is_single_command: bool, + ) -> anyhow::Result<&mut Self> { + // $this->defaultCommand = explode('|', ltrim($commandName, '|'))[0]; + let trimmed = shirabe_php_shim::ltrim(command_name, Some("|")); + self.default_command = shirabe_php_shim::explode("|", &trimmed) + .into_iter() + .next() + .unwrap_or_default(); + + if is_single_command { + // Ensure the command exist + self.find(command_name)?; + + self.single_command = true; + } + + Ok(self) } - /// Sets the application version. - pub fn set_version(&mut self, version: &str) { - self.version = version.to_string(); + pub fn is_single_command(&self) -> bool { + self.single_command } - /// Adds an array of command objects. + fn split_string_by_width(&self, string: &str, width: i64) -> Vec { + // str_split is not suitable for multi-byte characters, we should use preg_split to get char array properly. + let encoding = match shirabe_php_shim::mb_detect_encoding(string, None, true) { + None => return shirabe_php_shim::str_split(string, width), + Some(encoding) => encoding, + }; + + let utf8_string = shirabe_php_shim::mb_convert_encoding(string.into(), "utf8", &encoding); + let mut lines: Vec = Vec::new(); + let mut line = String::new(); + + let mut offset = 0i64; + let mut m: indexmap::IndexMap> = + indexmap::IndexMap::new(); + while shirabe_php_shim::preg_match2( + r"/.{1,10000}/u", + &utf8_string, + &mut m, + 0, + offset as usize, + ) { + let m0 = m[&shirabe_php_shim::CaptureKey::ByIndex(0)] + .as_deref() + .unwrap_or(""); + offset += shirabe_php_shim::strlen(m0); + + let chunk = m0; + for char in chunk + .char_indices() + .map(|(i, c)| &chunk[i..i + c.len_utf8()]) + { + // test if $char could be appended to current line + if shirabe_php_shim::mb_strwidth(&format!("{}{}", line, char), Some("utf8")) + <= width + { + line.push_str(char); + continue; + } + // if not, push current line to array and make new line + lines.push(shirabe_php_shim::str_pad( + &line, + width as usize, + " ", + shirabe_php_shim::STR_PAD_RIGHT, + )); + line = char.to_string(); + } + } + + lines.push(if !lines.is_empty() { + shirabe_php_shim::str_pad(&line, width as usize, " ", shirabe_php_shim::STR_PAD_RIGHT) + } else { + line.clone() + }); + + shirabe_php_shim::mb_convert_variables(&encoding, "utf8", &mut lines); + + lines + } + + /// Returns all namespaces of the command name. + fn extract_all_namespaces(&self, name: &str) -> Vec { + // -1 as third argument is needed to skip the command short name when exploding + let parts = shirabe_php_shim::explode_limit(":", name, -1); + let mut namespaces: Vec = Vec::new(); + + for part in parts { + if !namespaces.is_empty() { + let last = namespaces.last().unwrap().clone(); + namespaces.push(format!("{}:{}", last, part)); + } else { + namespaces.push(part); + } + } + + namespaces + } +} + +/// Shared handle to an `Application`. +/// +/// PHP `Application` instances are shared by reference — commands hold a back-pointer to their +/// owning application that aliases this very `RefCell`. The whole run flow is therefore driven +/// through this handle with short-lived borrows, so a command callback never re-borrows the +/// application while it is already borrowed. Mirrors the Composer handle pattern (`ComposerHandle`). +#[derive(Debug, Clone)] +pub struct ApplicationHandle(std::rc::Rc>); + +impl ApplicationHandle { + /// Builds the application inside a shared handle and registers the default commands. /// - /// If a SymfonyCommand is not enabled it will not be added. - pub fn add_commands( - &mut self, - commands: Vec>>, - ) -> anyhow::Result<()> { + /// Commands hold a back-pointer to their application, which shares this very `RefCell`. So the + /// whole run flow is driven through the handle (never a long-lived `borrow_mut`), and command + /// registration happens here — before any borrow is taken — so `set_application` can borrow the + /// application without a re-entrant conflict. + pub fn new(name: String, version: String) -> anyhow::Result { + let application = ApplicationHandle(std::rc::Rc::new(std::cell::RefCell::new( + Application::new(name, version), + ))); + application.0.borrow_mut().me = std::rc::Rc::downgrade(&application.0); + application.init()?; + Ok(application) + } + + /// Registers the default commands. It is called lazily with $this->initialized flag in PHP, + /// but called once from new() in Rust. + fn init(&self) -> anyhow::Result<()> { + let commands = self.0.borrow().get_default_commands(); for command in commands { self.add(command)?; } + Ok(()) } @@ -1829,18 +1888,17 @@ impl Application { /// /// If a command with the same name already exists, it will be overridden. /// If the command is not enabled it will not be added. + /// + /// Takes the shared application handle rather than `&mut self` so no application borrow is held + /// while calling into the command, letting `set_application` borrow back safely. pub fn add( - &mut self, + &self, command: std::rc::Rc>, ) -> anyhow::Result>>> { - self.init()?; - - // TODO(review): $command->setApplication($this) needs an Rc> to the - // current instance. Application is held by value here; the self-reference required to set - // the command's back-pointer needs the shared-ownership design (Phase C). - command - .borrow_mut() - .set_application(todo!("Rc> of self")); + let application = &self.0; + command.borrow_mut().set_application(Some( + application.clone() as std::rc::Rc> + )); if !command.borrow().is_enabled() { command.borrow_mut().set_application(None); @@ -1855,7 +1913,7 @@ impl Application { return Err(ConsoleLogicException(shirabe_php_shim::LogicException { message: format!( "The command defined in \"{}\" cannot have an empty name.", - PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command,)), + PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command)), ), code: 0, }) @@ -1863,1148 +1921,1028 @@ impl Application { } let name = command.borrow().get_name().unwrap(); - self.commands.insert(name, command.clone()); + application + .borrow_mut() + .commands + .insert(name, command.clone()); for alias in command.borrow().get_aliases() { - self.commands.insert(alias, command.clone()); + application + .borrow_mut() + .commands + .insert(alias, command.clone()); } Ok(Some(command)) } - /// Returns a registered command by name or alias. - /// - /// Throws CommandNotFoundException when given command name does not exist. - pub fn get( - &mut self, - name: &str, - ) -> anyhow::Result>> { - self.init()?; - - if !self.has(name) { - return Err(CommandNotFoundException::new( - format!( - "The command \"{}\" does not exist.", - PhpMixed::from(name.to_string()), - ), - Vec::new(), - 0, - ) - .into()); - } + pub fn run( + &self, + input: Option>>, + output: Option>>, + ) -> anyhow::Result { + let output = match output { + Some(output) => Some(output), + None => Some( + std::rc::Rc::new(std::cell::RefCell::new(Factory::create_output())) + as std::rc::Rc>, + ), + }; - // When the command has a different name than the one used at the command loader level - if !self.commands.contains_key(name) { - return Err(CommandNotFoundException::new( - format!( - "The \"{}\" command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or \"setName()\".", - PhpMixed::from(name.to_string()), - ), - Vec::new(), - 0, - ) - .into()); - } + self.base_run(input, output) + } - let command = self.commands[name].clone(); + pub fn do_run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let application = &self.0; + application.borrow_mut().disable_plugins_by_default = input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--no-plugins"]), false); + application.borrow_mut().disable_scripts_by_default = input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--no-scripts"]), false); - if self.want_helps { - self.want_helps = false; - - let help_command = self.get("help")?; - help_command - .borrow_mut() - .as_any_mut() - .downcast_mut::() - .expect("the help command is a HelpCommand instance") - .set_command(command); - - return Ok(help_command); + let stdin = shirabe_php_shim::STDIN; + if Platform::get_env("COMPOSER_TESTS_ARE_RUNNING").as_deref() != Some("1") + && (Platform::get_env("COMPOSER_NO_INTERACTION").is_some() + || !Platform::is_tty(Some(stdin))) + { + input.borrow_mut().set_interactive(false); } - Ok(command) - } - - /// Returns true if the command exists, false otherwise. - pub fn has(&mut self, name: &str) -> bool { - self.init().unwrap(); + let mut helpers: IndexMap< + HelperSetKey, + std::rc::Rc>, + > = IndexMap::new(); + helpers.insert( + HelperSetKey::Int(0), + std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default())), + ); + let helper_set = std::rc::Rc::new(std::cell::RefCell::new(HelperSet::default())); + HelperSet::new(&helper_set, helpers); + application.borrow_mut().io = std::rc::Rc::new(std::cell::RefCell::new(ConsoleIO::new( + input.clone(), + output.clone(), + helper_set.borrow().clone(), + ))); + // Cache the IO so the rest of the flow does not re-borrow the application; command callbacks + // (e.g. mergeApplicationDefinition) need the application's RefCell free. + let io = application.borrow().io.clone(); - if self.commands.contains_key(name) { - return true; - } + // Register error handler again to pass it the IO instance + ErrorHandler::register(Some(io.clone())); - if let Some(command_loader) = &self.command_loader - && command_loader.has(name) + if input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--no-cache"]), false) { - let command = command_loader.get(name); - // $this->add($this->commandLoader->get($name)) - // TODO(review): command_loader.get() returns Box while add() expects - // Rc>; the loader return type needs reconciliation. - let _ = command; - return self - .add(todo!( - "Rc> from command_loader.get(name)" - )) - .map(|c| c.is_some()) - .unwrap_or(false); + io.write_error3("Disabling cache usage", true, io_interface::DEBUG); + Platform::put_env( + "COMPOSER_CACHE_DIR", + if Platform::is_windows() { + "nul" + } else { + "/dev/null" + }, + ); } - false - } - - /// Returns an array of all unique namespaces used by currently registered commands. - /// - /// It does not return the global namespace which always exists. - pub fn get_namespaces(&mut self) -> anyhow::Result> { - let mut namespaces: Vec> = Vec::new(); - for command in self.all(None)?.values() { - if command.borrow().is_hidden() { - continue; - } - - namespaces.push( - self.extract_all_namespaces(&command.borrow().get_name().unwrap_or_default()), + // switch working dir + let new_work_dir = application.borrow().get_new_working_dir(input.clone())?; + let mut old_working_dir: Option = None; + if let Some(ref nwd) = new_work_dir { + old_working_dir = Some(Platform::get_cwd(true).unwrap_or_default()); + chdir(nwd); + application.borrow_mut().initial_working_directory = getcwd(); + let cwd = Platform::get_cwd(true).unwrap_or_default(); + io.write_error3( + &format!( + "Changed CWD to {}", + if !cwd.is_empty() { + cwd.clone() + } else { + nwd.clone() + } + ), + true, + io_interface::DEBUG, ); - - for alias in command.borrow().get_aliases() { - namespaces.push(self.extract_all_namespaces(&alias)); - } } - // array_values(array_unique(array_filter(array_merge([], ...$namespaces)))) - let mut merged: Vec = Vec::new(); - for ns in namespaces { - merged.extend(ns); + // determine command name to be executed without including plugin commands + let mut command_name: Option = Some(String::new()); + let raw_command_name = application + .borrow_mut() + .get_command_name_before_binding(input.clone())?; + if let Some(ref raw) = raw_command_name { + let find_result = application.borrow_mut().find(raw); + match find_result { + Ok(cmd) => { + command_name = cmd.borrow().get_name(); + } + Err(e) => { + if e.downcast_ref::().is_some() { + // we'll check command validity again later after plugins are loaded + command_name = None; + } + // PHP also catches \InvalidArgumentException here without action + } + } } - let merged: Vec = merged.into_iter().filter(|s| !s.is_empty()).collect(); - let mut seen = std::collections::HashSet::new(); - let unique: Vec = merged - .into_iter() - .filter(|s| seen.insert(s.clone())) - .collect(); - - Ok(unique) - } - - /// Finds a registered namespace by a name or an abbreviation. - /// - /// Throws NamespaceNotFoundException when namespace is incorrect or ambiguous. - pub fn find_namespace(&mut self, namespace: &str) -> anyhow::Result { - let all_namespaces = self.get_namespaces()?; - // implode('[^:]*:', array_map('preg_quote', explode(':', $namespace))).'[^:]*' - let parts: Vec = shirabe_php_shim::explode(":", namespace) - .into_iter() - .map(|p| shirabe_php_shim::preg_quote(&p, None)) - .collect(); - let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); - let namespaces = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_namespaces); - if namespaces.is_empty() { - let mut message = format!( - "There are no commands defined in the \"{}\" namespace.", - PhpMixed::from(namespace.to_string()), - ); + // prompt user for dir change if no composer.json is present in current dir + let no_composer_json_commands = vec![ + "".to_string(), + "list".to_string(), + "init".to_string(), + "about".to_string(), + "help".to_string(), + "diagnose".to_string(), + "self-update".to_string(), + "global".to_string(), + "create-project".to_string(), + "outdated".to_string(), + ]; + let use_parent_dir_if_no_json_available = + application.borrow().get_use_parent_dir_config_value(); + let no_composer_json_commands_pm = PhpMixed::List( + no_composer_json_commands + .iter() + .map(|s| PhpMixed::String(s.clone())) + .collect(), + ); + if new_work_dir.is_none() + && !in_array( + command_name.as_deref().unwrap_or("").into(), + &no_composer_json_commands_pm, + true, + ) + && !file_exists(&Factory::get_composer_file().unwrap_or_default()) + && use_parent_dir_if_no_json_available.as_bool() != Some(false) + && (command_name.as_deref() != Some("config") + || (!input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--file"]), true) + && !input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["-f"]), true))) + && !input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--help"]), true) + && !input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["-h"]), true) + { + let mut dir = dirname(&Platform::get_cwd(true).unwrap_or_default()); + let home_value = Platform::get_env("HOME") + .or_else(|| Platform::get_env("USERPROFILE")) + .unwrap_or_else(|| "/".to_string()); + let home = realpath(&home_value).unwrap_or_default(); - let alternatives = self.find_alternatives(namespace, &all_namespaces); - if !alternatives.is_empty() { - if alternatives.len() == 1 { - message.push_str("\n\nDid you mean this?\n "); - } else { - message.push_str("\n\nDid you mean one of these?\n "); + // abort when we reach the home dir or top of the filesystem + while dirname(&dir) != dir && dir != home { + if file_exists(&format!( + "{}/{}", + dir, + Factory::get_composer_file().unwrap_or_default() + )) { + if use_parent_dir_if_no_json_available.as_bool() != Some(true) + && !io.is_interactive() + { + io.write_error(&format!("No composer.json in current directory, to use the one at {} run interactively or set config.use-parent-dir to true", dir)); + break; + } + if use_parent_dir_if_no_json_available.as_bool() == Some(true) + || io.ask_confirmation(format!("No composer.json in current directory, do you want to use the one at {}? [y,n]? ", dir), true) + { + if use_parent_dir_if_no_json_available.as_bool() == Some(true) { + io.write_error(&format!("No composer.json in current directory, changing working directory to {}", dir)); + } else { + io.write_error("Always want to use the parent dir? Use \"composer config --global use-parent-dir true\" to change the default."); + } + old_working_dir = Some(Platform::get_cwd(true).unwrap_or_default()); + chdir(&dir); + } + break; } - - message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); + dir = dirname(&dir); } - - return Err(NamespaceNotFoundException(CommandNotFoundException::new( - message, - alternatives, - 0, - )) - .into()); + drop((dir, home)); } - let exact = namespaces.iter().any(|n| n == namespace); - if namespaces.len() > 1 && !exact { - return Err(NamespaceNotFoundException(CommandNotFoundException::new( - format!( - "The namespace \"{}\" is ambiguous.\nDid you mean one of these?\n{}.", - PhpMixed::from(namespace.to_string()), - PhpMixed::from(self.get_abbreviation_suggestions(&namespaces)), - ), - namespaces.clone(), - 0, - )) - .into()); - } - - // $exact ? $namespace : reset($namespaces) - if exact { - Ok(namespace.to_string()) - } else { - Ok(namespaces[0].clone()) - } - } - - /// Finds a command by name or alias. - /// - /// Contrary to get, this command tries to find the best match if you give it an - /// abbreviation of a name or alias. - /// - /// Throws CommandNotFoundException when command name is incorrect or ambiguous. - pub fn find( - &mut self, - name: &str, - ) -> anyhow::Result>> { - self.init()?; + let needs_sudo_check = !Platform::is_windows() + && function_exists("exec") + && Platform::get_env("COMPOSER_ALLOW_SUPERUSER").is_none() + && !Platform::is_docker(); + let mut is_non_allowed_root = false; - let mut aliases: IndexMap = IndexMap::new(); + // Clobber sudo credentials if COMPOSER_ALLOW_SUPERUSER is not set before loading plugins + if needs_sudo_check { + is_non_allowed_root = application.borrow().is_running_as_root(); - let commands_snapshot: Vec>> = - self.commands.values().cloned().collect(); - for command in &commands_snapshot { - // A command's run() can re-enter find() (e.g. HelpCommand looks up the command it - // describes). That command is mutably borrowed for the duration of its run(), so it - // cannot be borrowed here. It is safe to skip: find() always completes this alias pass - // before returning a command, so any command currently executing already had its - // aliases registered in the earlier find() call that located it. - // TODO: this work-around could be solved. - let Ok(borrowed) = command.try_borrow() else { - continue; - }; - let aliases = borrowed.get_aliases(); - drop(borrowed); - for alias in aliases { - if !self.has(&alias) { - self.commands.insert(alias, command.clone()); + if is_non_allowed_root { + let uid: i64 = Platform::get_env("SUDO_UID") + .map(|v| v.parse().unwrap_or(0)) + .unwrap_or(0); + if uid != 0 { + // Silently clobber any sudo credentials on the invoking user to avoid privilege escalations later on + // ref. https://github.com/composer/composer/issues/5119 + let _ = Silencer::call(|| { + shirabe_php_shim::exec( + &format!("sudo -u \\#{} sudo -K > /dev/null 2>&1", uid), + None, + None, + ); + Ok(()) + }); } } - } - if self.has(name) { - return self.get(name); + // Silently clobber any remaining sudo leases on the current user as well to avoid privilege escalations + let _ = Silencer::call(|| { + shirabe_php_shim::exec("sudo -K > /dev/null 2>&1", None, None); + Ok(()) + }); } - // $allCommands = commandLoader ? array_merge(loader->getNames(), array_keys(commands)) : array_keys(commands) - let all_commands: Vec = match &self.command_loader { - Some(command_loader) => { - let mut all = command_loader.get_names(); - all.extend(self.commands.keys().cloned()); - all - } - None => self.commands.keys().cloned().collect(), - }; - - let parts: Vec = shirabe_php_shim::explode(":", name) - .into_iter() - .map(|p| shirabe_php_shim::preg_quote(&p, None)) - .collect(); - let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); - let mut commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_commands); + // avoid loading plugins/initializing the Composer instance earlier than necessary if no plugin command is needed + // if showing the version, we never need plugin commands + let mnp_list = PhpMixed::List(vec![ + PhpMixed::String("".to_string()), + PhpMixed::String("list".to_string()), + PhpMixed::String("help".to_string()), + ]); + let may_need_plugin_command = !input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), false) + && (command_name.is_none() + || in_array( + command_name.as_deref().unwrap_or("").into(), + &mnp_list, + true, + ) + || (command_name.as_deref() == Some("_complete") && !is_non_allowed_root)); - if commands.is_empty() { - commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}i", expr), &all_commands); - } + let may_need_script_command = may_need_plugin_command + || command_name.as_deref() == Some("run-script") + || raw_command_name != command_name; - // if no commands matched or we just matched namespaces - if commands.is_empty() - || shirabe_php_shim::preg_grep(&format!("{{^{}$}}i", expr), &commands).is_empty() + if may_need_plugin_command + && !application.borrow().disable_plugins_by_default + && !application.borrow().has_plugin_commands { - if let Some(pos) = shirabe_php_shim::strrpos(name, ":") { - // check if a namespace exists and contains commands - self.find_namespace(&name[..pos])?; - } - - let mut message = format!( - "SymfonyCommand \"{}\" is not defined.", - PhpMixed::from(name.to_string()), - ); - - let mut alternatives = self.find_alternatives(name, &all_commands); - if !alternatives.is_empty() { - // remove hidden commands - let mut filtered: Vec = Vec::new(); - for alt in alternatives { - if !self.get(&alt)?.borrow().is_hidden() { - filtered.push(alt); - } - } - alternatives = filtered; + // at this point plugins are needed, so if we are running as root and it is not allowed we need to prompt + // if interactive, and abort otherwise + if is_non_allowed_root { + io.write_error("Do not run Composer as root/super user! See https://getcomposer.org/root for details"); - if alternatives.len() == 1 { - message.push_str("\n\nDid you mean this?\n "); + if io.is_interactive() + && io.ask_confirmation( + "Continue as root/super user [yes]? " + .to_string(), + true, + ) + { + // avoid a second prompt later + is_non_allowed_root = false; } else { - message.push_str("\n\nDid you mean one of these?\n "); + io.write_error("Aborting as no plugin should be loaded if running as super user is not explicitly allowed"); + + return Ok(1); } - message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); } - return Err(CommandNotFoundException::new(message, alternatives, 0).into()); - } - - // filter out aliases for commands which are already on the list - if commands.len() > 1 { - // $commandList = commandLoader ? array_merge(array_flip(loader->getNames()), commands) : commands - // TODO(review): $commandList mixes flipped loader names (string => int) with - // SymfonyCommand - // instances; this heterogeneous PHP array needs a typed representation. The alias - // de-duplication and the loader->get() lazy materialization are left to design. - let mut command_list: IndexMap< - String, - std::rc::Rc>, - > = self.commands.clone(); - - let commands_clone = commands.clone(); - let mut new_commands: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for name_or_alias in commands { - if !command_list.contains_key(&name_or_alias) { - let loaded = self.command_loader.as_ref().unwrap().get(&name_or_alias); - let _ = loaded; - command_list.insert( - name_or_alias.clone(), - todo!( - "Rc> from command_loader.get(name_or_alias)" - ), - ); + // TODO(phase-b): the original PHP catches plugin discovery exceptions in a + // try/catch. The Rust port keeps the loop but skips IO error reporting + // because get_plugin_commands borrows &mut self, conflicting with io. + let mut plugin_warnings: Vec = Vec::new(); + match (|| -> anyhow::Result<()> { + let plugin_commands = application.borrow_mut().get_plugin_commands()?; + for command in plugin_commands { + let cmd_name = command.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(); + plugin_warnings.push(format!("Plugin command {} ({}) would override a Composer command and has been skipped", cmd_name, cls)); + } else { + // Compatibility layer for symfony/console <7.4 + // TODO(phase-c): registering a plugin command needs the Symfony + // Application's typed add()/addCommand(); the external-package stub keeps + // its registry as PhpMixed/todo!() per the "Symfony stays todo!()" policy. + let _ = command; + } } + Ok(()) + })() { + Ok(_) => {} + Err(e) => { + if e.downcast_ref::().is_some() { + // suppress these as they are not relevant at this point + } else if let Some(pe) = e.downcast_ref::() { + let details = pe.get_details(); - let command_name = command_list[&name_or_alias] - .borrow() - .get_name() - .unwrap_or_default(); + let file = realpath(&Factory::get_composer_file().unwrap_or_default()); - aliases.insert(name_or_alias.clone(), command_name.clone()); + let line = details.line; - let keep = command_name == name_or_alias || !commands_clone.contains(&command_name); - if keep && seen.insert(name_or_alias.clone()) { - new_commands.push(name_or_alias); + let mut ghe = GithubActionError::new(io.clone()); + ghe.emit(&pe.message, file.as_deref(), line); + + return Err(e); + } else { + return Err(e); + } } } - commands = new_commands; + for warning in &plugin_warnings { + io.write_error(warning); + } - if commands.len() > 1 { - let usable_width = self.terminal.get_width() - 10; - let abbrevs: Vec = commands.clone(); - let mut max_len: i64 = 0; - for abbrev in &abbrevs { - max_len = std::cmp::max(Helper::width(abbrev), max_len); - } - let mut formatted_abbrevs: Vec = Vec::new(); - for cmd in commands.clone() { - if command_list[&cmd].borrow().is_hidden() { - // unset($commands[array_search($cmd, $commands)]) - if let Some(idx) = commands.iter().position(|c| *c == cmd) { - commands.remove(idx); - } - formatted_abbrevs.push(PhpMixed::Bool(false)); - continue; - } + application.borrow_mut().has_plugin_commands = true; + } - let abbrev = format!( - "{} {}", - shirabe_php_shim::str_pad( - &cmd, - max_len as usize, - " ", - shirabe_php_shim::STR_PAD_RIGHT - ), - command_list[&cmd].borrow().get_description() - ); - - if Helper::width(&abbrev) > usable_width { - formatted_abbrevs.push(PhpMixed::from(format!( - "{}...", - Helper::substr(&abbrev, 0, Some(usable_width - 3)) - ))); - } else { - formatted_abbrevs.push(PhpMixed::from(abbrev)); - } - } - - if commands.len() > 1 { - let filtered: Vec = formatted_abbrevs - .iter() - .filter(|a| shirabe_php_shim::php_truthy(a)) - .map(shirabe_php_shim::php_to_string) - .collect(); - let suggestions = self.get_abbreviation_suggestions(&filtered); + if !application.borrow().disable_plugins_by_default + && is_non_allowed_root + && !io.is_interactive() + { + io.write_error("Composer plugins have been disabled for safety in this non-interactive session."); + io.write_error("Set COMPOSER_ALLOW_SUPERUSER=1 if you want to allow plugins to run as root/super user."); + application.borrow_mut().disable_plugins_by_default = true; + } - return Err(CommandNotFoundException::new( - format!( - "SymfonyCommand \"{}\" is ambiguous.\nDid you mean one of these?\n{}.", - PhpMixed::from(name.to_string()), - PhpMixed::from(suggestions), - ), - commands.clone(), - 0, - ) - .into()); - } + // determine command name to be executed incl plugin commands, and check if it's a proxy command + let mut is_proxy_command = false; + let resolved_command_name = application + .borrow_mut() + .get_command_name_before_binding(input.clone())?; + if let Some(ref name) = resolved_command_name { + let find_result = application.borrow_mut().find(name); + if let Ok(command) = find_result { + command_name = command.borrow().get_name(); + // PHP: $command instanceof Command\BaseCommand && $command->isProxyCommand(). + // The Symfony `Command` trait exposes `is_proxy_command` (default false) so the + // `dyn SymfonyCommand` registry can answer this; Composer proxy commands override it. + is_proxy_command = command.borrow().is_proxy_command(); } } - // $command = $this->get(reset($commands)); - let command = self.get(&commands[0])?; - - if command.borrow().is_hidden() { - return Err(CommandNotFoundException::new( - format!( - "The command \"{}\" does not exist.", - PhpMixed::from(name.to_string()), + if !is_proxy_command { + io.write_error3( + &format!( + "Running {} ({}) with {} on {}", + composer::get_version(), + composer::RELEASE_DATE, + (if defined("HHVM_VERSION") { + format!("HHVM {}", shirabe_php_shim::HHVM_VERSION.unwrap_or("")) + } else { + format!("PHP {}", PHP_VERSION) + }), + (if function_exists("php_uname") { + format!("{} / {}", php_uname("s"), php_uname("r")) + } else { + "Unknown OS".to_string() + }), ), - Vec::new(), - 0, - ) - .into()); - } - - Ok(command) - } - - /// Gets the commands (registered in the given namespace if provided). - /// - /// The array keys are the full names and the values the command instances. - pub fn all( - &mut self, - namespace: Option<&str>, - ) -> anyhow::Result>>> { - self.init()?; + true, + io_interface::DEBUG, + ); - if namespace.is_none() { - if self.command_loader.is_none() { - return Ok(self.commands.clone()); + if PHP_VERSION_ID < 70205 { + io.write_error(&format!("Composer supports PHP 7.2.5 and above, you will most likely encounter problems with your PHP {}. Upgrading is strongly recommended but you can use Composer 2.2.x LTS as a fallback.", PHP_VERSION)); } - let mut commands = self.commands.clone(); - let names = self.command_loader.as_ref().unwrap().get_names(); - for name in names { - if !commands.contains_key(&name) && self.has(&name) { - commands.insert(name.clone(), self.get(&name)?); - } + if XdebugHandler::is_xdebug_active() + && Platform::get_env("COMPOSER_DISABLE_XDEBUG_WARN").is_none() + { + io.write_error("Composer is operating slower than normal because you have Xdebug enabled. See https://getcomposer.org/xdebug"); } - return Ok(commands); - } - - let namespace = namespace.unwrap(); - let mut commands: IndexMap>> = - IndexMap::new(); - let entries: Vec<(String, std::rc::Rc>)> = self - .commands - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - for (name, command) in entries { - if namespace - == self.extract_namespace( - &name, - Some(shirabe_php_shim::substr_count(namespace, ":") + 1), - ) + if defined("COMPOSER_DEV_WARNING_TIME") + && command_name.as_deref() != Some("self-update") + && command_name.as_deref() != Some("selfupdate") + && time() > shirabe_php_shim::composer_dev_warning_time() { - commands.insert(name, command); + io.write_error(&format!( + "Warning: This development build of Composer is over 60 days old. It is recommended to update it by running \"{} self-update\" to get the latest version.", + shirabe_php_shim::server_get("PHP_SELF").unwrap_or_default(), + )); } - } - if self.command_loader.is_some() { - let names = self.command_loader.as_ref().unwrap().get_names(); - for name in names { - if !commands.contains_key(&name) - && namespace - == self.extract_namespace( - &name, - Some(shirabe_php_shim::substr_count(namespace, ":") + 1), - ) - && self.has(&name) + if is_non_allowed_root + && command_name.as_deref() != Some("self-update") + && command_name.as_deref() != Some("selfupdate") + && command_name.as_deref() != Some("_complete") + { + io.write_error("Do not run Composer as root/super user! See https://getcomposer.org/root for details"); + + if io.is_interactive() + && !io.ask_confirmation( + "Continue as root/super user [yes]? " + .to_string(), + true, + ) { - commands.insert(name.clone(), self.get(&name)?); + return Ok(1); } } - } - - Ok(commands) - } - /// Returns an array of possible abbreviations given a set of names. - pub fn get_abbreviations(names: Vec) -> IndexMap> { - let mut abbrevs: IndexMap> = IndexMap::new(); - for name in names { - let mut len = shirabe_php_shim::strlen(&name); - while len > 0 { - let abbrev = shirabe_php_shim::substr(&name, 0, Some(len)); - abbrevs.entry(abbrev).or_default().push(name.clone()); - len -= 1; + // Check system temp folder for usability as it can cause weird runtime issues otherwise + let tempfile_msg: Option = Silencer::call(|| -> anyhow::Result> { + let pid = if function_exists("getmypid") { + format!("{}-", getmypid()) + } else { + String::new() + }; + let tempfile = format!( + "{}/temp-{}{}", + sys_get_temp_dir(), + pid, + bin2hex(&random_bytes(5)) + ); + if !(file_put_contents(&tempfile, file!().as_bytes()).is_some_and(|n| n > 0) + && file_get_contents(&tempfile).as_deref() == Some(file!()) + && unlink(&tempfile) + && !file_exists(&tempfile)) + { + return Ok(Some(format!("PHP temp directory ({}) does not exist or is not writable to Composer. Set sys_temp_dir in your php.ini", sys_get_temp_dir()))); + } + Ok(None) + }) + .ok() + .flatten(); + if let Some(msg) = tempfile_msg { + io.write_error(&msg); } - } - - abbrevs - } - pub fn render_throwable( - &self, - e: &anyhow::Error, - output: std::rc::Rc>, - ) { - output - .borrow() - .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); + // add non-standard scripts as own commands + let file = Factory::get_composer_file().unwrap_or_default(); + if may_need_script_command && is_file(&file) && Filesystem::is_readable(&file) { + let composer_json: PhpMixed = + json_decode(&file_get_contents(&file).unwrap_or_default(), true) + .unwrap_or(PhpMixed::Null); + if let Some(arr) = composer_json.as_array() + && let Some(scripts) = arr.get("scripts").and_then(|v| v.as_array()) + { + for (script, dummy) in scripts { + let script_event_const = format!( + "Composer\\Script\\ScriptEvents::{}", + str_replace("-", "_", &strtoupper(script)) + ); + if !defined(&script_event_const) { + if application.borrow_mut().has(script) { + io.write_error(&format!("A script named {} would override a Composer command and has been skipped", script)); + } else { + let mut description = format!( + "Runs the {} script as defined in composer.json", + script + ); - self.do_render_throwable(e, output.clone()); + if let Some(desc) = arr + .get("scripts-descriptions") + .and_then(|v| v.as_array()) + .and_then(|a| a.get(script)) + .and_then(|v| v.as_string()) + { + description = desc.to_string(); + } - if let Some(running_command) = &self.running_command { - // PHP: sprintf('%s', OutputFormatter::escape(sprintf($synopsis, $this->getName()))) - // A command synopsis carries no printf conversion specifier, so the inner sprintf is an - // identity over the synopsis and the application-name argument is never substituted. - let synopsis = running_command.borrow_mut().get_synopsis(false); - output.borrow().writeln( - &[format!( - "{}", - OutputFormatter::escape(&synopsis).unwrap(), - )], - output_interface::VERBOSITY_QUIET, - ); - output - .borrow() - .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); - } - } - - pub fn do_render_throwable( - &self, - e: &anyhow::Error, - output: std::rc::Rc>, - ) { - // do { ... } while ($e = $e->getPrevious()); - // PHP walks getPrevious(); anyhow models the exception chain as the source chain, which - // `anyhow::Error::chain()` yields head-first. - for e in e.chain() { - let message = shirabe_php_shim::trim(&e.to_string(), None); - let verbosity = output.borrow().get_verbosity(); - - let mut len; - let mut title = String::new(); - if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { - let class = throwable_debug_type(e); - let code = throwable_get_code(e); - title = format!( - " [{}{}] ", - class, - if code != 0 { - format!(" ({})", code) - } else { - String::new() - } - ); - len = Helper::width(&title); - } else { - len = 0; - } - - // PHP rewrites `@anonymous\0` markers via class_exists/get_parent_class/class_implements. - // Rust error messages never carry PHP's anonymous-class marker and those reflection - // primitives have no Rust equivalent, so the branch is unreachable here. - // TODO(review): port the @anonymous rewrite if it ever becomes relevant. - - let width = if self.terminal.get_width() != 0 { - self.terminal.get_width() - 1 - } else { - i64::MAX - }; - let mut lines: Vec<(String, i64)> = Vec::new(); - let split = if !message.is_empty() { - shirabe_php_shim::preg_split(r"/\r?\n/", &message) - } else { - Vec::new() - }; - for line in split { - for line in self.split_string_by_width(&line, width - 4) { - // pre-format lines to get the right string length - let line_length = Helper::width(&line) + 4; - lines.push((line, line_length)); - len = std::cmp::max(line_length, len); - } - } - - let mut messages: Vec = Vec::new(); - if !throwable_is_exception_interface(e) - || output_interface::VERBOSITY_VERBOSE <= verbosity - { - // TODO(review): anyhow::Error carries no PHP file/line, so getFile()/getLine() take - // the 'n/a' fallback PHP itself uses when they are unavailable. The real source - // location cannot be reproduced (it would be a Rust path, not Composer's PHP path). - messages.push(format!( - "{}", - OutputFormatter::escape(&format!("In {} line {}:", "n/a", "n/a")).unwrap() - )); - } - let empty_line = format!( - "{}", - shirabe_php_shim::str_repeat(" ", len as usize) - ); - messages.push(empty_line.clone()); - if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { - messages.push(format!( - "{}{}", - title, - shirabe_php_shim::str_repeat( - " ", - (len - Helper::width(&title)).max(0) as usize - ) - )); - } - for (line, line_length) in &lines { - messages.push(format!( - " {} {}", - OutputFormatter::escape(line).unwrap(), - shirabe_php_shim::str_repeat(" ", (len - line_length) as usize) - )); - } - messages.push(empty_line); - messages.push(String::new()); - - output - .borrow() - .writeln(&messages, output_interface::VERBOSITY_QUIET); - - // PHP renders the `Exception trace:` block (getTrace()) at -v or higher. A PHP backtrace - // has no faithful Rust equivalent, so this block is intentionally never ported; verbose - // output simply omits the trace. - } - } - - /// Configures the input and output instances based on the user arguments and options. - pub fn configure_io( - &self, - input: &std::rc::Rc>, - output: &std::rc::Rc>, - ) -> anyhow::Result<()> { - if input.borrow().has_parameter_option( - PhpMixed::from(vec![PhpMixed::from("--ansi".to_string())]), - true, - ) { - output.borrow().set_decorated(true); - } else if input.borrow().has_parameter_option( - PhpMixed::from(vec![PhpMixed::from("--no-ansi".to_string())]), - true, - ) { - output.borrow().set_decorated(false); - } - - if input.borrow().has_parameter_option( - PhpMixed::from(vec![ - PhpMixed::from("--no-interaction".to_string()), - PhpMixed::from("-n".to_string()), - ]), - true, - ) { - input.borrow_mut().set_interactive(false); - } - - let mut shell_verbosity = shirabe_php_shim::getenv("SHELL_VERBOSITY").unwrap_or_default(); - let shell_verbosity_int: i64 = shell_verbosity.parse().unwrap_or(0); - let mut shell_verbosity: i64 = shell_verbosity_int; - match shell_verbosity_int { - -1 => { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_QUIET); - } - 1 => { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_VERBOSE); - } - 2 => { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); - } - 3 => { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_DEBUG); - } - _ => { - shell_verbosity = 0; - } - } + let aliases: Vec = arr + .get("scripts-aliases") + .and_then(|v| v.as_array()) + .and_then(|a| a.get(script)) + .and_then(|v| v.as_list()) + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); - if input.borrow().has_parameter_option( - PhpMixed::from(vec![ - PhpMixed::from("--quiet".to_string()), - PhpMixed::from("-q".to_string()), - ]), - true, - ) { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_QUIET); - shell_verbosity = -1; - } else if input - .borrow() - .has_parameter_option(PhpMixed::from("-vvv".to_string()), true) - || input - .borrow() - .has_parameter_option(PhpMixed::from("--verbose=3".to_string()), true) - || input.borrow().get_parameter_option( - PhpMixed::from("--verbose".to_string()), - PhpMixed::Bool(false), - true, - ) == PhpMixed::from(3i64) - { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_DEBUG); - shell_verbosity = 3; - } else if input - .borrow() - .has_parameter_option(PhpMixed::from("-vv".to_string()), true) - || input - .borrow() - .has_parameter_option(PhpMixed::from("--verbose=2".to_string()), true) - || input.borrow().get_parameter_option( - PhpMixed::from("--verbose".to_string()), - PhpMixed::Bool(false), - true, - ) == PhpMixed::from(2i64) - { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); - shell_verbosity = 2; - } else if input - .borrow() - .has_parameter_option(PhpMixed::from("-v".to_string()), true) - || input - .borrow() - .has_parameter_option(PhpMixed::from("--verbose=1".to_string()), true) - || input - .borrow() - .has_parameter_option(PhpMixed::from("--verbose".to_string()), true) - || shirabe_php_shim::php_truthy(&input.borrow().get_parameter_option( - PhpMixed::from("--verbose".to_string()), - PhpMixed::Bool(false), - true, - )) - { - output - .borrow() - .set_verbosity(output_interface::VERBOSITY_VERBOSE); - shell_verbosity = 1; - } + let composer_opt = + application.borrow_mut().get_composer(false, None, None)?; + if let Some(composer) = composer_opt { + let composer = crate::command::composer_full(&composer); + let root_package = composer.get_package(); + let generator = composer.get_autoload_generator().clone(); + let generator = generator.borrow(); - if shell_verbosity == -1 { - input.borrow_mut().set_interactive(false); - } + let installation_manager = composer.get_installation_manager(); + let package_map = generator.build_package_map( + &mut installation_manager.borrow_mut(), + root_package.clone(), + vec![], + )?; + let map = generator.parse_autoloads( + package_map, + root_package.clone(), + PhpMixed::Bool(false), + ); - if shirabe_php_shim::function_exists("putenv") { - shirabe_php_shim::putenv(&format!("SHELL_VERBOSITY={}", shell_verbosity)); - } - shirabe_php_shim::env_set("SHELL_VERBOSITY", shell_verbosity.to_string()); - shirabe_php_shim::server_set("SHELL_VERBOSITY", shell_verbosity.to_string()); + let loader = generator.create_loader( + &map, + composer + .get_config() + .borrow() + .get("vendor-dir") + .as_string() + .map(|s| s.to_string()), + ); + loader.register(false); + } - let _ = &mut shell_verbosity; + // if the command is not an array of commands, and points to a valid SymfonyCommand subclass, import its details directly + let dummy_str = dummy.as_string().unwrap_or("").to_string(); + let cmd: PhpMixed = if is_string(dummy) + && shirabe_php_shim::class_exists(&dummy_str) + && is_subclass_of( + &PhpMixed::String(dummy_str.clone()), + "Symfony\\Component\\Console\\Command\\Command", + true, + ) { + if is_subclass_of( + &PhpMixed::String(dummy_str.clone()), + "Symfony\\Component\\Console\\SingleCommandApplication", + true, + ) { + io.write_error(&format!("The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.", script)); + } + let mut cmd = shirabe_php_shim::instantiate_class( + &dummy_str, + vec![PhpMixed::String(script.clone())], + ); + // TODO(phase-c): the script's command class is built by + // reflection (instantiate_class) and stays PhpMixed; the + // SingleCommandApplication / SymfonyCommand typed registry it + // belongs to is an external-package todo!() stub. + // let _ = SingleCommandApplication::new; - Ok(()) - } + // makes sure the command is find()'able by the name defined in composer.json, and the name isn't overridden in its configure() + // TODO(phase-c): cmd is the PhpMixed result of reflection + // instantiation; reading/overriding its + // name/description requires the typed SymfonyCommand model that + // the Symfony stub does not yet provide. + let _ = description.clone(); + let _ = &mut cmd; + cmd + } else { + // fallback to usual aliasing behavior + // TODO(phase-c): ScriptAliasCommand is a typed BaseCommand + // but this code path stores commands as PhpMixed; it can + // only be carried as a typed trait object once the Symfony + // command registry is modelled. + let _ = ScriptAliasCommand::new( + script.clone(), + Some(description.clone()), + aliases, + ); + PhpMixed::Null + }; - /// Runs the current command. - /// - /// If an event dispatcher has been attached to the application, events are also - /// dispatched during the life-cycle of the command. - /// - /// Returns 0 if everything went fine, or an error code. - pub fn do_run_command( - application: &std::rc::Rc>, - command: std::rc::Rc>, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - if let Some(helper_set) = command.borrow().get_helper_set() { - for (_alias, helper) in helper_set.borrow().get_iterator() { - // if ($helper instanceof InputAwareInterface) $helper->setInput($input); - // TODO(review): downcasting a HelperInterface to InputAwareInterface is not - // expressible without a typed mechanism; needs design. - let _ = helper; - let _ = std::marker::PhantomData::; + // Compatibility layer for symfony/console <7.4 + // TODO(phase-c): Application::add() takes Rc> + // but `cmd` here is the PhpMixed result of reflection-based + // plugin command instantiation; registering it as a typed + // command instance is blocked on the Symfony command-registry + // model (external-package todo!() stub). + let _ = &cmd; + todo!( + "plugin: register reflection-instantiated command on Application::add" + ); + } + } + } + } } } - if !application.borrow().signals_to_dispatch_event.is_empty() { - // $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [] - // TODO(review): SymfonyCommand is not a SignalableCommandInterface here; downcast needed. - let command_signals: Vec = Vec::new(); - let _ = std::marker::PhantomData::; + let mut start_time: Option = None; + let result_outcome: anyhow::Result = (|| -> anyhow::Result { + if input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--profile"]), false) + { + start_time = Some(microtime()); + // PHP: $this->io->enableDebugging($startTime). + // TODO(phase-c): enableDebugging exists only on ConsoleIO, not on IOInterface, + // and self.io is still the NullIO because the ConsoleIO construction above is + // deferred (Symfony HelperSet/Helper modelling). Once self.io is the real + // ConsoleIO this becomes a concrete-type call on it. + let _ = start_time.unwrap(); + } - if !command_signals.is_empty() { - if application.borrow().signal_registry.is_none() { - return Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { - message: "Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), - code: 0, - }) - .into()); - } + let result = self.base_do_run(input.clone(), output.clone())?; - if Terminal::has_stty_available() { - // TODO: registers SIGINT/SIGTERM handlers that restore the stty mode via - // shell_exec('stty ...'). pcntl signal handlers have no faithful Rust - // equivalent in Phase A. - let _stty_mode = shirabe_php_shim::shell_exec("stty -g"); - for _signal in [shirabe_php_shim::SIGINT, shirabe_php_shim::SIGTERM] { - todo!("register signal handler to restore stty mode"); - } - } + if input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), true) + { + io.write_error(&format!( + "PHP version {} ({})", + PHP_VERSION, PHP_BINARY, + )); + io.write_error( + "Run the \"diagnose\" command to get more detailed diagnostics output.", + ); } - for _signal in command_signals { - // $this->signalRegistry->register($signal, [$command, 'handleSignal']); - todo!("register command->handle_signal as signal handler"); + // chdir back to oldWorkingDir if set + if let Some(ref owd) = old_working_dir + && !owd.is_empty() + { + let owd = owd.clone(); + let _ = Silencer::call(|| { + chdir(&owd); + Ok(()) + }); } - } - command - .borrow_mut() - .run(input.clone(), output.clone()) - .map(|c| c as i32) - } + if let Some(st) = start_time { + io.write_error(&format!( + "Memory usage: {}MiB (peak: {}MiB), time: {}s", + round((memory_get_usage() as f64) / 1024.0 / 1024.0, 2), + round((memory_get_peak_usage(false) as f64) / 1024.0 / 1024.0, 2), + round(microtime() - st, 2) + )); + } - /// Gets the name of the command based on input. - pub fn get_command_name(&self, input: &dyn InputInterface) -> Option { - if self.single_command { - Some(self.default_command.clone()) - } else { - input.get_first_argument() - } - } + Ok(result) + })(); - /// Gets the default input definition (Symfony base; `parent::getDefaultInputDefinition`). - pub fn base_get_default_input_definition(&self) -> InputDefinition { - use shirabe_external_packages::symfony::console::input::input_definition::DefinitionItem; - InputDefinition::new(vec![ - DefinitionItem::InputArgument( - InputArgument::new( - "command".to_string(), - Some(InputArgument::REQUIRED), - "The command to execute".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--help", - PhpMixed::from("-h".to_string()), - Some(InputOption::VALUE_NONE), - format!( - "Display help for the given command. When no command is given display help for the {} command", - self.default_command - ), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--quiet", - PhpMixed::from("-q".to_string()), - Some(InputOption::VALUE_NONE), - "Do not output any message".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--verbose", - PhpMixed::from("-v|vv|vvv".to_string()), - Some(InputOption::VALUE_NONE), - "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--version", - PhpMixed::from("-V".to_string()), - Some(InputOption::VALUE_NONE), - "Display this application version".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--ansi", - PhpMixed::from("".to_string()), - Some(InputOption::VALUE_NEGATABLE), - "Force (or disable --no-ansi) ANSI output".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - DefinitionItem::InputOption( - InputOption::new( - "--no-interaction", - PhpMixed::from("-n".to_string()), - Some(InputOption::VALUE_NONE), - "Do not ask any interactive question".to_string(), - PhpMixed::Null, - ) - .unwrap(), - ), - ]) - .unwrap() + let outcome = match result_outcome { + Ok(r) => Ok(r), + Err(e) => { + // PHP's `exit` bypasses parent::doRun()'s catch entirely; re-raise it untouched so + // the GitHub Actions annotation and error hints below are skipped. + if e.downcast_ref::() + .is_some() + { + return Err(e); + } + if let Some(see) = e.downcast_ref::() { + if application.borrow().get_disable_plugins_by_default() + && application.borrow().is_running_as_root() + && !io.is_interactive() + { + io.write_error3("Plugins have been disabled automatically as you are running as root, this may be the cause of the script failure.", true, io_interface::QUIET); + io.write_error3( + "See also https://getcomposer.org/root", + true, + io_interface::QUIET, + ); + } + + Ok(see.get_code() as i32) + } else { + let mut ghe = GithubActionError::new(io.clone()); + ghe.emit(&e.to_string(), None, None); + + application.borrow_mut().hint_common_errors(&e, output); + + // override TransportException's code for the purpose of parent::run() using it as process exit code + // as http error codes are all beyond the 255 range of permitted exit codes + if e.downcast_ref::().is_some() { + // PHP: ReflectionProperty $reflProp = new \ReflectionProperty($e, 'code'); + // $reflProp->setValue($e, Installer::ERROR_TRANSPORT_EXCEPTION); + // TODO: reflection-based mutation of the existing exception is not portable; + // we surface the rewritten code via a fresh TransportException at the call site. + let _ = Installer::ERROR_TRANSPORT_EXCEPTION; + } + + Err(e) + } + } + }; + + restore_error_handler(); + + outcome } +} - /// Gets the default commands that should always be available (Symfony base; - /// `parent::getDefaultCommands`). - pub fn base_get_default_commands( +impl ApplicationHandle { + /// Runs the current application (Symfony base; `parent::run`). + pub fn base_run( &self, - ) -> Vec>> { - use shirabe_external_packages::symfony::console::command::complete_command::CompleteCommand; - use shirabe_external_packages::symfony::console::command::dump_completion_command::DumpCompletionCommand; - use shirabe_external_packages::symfony::console::command::help_command::HelpCommand; - use shirabe_external_packages::symfony::console::command::list_command::ListCommand; + input: Option>>, + output: Option>>, + ) -> anyhow::Result { + let application = &self.0; + if shirabe_php_shim::function_exists("putenv") { + let (height, width) = { + let app = application.borrow(); + (app.terminal.get_height(), app.terminal.get_width()) + }; + shirabe_php_shim::putenv(&format!("LINES={}", height)); + shirabe_php_shim::putenv(&format!("COLUMNS={}", width)); + } - vec![ - std::rc::Rc::new(std::cell::RefCell::new(HelpCommand::new())) - as std::rc::Rc>, - std::rc::Rc::new(std::cell::RefCell::new(ListCommand::new())), - std::rc::Rc::new(std::cell::RefCell::new( - CompleteCommand::new(IndexMap::new()) - .expect("CompleteCommand with default outputs is valid"), - )), - std::rc::Rc::new(std::cell::RefCell::new(DumpCompletionCommand::new())), - ] - } + let input: std::rc::Rc> = match input { + None => std::rc::Rc::new(std::cell::RefCell::new(ArgvInput::new(None, None)?)), + Some(input) => input, + }; - /// Gets the default helper set with the helpers that should always be available. - pub fn get_default_helper_set(&self) -> std::rc::Rc> { - let helper_set = std::rc::Rc::new(std::cell::RefCell::new(HelperSet::default())); - let helpers: IndexMap>> = { - let mut m: IndexMap< - HelperSetKey, - std::rc::Rc>, - > = IndexMap::new(); - m.insert( - HelperSetKey::Int(0), - std::rc::Rc::new(std::cell::RefCell::new(FormatterHelper::default())), - ); - m.insert( - HelperSetKey::Int(1), - std::rc::Rc::new(std::cell::RefCell::new(DebugFormatterHelper::default())), - ); - m.insert( - HelperSetKey::Int(2), - std::rc::Rc::new(std::cell::RefCell::new(ProcessHelper::default())), - ); - m.insert( - HelperSetKey::Int(3), - std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default())), - ); - m + let output: std::rc::Rc> = match output { + None => std::rc::Rc::new(std::cell::RefCell::new(ConsoleOutput::new( + None, None, None, + )?)), + Some(output) => output, }; - HelperSet::new(&helper_set, helpers); - helper_set - } - /// Returns abbreviated suggestions in string format. - fn get_abbreviation_suggestions(&self, abbrevs: &[String]) -> String { - format!(" {}", shirabe_php_shim::implode("\n ", abbrevs)) - } + // TODO: PHP installs a temporary `set_exception_handler($renderException)` and cooperates + // with Symfony's ErrorHandler to keep/restore it. PHP's process-global exception handler + // stack has no Rust equivalent; the rendering itself is invoked directly in the catch + // branch below. Review needed for the handler save/restore dance. + let render_exception = + |this: &Application, + e: &anyhow::Error, + output: &std::rc::Rc>| { + // if ($output instanceof ConsoleOutputInterface) render to its error output + // TODO(review): downcasting a `dyn OutputInterface` to `ConsoleOutputInterface` + // is not directly expressible; the ConsoleOutputInterface branch needs design. + this.render_throwable(e, output.clone()); + }; - /// Returns the namespace part of the command name. - /// - /// This method is not part of public API and should not be used directly. - pub fn extract_namespace(&self, name: &str, limit: Option) -> String { - // $parts = explode(':', $name, -1); - let parts = shirabe_php_shim::explode_limit(":", name, -1); + let result = (|| -> anyhow::Result { + application.borrow_mut().configure_io(&input, &output)?; - // implode(':', null === $limit ? $parts : array_slice($parts, 0, $limit)) - match limit { - None => shirabe_php_shim::implode(":", &parts), - Some(limit) => { - let sliced: Vec = parts.into_iter().take(limit.max(0) as usize).collect(); - shirabe_php_shim::implode(":", &sliced) + let exit_code = self.do_run(input.clone(), output.clone())?; + + Ok(exit_code) + })(); + + let exit_code = match result { + Ok(exit_code) => exit_code, + Err(e) => { + // PHP's `exit` bypasses Symfony's try/catch: it never renders and forces the exit + // code it carries. The message, if any, has already been written at the exit site. + if let Some(exit) = e.downcast_ref::() { + return Ok(exit.code as i32); + } + + if !application.borrow().catch_exceptions { + return Err(e); + } + + render_exception(&application.borrow(), &e, &output); + + // $exitCode = $e->getCode(); + // is_numeric($exitCode) ? max(1, (int) $exitCode) : 1 + // TODO(review): anyhow::Error has no PHP-style getCode(); the exit code derived + // from the exception's `code` field needs the downcast strategy decided. + let exit_code = shirabe_php_shim::php_exception_get_code(&e); + if shirabe_php_shim::is_numeric_string(&exit_code.to_string()) { + if exit_code <= 0 { 1 } else { exit_code } + } else { + 1 + } } - } + }; + + // finally: handler restore. See TODO above; no-op here. + + Ok(exit_code) } - /// Finds alternative of $name among $collection, if nothing is found in - /// $collection, try in $abbrevs. - fn find_alternatives(&self, name: &str, collection: &[String]) -> Vec { - let threshold = 1e3; - let mut alternatives: IndexMap = IndexMap::new(); + /// Runs the current application (Symfony base; `parent::doRun`). + pub fn base_do_run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let application = &self.0; + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--version".to_string()), + PhpMixed::from("-V".to_string()), + ]), + true, + ) { + let long_version = application.borrow().get_long_version(); + output + .borrow() + .writeln(&[long_version], output_interface::OUTPUT_NORMAL); - let mut collection_parts: IndexMap> = IndexMap::new(); - for item in collection { - collection_parts.insert(item.clone(), shirabe_php_shim::explode(":", item)); + return Ok(0); } - for (i, subname) in shirabe_php_shim::explode(":", name).into_iter().enumerate() { - for (collection_name, parts) in &collection_parts { - let exists = alternatives.contains_key(collection_name); - if parts.get(i).is_none() && exists { - *alternatives.get_mut(collection_name).unwrap() += threshold; - continue; - } else if parts.get(i).is_none() { - continue; - } - - let lev = shirabe_php_shim::levenshtein(&subname, &parts[i]) as f64; - if lev <= shirabe_php_shim::strlen(&subname) as f64 / 3.0 - || (!subname.is_empty() && parts[i].contains(&subname)) - { - let v = if exists { - alternatives[collection_name] + lev - } else { - lev - }; - alternatives.insert(collection_name.clone(), v); - } else if exists { - *alternatives.get_mut(collection_name).unwrap() += threshold; + // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. + let definition = application.borrow_mut().get_definition(); + match input.borrow_mut().bind(&definition.borrow()) { + Ok(()) => {} + Err(e) => { + // Errors must be ignored, full binding/validation happens later when the command is known. + if !is_exception_interface(&e) { + return Err(e); } } } - for item in collection { - let lev = shirabe_php_shim::levenshtein(name, item) as f64; - if lev <= shirabe_php_shim::strlen(name) as f64 / 3.0 || item.contains(name) { - let v = if alternatives.contains_key(item) { - alternatives[item] - lev - } else { - lev - }; - alternatives.insert(item.clone(), v); + let mut input = input; + let mut name = application.borrow().get_command_name(&*input.borrow()); + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--help".to_string()), + PhpMixed::from("-h".to_string()), + ]), + true, + ) { + if name.is_none() { + name = Some("help".to_string()); + let default_command = application.borrow().default_command.clone(); + input = std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new( + vec![( + PhpMixed::from("command_name".to_string()), + PhpMixed::from(default_command), + )], + None, + )?)); + } else { + application.borrow_mut().want_helps = true; } } - // array_filter($alternatives, fn($lev) => $lev < 2 * $threshold) - alternatives.retain(|_, lev| *lev < 2.0 * threshold); - // ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE) - let mut keys: Vec = alternatives.keys().cloned().collect(); - shirabe_php_shim::sort_natural_flag_case(&mut keys); - - keys - } - - /// Sets the default SymfonyCommand name. - pub fn set_default_command( - &mut self, - command_name: &str, - is_single_command: bool, - ) -> anyhow::Result<&mut Self> { - // $this->defaultCommand = explode('|', ltrim($commandName, '|'))[0]; - let trimmed = shirabe_php_shim::ltrim(command_name, Some("|")); - self.default_command = shirabe_php_shim::explode("|", &trimmed) - .into_iter() - .next() - .unwrap_or_default(); - - if is_single_command { - // Ensure the command exist - self.find(command_name)?; + let name = match name { + Some(name) => name, + None => { + let name = application.borrow().default_command.clone(); + let definition = application.borrow_mut().get_definition(); + let command_description = definition + .borrow() + .get_argument(&PhpMixed::from("command".to_string()))? + .get_description() + .to_string(); + let new_command_argument = InputArgument::new( + "command".to_string(), + Some(InputArgument::OPTIONAL), + command_description, + PhpMixed::from(name.clone()), + )?; + // $definition->setArguments(array_merge($definition->getArguments(), + // ['command' => new InputArgument('command', InputArgument::OPTIONAL, ...)])) + let mut merged_arguments: Vec = Vec::new(); + let mut replaced_command = false; + for (key, argument) in definition.borrow().get_arguments() { + if key == "command" { + merged_arguments.push(new_command_argument.clone()); + replaced_command = true; + } else { + merged_arguments.push((**argument).clone()); + } + } + if !replaced_command { + merged_arguments.push(new_command_argument); + } + definition.borrow_mut().set_arguments(merged_arguments)?; - self.single_command = true; - } + name + } + }; - Ok(self) - } + let command: std::rc::Rc>; + application.borrow_mut().running_command = None; + // the command name MUST be the first element of the input + let find_result = application.borrow_mut().find(&name); - pub fn is_single_command(&self) -> bool { - self.single_command - } + match find_result { + Ok(c) => { + command = c; + } + Err(e) => { + // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) + // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) + let alternatives: Option> = downcast_command_not_found(&e) + .filter(|_| !is_namespace_not_found(&e)) + .map(|cnf| cnf.get_alternatives().clone()); - fn split_string_by_width(&self, string: &str, width: i64) -> Vec { - // str_split is not suitable for multi-byte characters, we should use preg_split to get char array properly. - let encoding = match shirabe_php_shim::mb_detect_encoding(string, None, true) { - None => return shirabe_php_shim::str_split(string, width), - Some(encoding) => encoding, - }; + let single_alternative = match &alternatives { + Some(alts) if alts.len() == 1 => Some(alts[0].clone()), + _ => None, + }; - let utf8_string = shirabe_php_shim::mb_convert_encoding(string.into(), "utf8", &encoding); - let mut lines: Vec = Vec::new(); - let mut line = String::new(); + if single_alternative.is_none() || !input.borrow().is_interactive() { + return Err(e); + } - let mut offset = 0i64; - let mut m: indexmap::IndexMap> = - indexmap::IndexMap::new(); - while shirabe_php_shim::preg_match2( - r"/.{1,10000}/u", - &utf8_string, - &mut m, - 0, - offset as usize, - ) { - let m0 = m[&shirabe_php_shim::CaptureKey::ByIndex(0)] - .as_deref() - .unwrap_or(""); - offset += shirabe_php_shim::strlen(m0); + let alternative = single_alternative.unwrap(); - let chunk = m0; - for char in chunk - .char_indices() - .map(|(i, c)| &chunk[i..i + c.len_utf8()]) - { - // test if $char could be appended to current line - if shirabe_php_shim::mb_strwidth(&format!("{}{}", line, char), Some("utf8")) - <= width - { - line.push_str(char); - continue; + let mut style = SymfonyStyle::new(input.clone(), output.clone()); + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + let formatted_block = FormatterHelper::default().format_block( + FormatBlockMessages::String(format!( + "Command \"{}\" is not defined.", + PhpMixed::from(name.clone()), + )), + "error", + true, + ); + output + .borrow() + .writeln(&[formatted_block], output_interface::OUTPUT_NORMAL); + if !style.confirm( + &format!( + "Do you want to run \"{}\" instead? ", + PhpMixed::from(alternative.clone()), + ), + false, + ) { + return Ok(1); } - // if not, push current line to array and make new line - lines.push(shirabe_php_shim::str_pad( - &line, - width as usize, - " ", - shirabe_php_shim::STR_PAD_RIGHT, - )); - line = char.to_string(); + + command = application.borrow_mut().find(&alternative)?; } } - lines.push(if !lines.is_empty() { - shirabe_php_shim::str_pad(&line, width as usize, " ", shirabe_php_shim::STR_PAD_RIGHT) - } else { - line.clone() - }); + // LazyCommand is intentionally not ported. - shirabe_php_shim::mb_convert_variables(&encoding, "utf8", &mut lines); + application.borrow_mut().running_command = Some(command.clone()); + // do_run_command invokes the command's run(), which calls back into the application + // (mergeApplicationDefinition etc.); it must run with no application borrow held. + let exit_code = self.do_run_command(command.clone(), input.clone(), output.clone())?; + application.borrow_mut().running_command = None; - lines + Ok(exit_code) } - /// Returns all namespaces of the command name. - fn extract_all_namespaces(&self, name: &str) -> Vec { - // -1 as third argument is needed to skip the command short name when exploding - let parts = shirabe_php_shim::explode_limit(":", name, -1); - let mut namespaces: Vec = Vec::new(); + /// Adds an array of command objects. + /// + /// If a SymfonyCommand is not enabled it will not be added. + pub fn add_commands( + &self, + commands: Vec>>, + ) -> anyhow::Result<()> { + for command in commands { + self.add(command)?; + } + Ok(()) + } - for part in parts { - if !namespaces.is_empty() { - let last = namespaces.last().unwrap().clone(); - namespaces.push(format!("{}:{}", last, part)); - } else { - namespaces.push(part); + /// Runs the current command. + /// + /// If an event dispatcher has been attached to the application, events are also + /// dispatched during the life-cycle of the command. + /// + /// Returns 0 if everything went fine, or an error code. + pub fn do_run_command( + &self, + command: std::rc::Rc>, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let application = &self.0; + if let Some(helper_set) = command.borrow().get_helper_set() { + for (_alias, helper) in helper_set.borrow().get_iterator() { + // if ($helper instanceof InputAwareInterface) $helper->setInput($input); + // TODO(review): downcasting a HelperInterface to InputAwareInterface is not + // expressible without a typed mechanism; needs design. + let _ = helper; + let _ = std::marker::PhantomData::; } } - namespaces - } + if !application.borrow().signals_to_dispatch_event.is_empty() { + // $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [] + // TODO(review): SymfonyCommand is not a SignalableCommandInterface here; downcast needed. + let command_signals: Vec = Vec::new(); + let _ = std::marker::PhantomData::; - fn init(&mut self) -> anyhow::Result<()> { - if self.initialized { - return Ok(()); - } - self.initialized = true; + if !command_signals.is_empty() { + if application.borrow().signal_registry.is_none() { + return Err(ConsoleRuntimeException(shirabe_php_shim::RuntimeException { + message: "Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), + code: 0, + }) + .into()); + } - for command in self.get_default_commands() { - self.add(command)?; + if Terminal::has_stty_available() { + // TODO: registers SIGINT/SIGTERM handlers that restore the stty mode via + // shell_exec('stty ...'). pcntl signal handlers have no faithful Rust + // equivalent in Phase A. + let _stty_mode = shirabe_php_shim::shell_exec("stty -g"); + for _signal in [shirabe_php_shim::SIGINT, shirabe_php_shim::SIGTERM] { + todo!("register signal handler to restore stty mode"); + } + } + } + + for _signal in command_signals { + // $this->signalRegistry->register($signal, [$command, 'handleSignal']); + todo!("register command->handle_signal as signal handler"); + } } - Ok(()) + command + .borrow_mut() + .run(input.clone(), output.clone()) + .map(|c| c as i32) } } -- cgit v1.3.1