diff options
| -rw-r--r-- | crates/shirabe/src/command/outdated_command.rs | 5 | ||||
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 2278 | ||||
| -rw-r--r-- | crates/shirabe/src/lib.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe/tests/application_test.rs | 28 |
4 files changed, 1120 insertions, 1197 deletions
diff --git a/crates/shirabe/src/command/outdated_command.rs b/crates/shirabe/src/command/outdated_command.rs index 16a4ac5..3281723 100644 --- a/crates/shirabe/src/command/outdated_command.rs +++ b/crates/shirabe/src/command/outdated_command.rs @@ -227,10 +227,7 @@ impl Command for OutdatedCommand { .shared() }; - Ok( - crate::console::application::Application::run(&application, Some(input), Some(output))? - as i64, - ) + Ok(application.run(Some(input), Some(output))? as i64) } fn initialize( 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<SignalRegistry>, signals_to_dispatch_event: Vec<i64>, - + // $initialized is omitted. See ApplicationHandle::init(). pub(crate) composer: Option<PartialComposerHandle>, pub(crate) io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, 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,783 +232,14 @@ impl Application { this } - /// Builds the application inside a shared `Rc<RefCell<…>>` 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<std::rc::Rc<std::cell::RefCell<Application>>> { - 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<std::cell::RefCell<Application>> { - self.me - .upgrade() - .expect("Application must be constructed through new_shared") - } - - /// 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<std::cell::RefCell<Application>>, - ) -> anyhow::Result<()> { - { - 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<std::cell::RefCell<Application>>, - command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>, - ) -> anyhow::Result<Option<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> { - command.borrow_mut().set_application(Some( - application.clone() as std::rc::Rc<std::cell::RefCell<dyn BaseApplication>> - )); - - 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 { - message: format!( - "The command defined in \"{}\" cannot have an empty name.", - PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command)), - ), - 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<std::cell::RefCell<Application>>, - input: Option<std::rc::Rc<std::cell::RefCell<dyn InputInterface>>>, - output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, - ) -> anyhow::Result<i32> { - 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<std::cell::RefCell<dyn OutputInterface>>, - ), - }; - - Application::base_run(application, input, output) - } - - pub fn do_run( - application: &std::rc::Rc<std::cell::RefCell<Application>>, - input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - ) -> anyhow::Result<i32> { - 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))) - { - input.borrow_mut().set_interactive(false); - } - - let mut helpers: IndexMap< - HelperSetKey, - std::rc::Rc<std::cell::RefCell<dyn HelperInterface>>, - > = 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(); - - // Register error handler again to pass it the IO instance - ErrorHandler::register(Some(io.clone())); - - 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" - }, - ); - } - - // switch working dir - let new_work_dir = application.borrow().get_new_working_dir(input.clone())?; - let mut old_working_dir: Option<String> = 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, - ); - } - - // determine command name to be executed without including plugin commands - let mut command_name: Option<String> = 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::<CommandNotFoundException>().is_some() { - // we'll check command validity again later after plugins are loaded - command_name = None; - } - // PHP also catches \InvalidArgumentException here without action - } - } - } - - // 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(); - - // 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!("<info>No composer.json in current directory, to use the one at {} run interactively or set config.use-parent-dir to true</info>", dir)); - break; - } - if use_parent_dir_if_no_json_available.as_bool() == Some(true) - || io.ask_confirmation(format!("<info>No composer.json in current directory, do you want to use the one at {}?</info> [<comment>y,n</comment>]? ", dir), true) - { - if use_parent_dir_if_no_json_available.as_bool() == Some(true) { - io.write_error(&format!("<info>No composer.json in current directory, changing working directory to {}</info>", dir)); - } else { - io.write_error("<info>Always want to use the parent dir? Use \"composer config --global use-parent-dir true\" to change the default.</info>"); - } - 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; - - // 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(); - - 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(()) - }); - } - } - - // 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; - - if may_need_plugin_command - && !application.borrow().disable_plugins_by_default - && !application.borrow().has_plugin_commands - { - // 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("<warning>Do not run Composer as root/super user! See https://getcomposer.org/root for details</warning>"); - - if io.is_interactive() - && io.ask_confirmation( - "<info>Continue as root/super user</info> [<comment>yes</comment>]? " - .to_string(), - true, - ) - { - // avoid a second prompt later - is_non_allowed_root = false; - } else { - io.write_error("<warning>Aborting as no plugin should be loaded if running as super user is not explicitly allowed</warning>"); - - return Ok(1); - } - } - - // 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<String> = 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!("<warning>Plugin command {} ({}) would override a Composer command and has been skipped</warning>", 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::<NoSslException>().is_some() { - // suppress these as they are not relevant at this point - } else if let Some(pe) = e.downcast_ref::<ParsingException>() { - let details = pe.get_details(); - - let file = realpath(&Factory::get_composer_file().unwrap_or_default()); - - let line = details.line; - - let mut ghe = GithubActionError::new(io.clone()); - ghe.emit(&pe.message, file.as_deref(), line); - - return Err(e); - } else { - return Err(e); - } - } - } - for warning in &plugin_warnings { - io.write_error(warning); - } - - application.borrow_mut().has_plugin_commands = true; - } - - if !application.borrow().disable_plugins_by_default - && is_non_allowed_root - && !io.is_interactive() - { - io.write_error("<error>Composer plugins have been disabled for safety in this non-interactive session.</error>"); - io.write_error("<error>Set COMPOSER_ALLOW_SUPERUSER=1 if you want to allow plugins to run as root/super user.</error>"); - application.borrow_mut().disable_plugins_by_default = true; - } - - // 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(); - } - } - - 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, - ); - - if PHP_VERSION_ID < 70205 { - io.write_error(&format!("<warning>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.</warning>", PHP_VERSION)); - } - - if XdebugHandler::is_xdebug_active() - && Platform::get_env("COMPOSER_DISABLE_XDEBUG_WARN").is_none() - { - io.write_error("<warning>Composer is operating slower than normal because you have Xdebug enabled. See https://getcomposer.org/xdebug</warning>"); - } - - 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>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.</warning>", - shirabe_php_shim::server_get("PHP_SELF").unwrap_or_default(), - )); - } - - 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("<warning>Do not run Composer as root/super user! See https://getcomposer.org/root for details</warning>"); - - if io.is_interactive() - && !io.ask_confirmation( - "<info>Continue as root/super user</info> [<comment>yes</comment>]? " - .to_string(), - true, - ) - { - return Ok(1); - } - } - - // Check system temp folder for usability as it can cause weird runtime issues otherwise - let tempfile_msg: Option<String> = Silencer::call(|| -> anyhow::Result<Option<String>> { - 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!("<error>PHP temp directory ({}) does not exist or is not writable to Composer. Set sys_temp_dir in your php.ini</error>", 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!("<warning>A script named {} would override a Composer command and has been skipped</warning>", script)); - } else { - let mut description = format!( - "Runs the {} script as defined in composer.json", - script - ); - - 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(); - } - - let aliases: Vec<String> = 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(); - - 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(); - - 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), - ); - - let loader = generator.create_loader( - &map, - composer - .get_config() - .borrow() - .get("vendor-dir") - .as_string() - .map(|s| s.to_string()), - ); - loader.register(false); - } - - // 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!("<warning>The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.</warning>", 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; - - // 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 - }; - - // Compatibility layer for symfony/console <7.4 - // TODO(phase-c): Application::add() takes Rc<RefCell<dyn - // SymfonyCommand>> - // 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" - ); - } - } - } - } - } - } - - let mut start_time: Option<f64> = None; - let result_outcome: anyhow::Result<i32> = (|| -> anyhow::Result<i32> { - 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(); - } - - let result = Application::base_do_run(application, input.clone(), output.clone())?; - - if input - .borrow() - .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), true) - { - io.write_error(&format!( - "<info>PHP</info> version <comment>{}</comment> ({})", - 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!( - "<info>Memory usage: {}MiB (peak: {}MiB), time: {}s</info>", - 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) - )); - } - - Ok(result) - })(); - - 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::<shirabe_php_shim::ExitException>() - .is_some() - { - return Err(e); - } - if let Some(see) = e.downcast_ref::<ScriptExecutionException>() { - if application.borrow().get_disable_plugins_by_default() - && application.borrow().is_running_as_root() - && !io.is_interactive() - { - io.write_error3("<error>Plugins have been disabled automatically as you are running as root, this may be the cause of the script failure.</error>", true, io_interface::QUIET); - io.write_error3( - "<error>See also https://getcomposer.org/root</error>", - 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::<TransportException>().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 + /// 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"), + ) } fn get_new_working_dir( @@ -1409,14 +644,7 @@ impl Application { fn is_running_as_root(&self) -> bool { function_exists("posix_getuid") && posix_getuid() == 0 } -} -/// 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<dyn CommandLoaderInterface>) { self.command_loader = Some(command_loader); } @@ -1436,254 +664,6 @@ impl Application { 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<std::cell::RefCell<Application>>, - input: Option<std::rc::Rc<std::cell::RefCell<dyn InputInterface>>>, - output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, - ) -> anyhow::Result<i32> { - 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 input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>> = match input { - None => std::rc::Rc::new(std::cell::RefCell::new(ArgvInput::new(None, None)?)), - Some(input) => input, - }; - - let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = 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<std::cell::RefCell<dyn OutputInterface>>| { - // 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<i32> { - 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::<shirabe_php_shim::ExitException>() { - 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) - } - - /// Runs the current application (Symfony base; `parent::doRun`). - pub fn base_do_run( - application: &std::rc::Rc<std::cell::RefCell<Application>>, - input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - ) -> anyhow::Result<i32> { - 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); - - return Ok(0); - } - - // 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); - } - } - } - - 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; - } - } - - 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<InputArgument> = 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)?; - - name - } - }; - - let command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>; - 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; - } - Err(e) => { - // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) - // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) - let alternatives: Option<Vec<String>> = downcast_command_not_found(&e) - .filter(|_| !is_namespace_not_found(&e)) - .map(|cnf| cnf.get_alternatives().clone()); - - let single_alternative = match &alternatives { - Some(alts) if alts.len() == 1 => Some(alts[0].clone()), - _ => None, - }; - - if single_alternative.is_none() || !input.borrow().is_interactive() { - return Err(e); - } - - let alternative = single_alternative.unwrap(); - - 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); - } - - command = application.borrow_mut().find(&alternative)?; - } - } - - // LazyCommand is intentionally not ported. - - 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<std::cell::RefCell<HelperSet>>) { self.helper_set = Some(helper_set); } @@ -1812,66 +792,6 @@ impl Application { self.version = version.to_string(); } - /// Adds an array of command objects. - /// - /// If a SymfonyCommand is not enabled it will not be added. - pub fn add_commands( - &mut self, - commands: Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>, - ) -> anyhow::Result<()> { - for command in commands { - self.add(command)?; - } - Ok(()) - } - - /// Adds a command object. - /// - /// If a command with the same name already exists, it will be overridden. - /// If the command is not enabled it will not be added. - pub fn add( - &mut self, - command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>, - ) -> anyhow::Result<Option<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> { - self.init()?; - - // TODO(review): $command->setApplication($this) needs an Rc<RefCell<Application>> 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<RefCell<Application>> of self")); - - 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 { - message: format!( - "The command defined in \"{}\" cannot have an empty name.", - PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command,)), - ), - code: 0, - }) - .into()); - } - - let name = command.borrow().get_name().unwrap(); - self.commands.insert(name, command.clone()); - - for alias in command.borrow().get_aliases() { - self.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. @@ -1879,8 +799,6 @@ impl Application { &mut self, name: &str, ) -> anyhow::Result<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>> { - self.init()?; - if !self.has(name) { return Err(CommandNotFoundException::new( format!( @@ -1927,8 +845,6 @@ impl Application { /// Returns true if the command exists, false otherwise. pub fn has(&mut self, name: &str) -> bool { - self.init().unwrap(); - if self.commands.contains_key(name) { return true; } @@ -1942,6 +858,7 @@ impl Application { // Rc<RefCell<dyn SymfonyCommand>>; the loader return type needs reconciliation. let _ = command; return self + .shared() .add(todo!( "Rc<RefCell<dyn SymfonyCommand>> from command_loader.get(name)" )) @@ -2056,8 +973,6 @@ impl Application { &mut self, name: &str, ) -> anyhow::Result<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>> { - self.init()?; - let mut aliases: IndexMap<String, String> = IndexMap::new(); let commands_snapshot: Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>> = @@ -2269,8 +1184,6 @@ impl Application { &mut self, namespace: Option<&str>, ) -> anyhow::Result<IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> { - self.init()?; - if namespace.is_none() { if self.command_loader.is_none() { return Ok(self.commands.clone()); @@ -2608,66 +1521,6 @@ impl Application { Ok(()) } - /// 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<std::cell::RefCell<Application>>, - command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>, - input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - ) -> anyhow::Result<i32> { - 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::<dyn InputAwareInterface>; - } - } - - 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<i64> = Vec::new(); - let _ = std::marker::PhantomData::<dyn SignalableCommandInterface>; - - 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()); - } - - 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"); - } - } - - command - .borrow_mut() - .run(input.clone(), output.clone()) - .map(|c| c as i32) - } - /// Gets the name of the command based on input. pub fn get_command_name(&self, input: &dyn InputInterface) -> Option<String> { if self.single_command { @@ -2993,19 +1846,1104 @@ impl Application { namespaces } +} - fn init(&mut self) -> anyhow::Result<()> { - if self.initialized { - return Ok(()); - } - self.initialized = true; +/// 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<std::cell::RefCell<Application>>); - for command in self.get_default_commands() { +impl ApplicationHandle { + /// Builds the application inside a shared handle 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 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<ApplicationHandle> { + 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(()) } + + /// Adds a command object. + /// + /// 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( + &self, + command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>, + ) -> anyhow::Result<Option<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> { + let application = &self.0; + command.borrow_mut().set_application(Some( + application.clone() as std::rc::Rc<std::cell::RefCell<dyn BaseApplication>> + )); + + 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 { + message: format!( + "The command defined in \"{}\" cannot have an empty name.", + PhpMixed::from(shirabe_php_shim::get_debug_type_obj(&command)), + ), + 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( + &self, + input: Option<std::rc::Rc<std::cell::RefCell<dyn InputInterface>>>, + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, + ) -> anyhow::Result<i32> { + 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<std::cell::RefCell<dyn OutputInterface>>, + ), + }; + + self.base_run(input, output) + } + + pub fn do_run( + &self, + input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<i32> { + 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); + + 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); + } + + let mut helpers: IndexMap< + HelperSetKey, + std::rc::Rc<std::cell::RefCell<dyn HelperInterface>>, + > = 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(); + + // Register error handler again to pass it the IO instance + ErrorHandler::register(Some(io.clone())); + + 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" + }, + ); + } + + // switch working dir + let new_work_dir = application.borrow().get_new_working_dir(input.clone())?; + let mut old_working_dir: Option<String> = 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, + ); + } + + // determine command name to be executed without including plugin commands + let mut command_name: Option<String> = 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::<CommandNotFoundException>().is_some() { + // we'll check command validity again later after plugins are loaded + command_name = None; + } + // PHP also catches \InvalidArgumentException here without action + } + } + } + + // 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(); + + // 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!("<info>No composer.json in current directory, to use the one at {} run interactively or set config.use-parent-dir to true</info>", dir)); + break; + } + if use_parent_dir_if_no_json_available.as_bool() == Some(true) + || io.ask_confirmation(format!("<info>No composer.json in current directory, do you want to use the one at {}?</info> [<comment>y,n</comment>]? ", dir), true) + { + if use_parent_dir_if_no_json_available.as_bool() == Some(true) { + io.write_error(&format!("<info>No composer.json in current directory, changing working directory to {}</info>", dir)); + } else { + io.write_error("<info>Always want to use the parent dir? Use \"composer config --global use-parent-dir true\" to change the default.</info>"); + } + 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; + + // 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(); + + 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(()) + }); + } + } + + // 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; + + if may_need_plugin_command + && !application.borrow().disable_plugins_by_default + && !application.borrow().has_plugin_commands + { + // 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("<warning>Do not run Composer as root/super user! See https://getcomposer.org/root for details</warning>"); + + if io.is_interactive() + && io.ask_confirmation( + "<info>Continue as root/super user</info> [<comment>yes</comment>]? " + .to_string(), + true, + ) + { + // avoid a second prompt later + is_non_allowed_root = false; + } else { + io.write_error("<warning>Aborting as no plugin should be loaded if running as super user is not explicitly allowed</warning>"); + + return Ok(1); + } + } + + // 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<String> = 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!("<warning>Plugin command {} ({}) would override a Composer command and has been skipped</warning>", 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::<NoSslException>().is_some() { + // suppress these as they are not relevant at this point + } else if let Some(pe) = e.downcast_ref::<ParsingException>() { + let details = pe.get_details(); + + let file = realpath(&Factory::get_composer_file().unwrap_or_default()); + + let line = details.line; + + let mut ghe = GithubActionError::new(io.clone()); + ghe.emit(&pe.message, file.as_deref(), line); + + return Err(e); + } else { + return Err(e); + } + } + } + for warning in &plugin_warnings { + io.write_error(warning); + } + + application.borrow_mut().has_plugin_commands = true; + } + + if !application.borrow().disable_plugins_by_default + && is_non_allowed_root + && !io.is_interactive() + { + io.write_error("<error>Composer plugins have been disabled for safety in this non-interactive session.</error>"); + io.write_error("<error>Set COMPOSER_ALLOW_SUPERUSER=1 if you want to allow plugins to run as root/super user.</error>"); + application.borrow_mut().disable_plugins_by_default = true; + } + + // 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(); + } + } + + 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, + ); + + if PHP_VERSION_ID < 70205 { + io.write_error(&format!("<warning>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.</warning>", PHP_VERSION)); + } + + if XdebugHandler::is_xdebug_active() + && Platform::get_env("COMPOSER_DISABLE_XDEBUG_WARN").is_none() + { + io.write_error("<warning>Composer is operating slower than normal because you have Xdebug enabled. See https://getcomposer.org/xdebug</warning>"); + } + + 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>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.</warning>", + shirabe_php_shim::server_get("PHP_SELF").unwrap_or_default(), + )); + } + + 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("<warning>Do not run Composer as root/super user! See https://getcomposer.org/root for details</warning>"); + + if io.is_interactive() + && !io.ask_confirmation( + "<info>Continue as root/super user</info> [<comment>yes</comment>]? " + .to_string(), + true, + ) + { + return Ok(1); + } + } + + // Check system temp folder for usability as it can cause weird runtime issues otherwise + let tempfile_msg: Option<String> = Silencer::call(|| -> anyhow::Result<Option<String>> { + 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!("<error>PHP temp directory ({}) does not exist or is not writable to Composer. Set sys_temp_dir in your php.ini</error>", 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!("<warning>A script named {} would override a Composer command and has been skipped</warning>", script)); + } else { + let mut description = format!( + "Runs the {} script as defined in composer.json", + script + ); + + 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(); + } + + let aliases: Vec<String> = 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(); + + 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(); + + 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), + ); + + let loader = generator.create_loader( + &map, + composer + .get_config() + .borrow() + .get("vendor-dir") + .as_string() + .map(|s| s.to_string()), + ); + loader.register(false); + } + + // 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!("<warning>The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.</warning>", 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; + + // 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 + }; + + // Compatibility layer for symfony/console <7.4 + // TODO(phase-c): Application::add() takes Rc<RefCell<dyn + // SymfonyCommand>> + // 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" + ); + } + } + } + } + } + } + + let mut start_time: Option<f64> = None; + let result_outcome: anyhow::Result<i32> = (|| -> anyhow::Result<i32> { + 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(); + } + + let result = self.base_do_run(input.clone(), output.clone())?; + + if input + .borrow() + .has_parameter_option(PhpMixed::from(vec!["--version", "-V"]), true) + { + io.write_error(&format!( + "<info>PHP</info> version <comment>{}</comment> ({})", + 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!( + "<info>Memory usage: {}MiB (peak: {}MiB), time: {}s</info>", + 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) + )); + } + + Ok(result) + })(); + + 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::<shirabe_php_shim::ExitException>() + .is_some() + { + return Err(e); + } + if let Some(see) = e.downcast_ref::<ScriptExecutionException>() { + if application.borrow().get_disable_plugins_by_default() + && application.borrow().is_running_as_root() + && !io.is_interactive() + { + io.write_error3("<error>Plugins have been disabled automatically as you are running as root, this may be the cause of the script failure.</error>", true, io_interface::QUIET); + io.write_error3( + "<error>See also https://getcomposer.org/root</error>", + 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::<TransportException>().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 + } +} + +impl ApplicationHandle { + /// Runs the current application (Symfony base; `parent::run`). + pub fn base_run( + &self, + input: Option<std::rc::Rc<std::cell::RefCell<dyn InputInterface>>>, + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, + ) -> anyhow::Result<i32> { + 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)); + } + + let input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>> = match input { + None => std::rc::Rc::new(std::cell::RefCell::new(ArgvInput::new(None, None)?)), + Some(input) => input, + }; + + let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = 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<std::cell::RefCell<dyn OutputInterface>>| { + // 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<i32> { + application.borrow_mut().configure_io(&input, &output)?; + + 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::<shirabe_php_shim::ExitException>() { + 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) + } + + /// Runs the current application (Symfony base; `parent::doRun`). + pub fn base_do_run( + &self, + input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<i32> { + 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); + + return Ok(0); + } + + // 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); + } + } + } + + 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; + } + } + + 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<InputArgument> = 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)?; + + name + } + }; + + let command: std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>; + 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; + } + Err(e) => { + // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) + // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) + let alternatives: Option<Vec<String>> = downcast_command_not_found(&e) + .filter(|_| !is_namespace_not_found(&e)) + .map(|cnf| cnf.get_alternatives().clone()); + + let single_alternative = match &alternatives { + Some(alts) if alts.len() == 1 => Some(alts[0].clone()), + _ => None, + }; + + if single_alternative.is_none() || !input.borrow().is_interactive() { + return Err(e); + } + + let alternative = single_alternative.unwrap(); + + 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); + } + + command = application.borrow_mut().find(&alternative)?; + } + } + + // LazyCommand is intentionally not ported. + + 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; + + Ok(exit_code) + } + + /// Adds an array of command objects. + /// + /// If a SymfonyCommand is not enabled it will not be added. + pub fn add_commands( + &self, + commands: Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>, + ) -> anyhow::Result<()> { + for command in commands { + self.add(command)?; + } + Ok(()) + } + + /// 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<std::cell::RefCell<dyn SymfonyCommand>>, + input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<i32> { + 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::<dyn InputAwareInterface>; + } + } + + 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<i64> = Vec::new(); + let _ = std::marker::PhantomData::<dyn SignalableCommandInterface>; + + 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()); + } + + 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"); + } + } + + command + .borrow_mut() + .run(input.clone(), output.clone()) + .map(|c| c as i32) + } } impl BaseApplication for Application { diff --git a/crates/shirabe/src/lib.rs b/crates/shirabe/src/lib.rs index 8d8cc96..ec63ee1 100644 --- a/crates/shirabe/src/lib.rs +++ b/crates/shirabe/src/lib.rs @@ -26,7 +26,7 @@ pub mod self_update; pub mod util; pub fn run(argv: Vec<String>) -> anyhow::Result<i32> { - use crate::console::Application; + use crate::console::ApplicationHandle; use crate::util::Platform; use shirabe_external_packages::symfony::console::input::argv_input::ArgvInput; use shirabe_external_packages::symfony::console::input::input_interface::InputInterface; @@ -39,10 +39,10 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<i32> { .unwrap_or_default(), ); - let application = Application::new_shared("Composer".to_string(), String::new())?; + let application = ApplicationHandle::new("Composer".to_string(), String::new())?; let input = std::rc::Rc::new(std::cell::RefCell::new(ArgvInput::new(Some(argv), None)?)) as std::rc::Rc<std::cell::RefCell<dyn InputInterface>>; - Application::run(&application, Some(input), None) + application.run(Some(input), None) } #[cfg(test)] diff --git a/crates/shirabe/tests/application_test.rs b/crates/shirabe/tests/application_test.rs index dddf8de..7a2a751 100644 --- a/crates/shirabe/tests/application_test.rs +++ b/crates/shirabe/tests/application_test.rs @@ -9,7 +9,7 @@ use std::rc::Rc; use shirabe::command::about_command::AboutCommand; use shirabe::command::self_update_command::SelfUpdateCommand; -use shirabe::console::application::Application; +use shirabe::console::application::ApplicationHandle; use shirabe::util::platform::Platform; use shirabe_external_packages::symfony::console::command::Command; use shirabe_external_packages::symfony::console::input::array_input::ArrayInput; @@ -54,12 +54,9 @@ fn test_dev_warning_suppressed_for_self_update() { return; } - let application = Rc::new(RefCell::new(Application::new( - "Composer".to_string(), - "".to_string(), - ))); + let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap(); let command: Rc<RefCell<dyn Command>> = Rc::new(RefCell::new(SelfUpdateCommand::new())); - application.borrow_mut().add(command).unwrap(); + application.add(command).unwrap(); let output = Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new( @@ -70,7 +67,7 @@ fn test_dev_warning_suppressed_for_self_update() { .unwrap(), )); let output_trait: Rc<RefCell<dyn OutputInterface>> = output.clone(); - Application::do_run(&application, input, output_trait).unwrap(); + application.do_run(input, output_trait).unwrap(); assert_eq!( "This instance of Composer does not have the self-update command.\n\ @@ -85,12 +82,9 @@ fn test_process_isolation_works_multiple_times() { let _tear_down = TearDown; set_up(); - let application = Rc::new(RefCell::new(Application::new( - "Composer".to_string(), - "".to_string(), - ))); + let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap(); let command: Rc<RefCell<dyn Command>> = Rc::new(RefCell::new(AboutCommand::new())); - application.borrow_mut().add(command).unwrap(); + application.add(command).unwrap(); let input1: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new( ArrayInput::new( @@ -101,10 +95,7 @@ fn test_process_isolation_works_multiple_times() { )); let output1: Rc<RefCell<dyn OutputInterface>> = Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); - assert_eq!( - 0, - Application::do_run(&application, input1, output1).unwrap() - ); + assert_eq!(0, application.do_run(input1, output1).unwrap()); let input2: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new( ArrayInput::new( @@ -115,10 +106,7 @@ fn test_process_isolation_works_multiple_times() { )); let output2: Rc<RefCell<dyn OutputInterface>> = Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); - assert_eq!( - 0, - Application::do_run(&application, input2, output2).unwrap() - ); + assert_eq!(0, application.do_run(input2, output2).unwrap()); } #[ignore = "Application::set_auto_exit / set_catch_errors and the initTempComposer test helper do not exist"] |
