From 73bb568e4ac3ee096c048633173bd0efd0c2214c Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 16 May 2026 20:12:42 +0900 Subject: feat(port): port BaseCommand.php --- crates/shirabe/src/command/base_command.rs | 628 +++++++++++++++++++++++++++++ 1 file changed, 628 insertions(+) diff --git a/crates/shirabe/src/command/base_command.rs b/crates/shirabe/src/command/base_command.rs index 2386c73..704b932 100644 --- a/crates/shirabe/src/command/base_command.rs +++ b/crates/shirabe/src/command/base_command.rs @@ -1 +1,629 @@ //! ref: composer/src/Composer/Command/BaseCommand.php + +use anyhow::Result; +use indexmap::IndexMap; +use shirabe_external_packages::symfony::component::console::command::command::Command; +use shirabe_external_packages::symfony::component::console::completion::completion_input::CompletionInput; +use shirabe_external_packages::symfony::component::console::completion::completion_suggestions::CompletionSuggestions; +use shirabe_external_packages::symfony::component::console::helper::table::Table; +use shirabe_external_packages::symfony::component::console::helper::table_separator::TableSeparator; +use shirabe_external_packages::symfony::component::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::component::console::output::output_interface::OutputInterface; +use shirabe_external_packages::symfony::component::console::terminal::Terminal; +use shirabe_php_shim::{ + count, explode, in_array, is_string, max, InvalidArgumentException, LogicException, PhpMixed, + RuntimeException, UnexpectedValueException, +}; + +use crate::advisory::audit_config::AuditConfig; +use crate::advisory::auditor::Auditor; +use crate::command::self_update_command::SelfUpdateCommand; +use crate::composer::Composer; +use crate::config::Config; +use crate::console::application::Application; +use crate::console::input::input_argument::InputArgument; +use crate::console::input::input_option::InputOption; +use crate::factory::Factory; +use crate::filter::platform_requirement_filter::platform_requirement_filter_factory::PlatformRequirementFilterFactory; +use crate::filter::platform_requirement_filter::platform_requirement_filter_interface::PlatformRequirementFilterInterface; +use crate::io::io_interface::IOInterface; +use crate::io::null_io::NullIO; +use crate::package::version::version_parser::VersionParser; +use crate::plugin::plugin_events::PluginEvents; +use crate::plugin::pre_command_run_event::PreCommandRunEvent; +use crate::util::platform::Platform; + +/// Base class for Composer commands +#[derive(Debug)] +pub struct BaseCommand { + inner: Command, + /// @var Composer|null + composer: Option, + /// @var IOInterface + io: Option>, +} + +impl BaseCommand { + /// Gets the application instance for this command. + pub fn get_application(&self) -> Result { + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast from generic Symfony Application + let application_as_composer: Option = application; + if application_as_composer.is_none() { + return Err(RuntimeException { + message: format!( + "Composer commands can only work with an {} instance set", + "Composer\\Console\\Application" + ), + code: 0, + } + .into()); + } + + Ok(application_as_composer.unwrap()) + } + + /// @deprecated since Composer 2.3.0 use requireComposer or tryComposer depending on whether you have $required set to true or false + pub fn get_composer( + &mut self, + required: bool, + disable_plugins: Option, + disable_scripts: Option, + ) -> Result> { + if required { + return Ok(Some(self.require_composer(disable_plugins, disable_scripts)?)); + } + + Ok(self.try_composer(disable_plugins, disable_scripts)) + } + + /// Retrieves the default Composer\Composer instance or throws + pub fn require_composer( + &mut self, + disable_plugins: Option, + disable_scripts: Option, + ) -> Result { + if self.composer.is_none() { + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast + let application_as_composer: Option = application; + if let Some(app) = application_as_composer { + self.composer = Some(app.get_composer(true, disable_plugins, disable_scripts)?); + // PHP: assert($this->composer instanceof Composer) — Rust types guarantee this + } else { + return Err(RuntimeException { + message: + "Could not create a Composer\\Composer instance, you must inject one if this command is not used with a Composer\\Console\\Application instance" + .to_string(), + code: 0, + } + .into()); + } + } + + Ok(self.composer.clone().unwrap()) + } + + /// Retrieves the default Composer\Composer instance or null + pub fn try_composer( + &mut self, + disable_plugins: Option, + disable_scripts: Option, + ) -> Option { + if self.composer.is_none() { + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast + let application_as_composer: Option = application; + if let Some(app) = application_as_composer { + self.composer = app + .get_composer(false, disable_plugins, disable_scripts) + .ok(); + } + } + + self.composer.clone() + } + + pub fn set_composer(&mut self, composer: Composer) { + self.composer = Some(composer); + } + + /// Removes the cached composer instance + pub fn reset_composer(&mut self) -> Result<()> { + self.composer = None; + self.get_application()?.reset_composer(); + Ok(()) + } + + /// Whether or not this command is meant to call another command. + pub fn is_proxy_command(&self) -> bool { + false + } + + pub fn get_io(&mut self) -> &dyn IOInterface { + if self.io.is_none() { + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast + let application_as_composer: Option = application; + if let Some(app) = application_as_composer { + self.io = Some(app.get_io()); + } else { + self.io = Some(Box::new(NullIO::new())); + } + } + + &**self.io.as_ref().unwrap() + } + + pub fn set_io(&mut self, io: Box) { + self.io = Some(io); + } + + /// @inheritdoc + /// + /// Backport suggested values definition from symfony/console 6.1+ + pub fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + let definition = self.inner.get_definition(); + let name = input.get_completion_name().to_string(); + if CompletionInput::TYPE_OPTION_VALUE == input.get_completion_type() + && definition.has_option(&name) + { + let option = definition.get_option(&name); + // TODO(phase-b): `$option instanceof InputOption` (our InputOption, not Symfony's) + let option_as_input: Option<&InputOption> = None; + if let Some(input_option) = option_as_input { + input_option.complete(input, suggestions); + let _ = option; + return; + } + } + if CompletionInput::TYPE_ARGUMENT_VALUE == input.get_completion_type() + && definition.has_argument(&name) + { + let argument = definition.get_argument(&name); + // TODO(phase-b): `$argument instanceof InputArgument` (our InputArgument, not Symfony's) + let argument_as_input: Option<&InputArgument> = None; + if let Some(input_argument) = argument_as_input { + input_argument.complete(input, suggestions); + let _ = argument; + return; + } + } + self.inner.complete(input, suggestions); + } + + /// @inheritDoc + pub fn initialize( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> Result<()> { + // initialize a plugin-enabled Composer instance, either local or global + let mut disable_plugins = + input.has_parameter_option(PhpMixed::String("--no-plugins".to_string())); + let mut disable_scripts = + input.has_parameter_option(PhpMixed::String("--no-scripts".to_string())); + + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast + let application_as_composer: Option<&Application> = None; + if let Some(app) = application_as_composer { + if app.get_disable_plugins_by_default() { + disable_plugins = true; + } + if app.get_disable_scripts_by_default() { + disable_scripts = true; + } + } + let _ = application; + + // TODO(phase-b): `$this instanceof SelfUpdateCommand` — not representable since + // BaseCommand is a struct, not a base type + let self_is_self_update: Option<&SelfUpdateCommand> = None; + if self_is_self_update.is_some() { + disable_plugins = true; + disable_scripts = true; + } + + let composer = self.try_composer(Some(disable_plugins), Some(disable_scripts)); + // TODO(phase-b): re-borrow self for get_io after try_composer move + let io_ptr: *const dyn IOInterface = self.get_io(); + let io = unsafe { &*io_ptr }; + + let composer = if composer.is_none() { + Some(Factory::create_global( + io, + Some(disable_plugins), + Some(disable_scripts), + )?) + } else { + composer + }; + if let Some(composer) = composer.as_ref() { + let pre_command_run_event = PreCommandRunEvent::new( + PluginEvents::PRE_COMMAND_RUN.to_string(), + Box::new(input), + self.inner.get_name().to_string(), + ); + composer.get_event_dispatcher().dispatch( + pre_command_run_event.get_name(), + Box::new(pre_command_run_event), + ); + } + + if true + == input.has_parameter_option(PhpMixed::List(vec![Box::new(PhpMixed::String( + "--no-ansi".to_string(), + ))])) + && input.has_option("no-progress") + { + input.set_option("no-progress", PhpMixed::Bool(true)); + } + + let env_options: IndexMap<&str, Vec<&str>> = [ + ("COMPOSER_NO_AUDIT", vec!["no-audit"]), + ("COMPOSER_NO_DEV", vec!["no-dev", "update-no-dev"]), + ("COMPOSER_PREFER_STABLE", vec!["prefer-stable"]), + ("COMPOSER_PREFER_LOWEST", vec!["prefer-lowest"]), + ("COMPOSER_MINIMAL_CHANGES", vec!["minimal-changes"]), + ("COMPOSER_WITH_DEPENDENCIES", vec!["with-dependencies"]), + ("COMPOSER_WITH_ALL_DEPENDENCIES", vec!["with-all-dependencies"]), + ("COMPOSER_NO_SECURITY_BLOCKING", vec!["no-security-blocking"]), + ] + .into_iter() + .collect(); + for (env_name, option_names) in &env_options { + for option_name in option_names { + if true == input.has_option(option_name) { + if false == input.get_option(option_name).as_bool().unwrap_or(false) + && Platform::get_env(env_name).as_bool().unwrap_or(false) + { + input.set_option(option_name, PhpMixed::Bool(true)); + } + } + } + } + + if true == input.has_option("ignore-platform-reqs") { + if !input.get_option("ignore-platform-reqs").as_bool().unwrap_or(false) + && Platform::get_env("COMPOSER_IGNORE_PLATFORM_REQS") + .as_bool() + .unwrap_or(false) + { + input.set_option("ignore-platform-reqs", PhpMixed::Bool(true)); + + io.write_error("COMPOSER_IGNORE_PLATFORM_REQS is set. You may experience unexpected errors."); + } + } + + if true == input.has_option("ignore-platform-req") + && (!input.has_option("ignore-platform-reqs") + || !input + .get_option("ignore-platform-reqs") + .as_bool() + .unwrap_or(false)) + { + let ignore_platform_req_env = Platform::get_env("COMPOSER_IGNORE_PLATFORM_REQ"); + let ignore_str = ignore_platform_req_env.as_string().unwrap_or("").to_string(); + if 0 == count(&input.get_option("ignore-platform-req")) + && is_string(&ignore_platform_req_env) + && "" != ignore_str + { + input.set_option( + "ignore-platform-req", + PhpMixed::List( + explode(",", &ignore_str) + .into_iter() + .map(|s| Box::new(PhpMixed::String(s))) + .collect(), + ), + ); + + io.write_error(&format!( + "COMPOSER_IGNORE_PLATFORM_REQ is set to ignore {}. You may experience unexpected errors.", + ignore_str + )); + } + } + + self.inner.initialize(input, output) + } + + /// Calls {@see Factory::create()} with the given arguments, taking into account flags and default states for disabling scripts and plugins + pub fn create_composer_instance( + &self, + input: &dyn InputInterface, + io: &dyn IOInterface, + config: Option>, + disable_plugins: bool, + disable_scripts: Option, + ) -> Result { + let mut disable_plugins = disable_plugins == true + || input.has_parameter_option(PhpMixed::String("--no-plugins".to_string())); + let mut disable_scripts = disable_scripts == Some(true) + || input.has_parameter_option(PhpMixed::String("--no-scripts".to_string())); + + let application = self.inner.get_application(); + // TODO(phase-b): `$application instanceof Application` downcast + let application_as_composer: Option<&Application> = None; + if let Some(app) = application_as_composer { + if app.get_disable_plugins_by_default() { + disable_plugins = true; + } + if app.get_disable_scripts_by_default() { + disable_scripts = true; + } + } + let _ = application; + + Factory::create(io, config, disable_plugins, disable_scripts) + } + + /// Returns preferSource and preferDist values based on the configuration. + pub fn get_preferred_install_options( + &self, + config: &Config, + input: &dyn InputInterface, + keep_vcs_requires_prefer_source: bool, + ) -> Result<(bool, bool)> { + let mut prefer_source = false; + let mut prefer_dist = false; + + match config.get("preferred-install").as_string().unwrap_or("") { + "source" => { + prefer_source = true; + } + "dist" => { + prefer_dist = true; + } + "auto" | _ => { + // noop + } + } + + if !input.has_option("prefer-dist") || !input.has_option("prefer-source") { + return Ok((prefer_source, prefer_dist)); + } + + if input.has_option("prefer-install") + && is_string(&input.get_option("prefer-install")) + { + if input.get_option("prefer-source").as_bool().unwrap_or(false) { + return Err(InvalidArgumentException { + message: "--prefer-source can not be used together with --prefer-install" + .to_string(), + code: 0, + } + .into()); + } + if input.get_option("prefer-dist").as_bool().unwrap_or(false) { + return Err(InvalidArgumentException { + message: "--prefer-dist can not be used together with --prefer-install" + .to_string(), + code: 0, + } + .into()); + } + let prefer_install = input.get_option("prefer-install"); + match prefer_install.as_string().unwrap_or("") { + "dist" => { + // TODO(phase-b): InputInterface set_option needs &mut self + let _ = "input.set_option('prefer-dist', true)"; + } + "source" => { + let _ = "input.set_option('prefer-source', true)"; + } + "auto" => { + prefer_dist = false; + prefer_source = false; + } + other => { + return Err(UnexpectedValueException { + message: format!( + "--prefer-install accepts one of \"dist\", \"source\" or \"auto\", got {}", + other + ), + code: 0, + } + .into()); + } + } + } + + if input.get_option("prefer-source").as_bool().unwrap_or(false) + || input.get_option("prefer-dist").as_bool().unwrap_or(false) + || (keep_vcs_requires_prefer_source + && input.has_option("keep-vcs") + && input.get_option("keep-vcs").as_bool().unwrap_or(false)) + { + prefer_source = input.get_option("prefer-source").as_bool().unwrap_or(false) + || (keep_vcs_requires_prefer_source + && input.has_option("keep-vcs") + && input.get_option("keep-vcs").as_bool().unwrap_or(false)); + prefer_dist = input.get_option("prefer-dist").as_bool().unwrap_or(false); + } + + Ok((prefer_source, prefer_dist)) + } + + pub fn get_platform_requirement_filter( + &self, + input: &dyn InputInterface, + ) -> Result> { + if !input.has_option("ignore-platform-reqs") + || !input.has_option("ignore-platform-req") + { + return Err(LogicException { + message: + "Calling getPlatformRequirementFilter from a command which does not define the --ignore-platform-req[s] flags is not permitted." + .to_string(), + code: 0, + } + .into()); + } + + if true == input.get_option("ignore-platform-reqs").as_bool().unwrap_or(false) { + return Ok(PlatformRequirementFilterFactory::ignore_all()); + } + + let ignores = input.get_option("ignore-platform-req"); + if count(&ignores) > 0 { + return Ok(PlatformRequirementFilterFactory::from_bool_or_list(ignores)); + } + + Ok(PlatformRequirementFilterFactory::ignore_nothing()) + } + + /// @param array $requirements + /// + /// @return array + pub fn format_requirements( + &self, + requirements: Vec, + ) -> Result> { + let mut requires: IndexMap = IndexMap::new(); + let requirements = self.normalize_requirements(requirements)?; + for requirement in requirements { + if !requirement.contains_key("version") { + return Err(UnexpectedValueException { + message: format!( + "Option {} is missing a version constraint, use e.g. {}:^1.0", + requirement.get("name").map(|s| s.as_str()).unwrap_or(""), + requirement.get("name").map(|s| s.as_str()).unwrap_or(""), + ), + code: 0, + } + .into()); + } + requires.insert( + requirement.get("name").cloned().unwrap_or_default(), + requirement.get("version").cloned().unwrap_or_default(), + ); + } + + Ok(requires) + } + + /// @param array $requirements + /// + /// @return list + pub fn normalize_requirements( + &self, + requirements: Vec, + ) -> Result>> { + // TODO(phase-b): VersionParser has no public `new` yet + let parser: VersionParser = todo!("VersionParser::new()"); + + parser.parse_name_version_pairs(requirements) + } + + /// @param array $table + pub fn render_table(&self, table: Vec, output: &dyn OutputInterface) { + let mut renderer = Table::new(output); + renderer.set_style("compact"); + renderer.set_rows(table).render(); + let _ = TableSeparator::new(); + } + + pub fn get_terminal_width(&self) -> i64 { + let terminal = Terminal::new(); + let mut width = terminal.get_width(); + + if Platform::is_windows() { + width -= 1; + } else { + width = max(80, width); + } + + width + } + + /// @internal + /// @param 'format'|'audit-format' $optName + /// @return Auditor::FORMAT_* + pub fn get_audit_format( + &self, + input: &dyn InputInterface, + opt_name: &str, + ) -> Result { + if !input.has_option(opt_name) { + return Err(LogicException { + message: format!( + "This should not be called on a Command which has no {} option defined.", + opt_name + ), + code: 0, + } + .into()); + } + + let val = input.get_option(opt_name); + let formats: Vec> = Auditor::FORMATS + .iter() + .map(|s| Box::new(PhpMixed::String(s.to_string()))) + .collect(); + if !in_array(val.clone(), &PhpMixed::List(formats), true) { + return Err(InvalidArgumentException { + message: format!( + "--{} must be one of {}.", + opt_name, + Auditor::FORMATS.join(", ") + ), + code: 0, + } + .into()); + } + + Ok(val.as_string().unwrap_or("").to_string()) + } + + /// Creates an AuditConfig from the Config object, optionally overriding security blocking based on input options + pub fn create_audit_config( + &self, + config: &Config, + input: &dyn InputInterface, + ) -> Result { + // Handle both --audit and --no-audit flags + let audit = if input.has_option("audit") { + input.get_option("audit").as_bool().unwrap_or(false) + } else { + !(input.has_option("no-audit") + && input.get_option("no-audit").as_bool().unwrap_or(false)) + }; + let audit_format = if input.has_option("audit-format") { + self.get_audit_format(input, "audit-format")? + } else { + Auditor::FORMAT_SUMMARY.to_string() + }; + + let audit_config = AuditConfig::from_config(config, audit, &audit_format)?; + + if Platform::get_env("COMPOSER_NO_SECURITY_BLOCKING") + .as_bool() + .unwrap_or(false) + || (input.has_option("no-security-blocking") + && input + .get_option("no-security-blocking") + .as_bool() + .unwrap_or(false)) + { + let audit_config = AuditConfig::new( + audit_config.audit, + audit_config.audit_format, + audit_config.audit_abandoned, + false, // blockInsecure + audit_config.block_abandoned, + audit_config.ignore_unreachable, + audit_config.ignore_list_for_audit, + audit_config.ignore_list_for_blocking, + audit_config.ignore_severity_for_audit, + audit_config.ignore_severity_for_blocking, + audit_config.ignore_abandoned_for_audit, + audit_config.ignore_abandoned_for_blocking, + ); + return Ok(audit_config); + } + + Ok(audit_config) + } +} -- cgit v1.3.1