From f749a47804cd296a3059cd3f8079c62dbaa5fdc0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 7 Aug 2026 07:26:48 +0900 Subject: refactor: merge split inherent impl blocks into one per type Enable clippy::multiple_inherent_impl and fix the 21 sites it reports. Types whose inherent methods were spread across two or three impl blocks now keep them in a single block; only the impl headers move, no method bodies change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/symfony/console/input/array_input.rs | 118 +- .../src/symfony/console/style/symfony_style.rs | 2 - crates/shirabe-php-shim/src/zip.rs | 2 - crates/shirabe/src/command/archive_command.rs | 312 +- crates/shirabe/src/command/audit_command.rs | 110 +- crates/shirabe/src/command/config_command.rs | 2188 +++++---- .../shirabe/src/command/create_project_command.rs | 410 +- crates/shirabe/src/command/diagnose_command.rs | 1864 ++++--- crates/shirabe/src/command/init_command.rs | 1496 +++--- crates/shirabe/src/command/require_command.rs | 2134 ++++---- crates/shirabe/src/command/show_command.rs | 5146 ++++++++++---------- crates/shirabe/src/command/update_command.rs | 394 +- crates/shirabe/src/command/validate_command.rs | 190 +- crates/shirabe/src/console/application.rs | 2 - crates/shirabe/src/dependency_resolver/request.rs | 50 +- crates/shirabe/src/downloader/file_downloader.rs | 460 +- crates/shirabe/src/package/loader/array_loader.rs | 296 +- .../src/package/loader/validating_array_loader.rs | 2716 ++++++----- 18 files changed, 8924 insertions(+), 8966 deletions(-) (limited to 'crates') diff --git a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs index 1ae7069d..9a19b839 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs @@ -130,67 +130,7 @@ impl ArrayInput { default } -} - -/// Returns a stringified representation of the args passed to the command. -impl std::fmt::Display for ArrayInput { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut params: Vec = vec![]; - for (param, val) in &self.parameters { - // $param && \is_string($param) && '-' === $param[0] - let is_option_key = - matches!(param, PhpMixed::String(s) if !s.is_empty() && s.as_bytes()[0] == b'-'); - if is_option_key { - let param = param.as_string().unwrap(); - let glue = if param.as_bytes().get(1) == Some(&b'-') { - "=" - } else { - " " - }; - if let PhpMixed::List(list) = val { - for v in list { - let v = shirabe_php_shim::php_to_string(v); - params.push(format!( - "{}{}", - param, - if !v.is_empty() { - format!("{}{}", glue, self.inner.escape_token(&v)) - } else { - String::new() - } - )); - } - } else { - let val = shirabe_php_shim::php_to_string(val); - params.push(format!( - "{}{}", - param, - if !val.is_empty() { - format!("{}{}", glue, self.inner.escape_token(&val)) - } else { - String::new() - } - )); - } - } else if let PhpMixed::List(list) = val { - let escaped: Vec = list - .iter() - .map(|v| self.inner.escape_token(&shirabe_php_shim::php_to_string(v))) - .collect(); - params.push(shirabe_php_shim::implode(" ", &escaped)); - } else { - params.push( - self.inner - .escape_token(&shirabe_php_shim::php_to_string(val)), - ); - } - } - - write!(f, "{}", shirabe_php_shim::implode(" ", ¶ms)) - } -} -impl ArrayInput { fn parse(&mut self) -> anyhow::Result<()> { // Clone to avoid borrowing self while mutating; PHP iterates over a copy semantically. let parameters = self.parameters.clone(); @@ -296,6 +236,64 @@ impl ArrayInput { } } +/// Returns a stringified representation of the args passed to the command. +impl std::fmt::Display for ArrayInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut params: Vec = vec![]; + for (param, val) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + let is_option_key = + matches!(param, PhpMixed::String(s) if !s.is_empty() && s.as_bytes()[0] == b'-'); + if is_option_key { + let param = param.as_string().unwrap(); + let glue = if param.as_bytes().get(1) == Some(&b'-') { + "=" + } else { + " " + }; + if let PhpMixed::List(list) = val { + for v in list { + let v = shirabe_php_shim::php_to_string(v); + params.push(format!( + "{}{}", + param, + if !v.is_empty() { + format!("{}{}", glue, self.inner.escape_token(&v)) + } else { + String::new() + } + )); + } + } else { + let val = shirabe_php_shim::php_to_string(val); + params.push(format!( + "{}{}", + param, + if !val.is_empty() { + format!("{}{}", glue, self.inner.escape_token(&val)) + } else { + String::new() + } + )); + } + } else if let PhpMixed::List(list) = val { + let escaped: Vec = list + .iter() + .map(|v| self.inner.escape_token(&shirabe_php_shim::php_to_string(v))) + .collect(); + params.push(shirabe_php_shim::implode(" ", &escaped)); + } else { + params.push( + self.inner + .escape_token(&shirabe_php_shim::php_to_string(val)), + ); + } + } + + write!(f, "{}", shirabe_php_shim::implode(" ", ¶ms)) + } +} + impl InputInterface for ArrayInput { fn dup(&self) -> std::rc::Rc> { std::rc::Rc::new(std::cell::RefCell::new(self.clone())) diff --git a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs index 9097faff..b62c2565 100644 --- a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs +++ b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs @@ -479,9 +479,7 @@ impl SymfonyStyle { as Box) -> Result> }) } -} -impl SymfonyStyle { /// {@inheritdoc} pub fn writeln(&mut self, messages: PhpMixed, r#type: i64) { let messages: Vec = if !shirabe_php_shim::is_iterable(&messages) { diff --git a/crates/shirabe-php-shim/src/zip.rs b/crates/shirabe-php-shim/src/zip.rs index 56f02db7..6a088419 100644 --- a/crates/shirabe-php-shim/src/zip.rs +++ b/crates/shirabe-php-shim/src/zip.rs @@ -265,9 +265,7 @@ impl ZipArchive { _ => String::new(), } } -} -impl ZipArchive { pub const CREATE: i64 = 1; pub const OPSYS_UNIX: i64 = 3; pub const ER_SEEK: i64 = 4; diff --git a/crates/shirabe/src/command/archive_command.rs b/crates/shirabe/src/command/archive_command.rs index 7a24dacc..e749d13f 100644 --- a/crates/shirabe/src/command/archive_command.rs +++ b/crates/shirabe/src/command/archive_command.rs @@ -80,155 +80,7 @@ impl ArchiveCommand { .expect("ArchiveCommand::configure uses static, valid metadata"); command } -} - -impl Command for ArchiveCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("archive")?; - self.set_description("Creates an archive of this composer package"); - self.set_definition(&[ - InputArgument::new5("package", Some(InputArgument::OPTIONAL), "The package to archive instead of the current project", None, self.suggest_available_package(99)).unwrap().into(), - InputArgument::new("version", Some(InputArgument::OPTIONAL), "A version constraint to find the package to archive", None).unwrap().into(), - InputOption::new6("format", Some(shirabe_php_shim::PhpMixed::String("f".to_string())), Some(InputOption::VALUE_REQUIRED), "Format of the resulting archive: tar, tar.gz, tar.bz2 or zip (default tar)", None, SuggestedValues::List(Self::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), - InputOption::new("dir", None, Some(InputOption::VALUE_REQUIRED), "Write the archive to this directory", None).unwrap().into(), - InputOption::new("file", None, Some(InputOption::VALUE_REQUIRED), "Write the archive with the given file name. Note that the format will be appended.", None).unwrap().into(), - InputOption::new("ignore-filters", None, Some(InputOption::VALUE_NONE), "Ignore filters when saving package", None).unwrap().into(), - ]); - self.set_help( - "The archive command creates an archive of the specified format\n\ - containing the files and directories of the Composer project or the specified\n\ - package in the specified version and writes it to the specified directory.\n\n\ - shirabe archive [--format=zip] [--dir=/foo] [--file=filename] [package [version]]\n\n\ - Read more at https://getcomposer.org/doc/03-cli.md#archive" - ); - Ok(()) - } - - fn execute( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - let composer = self.try_composer(None, None); - - let config = if let Some(ref composer) = composer { - let config = composer.borrow_partial().get_config(); - // TODO(plugin): dispatch CommandEvent - let command_event = - CommandEvent::new(PluginEvents::COMMAND, "archive", input.clone(), output); - let event_dispatcher = composer.borrow_partial().get_event_dispatcher(); - event_dispatcher - .borrow_mut() - .dispatch(Some(command_event.get_name()), None); - event_dispatcher.borrow_mut().dispatch_script( - ScriptEvents::PRE_ARCHIVE_CMD, - true, - vec![], - indexmap::IndexMap::new(), - ); - config - } else { - std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)) - }; - - let format = input - .borrow() - .get_option("format")? - .as_string() - .map(|s| s.to_string()) - .unwrap_or_else(|| { - config - .borrow_mut() - .get("archive-format") - .as_string() - .unwrap_or("tar") - .to_string() - }); - - let dir = input - .borrow() - .get_option("dir")? - .as_string() - .map(|s| s.to_string()) - .unwrap_or_else(|| { - config - .borrow_mut() - .get("archive-dir") - .as_string() - .unwrap_or(".") - .to_string() - }); - let io = self.get_io().clone(); - let return_code = self.archive( - io.clone(), - &config, - input - .borrow() - .get_argument("package")? - .as_string() - .map(|s| s.to_string()), - input - .borrow() - .get_argument("version")? - .as_string() - .map(|s| s.to_string()), - &format, - &dir, - input - .borrow() - .get_option("file")? - .as_string() - .map(|s| s.to_string()), - input - .borrow() - .get_option("ignore-filters")? - .as_bool() - .unwrap_or(false), - composer.as_ref(), - )?; - - if return_code == 0 - && let Some(ref composer) = composer - { - composer - .borrow_partial() - .get_event_dispatcher() - .borrow_mut() - .dispatch_script( - ScriptEvents::POST_ARCHIVE_CMD, - true, - vec![], - indexmap::IndexMap::new(), - ); - } - - Ok(return_code) - } - - fn initialize( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result<()> { - if self.test_hooks.borrow().skip_initialize { - return Ok(()); - } - base_command_initialize(self, input, output) - } - - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } - - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} - -impl ArchiveCommand { /// For testing only: makes `initialize` a no-op (PHPUnit `onlyMethods(['initialize'])`). pub fn __test_skip_initialize(&self) { self.test_hooks.borrow_mut().skip_initialize = true; @@ -244,17 +96,7 @@ impl ArchiveCommand { pub fn __test_archive_calls(&self) -> Vec { self.test_hooks.borrow().archive_calls.clone() } -} - -impl BaseCommand for ArchiveCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } - - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} -impl ArchiveCommand { #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] pub fn archive( &self, @@ -474,3 +316,157 @@ impl ArchiveCommand { Ok(Some(complete)) } } + +impl Command for ArchiveCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("archive")?; + self.set_description("Creates an archive of this composer package"); + self.set_definition(&[ + InputArgument::new5("package", Some(InputArgument::OPTIONAL), "The package to archive instead of the current project", None, self.suggest_available_package(99)).unwrap().into(), + InputArgument::new("version", Some(InputArgument::OPTIONAL), "A version constraint to find the package to archive", None).unwrap().into(), + InputOption::new6("format", Some(shirabe_php_shim::PhpMixed::String("f".to_string())), Some(InputOption::VALUE_REQUIRED), "Format of the resulting archive: tar, tar.gz, tar.bz2 or zip (default tar)", None, SuggestedValues::List(Self::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), + InputOption::new("dir", None, Some(InputOption::VALUE_REQUIRED), "Write the archive to this directory", None).unwrap().into(), + InputOption::new("file", None, Some(InputOption::VALUE_REQUIRED), "Write the archive with the given file name. Note that the format will be appended.", None).unwrap().into(), + InputOption::new("ignore-filters", None, Some(InputOption::VALUE_NONE), "Ignore filters when saving package", None).unwrap().into(), + ]); + self.set_help( + "The archive command creates an archive of the specified format\n\ + containing the files and directories of the Composer project or the specified\n\ + package in the specified version and writes it to the specified directory.\n\n\ + shirabe archive [--format=zip] [--dir=/foo] [--file=filename] [package [version]]\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#archive" + ); + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let composer = self.try_composer(None, None); + + let config = if let Some(ref composer) = composer { + let config = composer.borrow_partial().get_config(); + // TODO(plugin): dispatch CommandEvent + let command_event = + CommandEvent::new(PluginEvents::COMMAND, "archive", input.clone(), output); + let event_dispatcher = composer.borrow_partial().get_event_dispatcher(); + event_dispatcher + .borrow_mut() + .dispatch(Some(command_event.get_name()), None); + event_dispatcher.borrow_mut().dispatch_script( + ScriptEvents::PRE_ARCHIVE_CMD, + true, + vec![], + indexmap::IndexMap::new(), + ); + config + } else { + std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)) + }; + + let format = input + .borrow() + .get_option("format")? + .as_string() + .map(|s| s.to_string()) + .unwrap_or_else(|| { + config + .borrow_mut() + .get("archive-format") + .as_string() + .unwrap_or("tar") + .to_string() + }); + + let dir = input + .borrow() + .get_option("dir")? + .as_string() + .map(|s| s.to_string()) + .unwrap_or_else(|| { + config + .borrow_mut() + .get("archive-dir") + .as_string() + .unwrap_or(".") + .to_string() + }); + + let io = self.get_io().clone(); + let return_code = self.archive( + io.clone(), + &config, + input + .borrow() + .get_argument("package")? + .as_string() + .map(|s| s.to_string()), + input + .borrow() + .get_argument("version")? + .as_string() + .map(|s| s.to_string()), + &format, + &dir, + input + .borrow() + .get_option("file")? + .as_string() + .map(|s| s.to_string()), + input + .borrow() + .get_option("ignore-filters")? + .as_bool() + .unwrap_or(false), + composer.as_ref(), + )?; + + if return_code == 0 + && let Some(ref composer) = composer + { + composer + .borrow_partial() + .get_event_dispatcher() + .borrow_mut() + .dispatch_script( + ScriptEvents::POST_ARCHIVE_CMD, + true, + vec![], + indexmap::IndexMap::new(), + ); + } + + Ok(return_code) + } + + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + if self.test_hooks.borrow().skip_initialize { + return Ok(()); + } + base_command_initialize(self, input, output) + } + + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) + } + + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} + +impl BaseCommand for ArchiveCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data + } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); +} diff --git a/crates/shirabe/src/command/audit_command.rs b/crates/shirabe/src/command/audit_command.rs index 082afdc2..0eee0036 100644 --- a/crates/shirabe/src/command/audit_command.rs +++ b/crates/shirabe/src/command/audit_command.rs @@ -44,6 +44,60 @@ impl AuditCommand { .expect("AuditCommand::configure uses static, valid metadata"); command } + + fn get_packages( + &self, + composer: &PartialComposerHandle, + input: std::rc::Rc>, + ) -> anyhow::Result> { + let composer = crate::composer::composer_full(composer); + if input + .borrow() + .get_option("locked")? + .as_bool() + .unwrap_or(false) + { + let locker = composer.get_locker().clone(); + let mut locker = locker.borrow_mut(); + if !locker.is_locked() { + return Err(UnexpectedValueException { + message: "Valid composer.json and composer.lock files are required to run this command with --locked".to_string(), + code: 0, + }.into()); + } + let locked_repo = locker.get_locked_repository( + !input + .borrow() + .get_option("no-dev")? + .as_bool() + .unwrap_or(false), + )?; + return locked_repo.borrow_mut().get_packages(); + } + + let root_pkg = composer.get_package(); + let local_repo = composer + .get_repository_manager() + .borrow() + .get_local_repository(); + let mut installed_repo = InstalledRepository::new(vec![local_repo]); + + if input + .borrow() + .get_option("no-dev")? + .as_bool() + .unwrap_or(false) + { + return Ok(RepositoryUtils::filter_required_packages( + &installed_repo.get_packages()?, + root_pkg.clone().into(), + false, + vec![], + )); + } + + installed_repo.get_packages() + } } impl Command for AuditCommand { @@ -255,59 +309,3 @@ impl BaseCommand for AuditCommand { crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } - -impl AuditCommand { - fn get_packages( - &self, - composer: &PartialComposerHandle, - input: std::rc::Rc>, - ) -> anyhow::Result> { - let composer = crate::composer::composer_full(composer); - if input - .borrow() - .get_option("locked")? - .as_bool() - .unwrap_or(false) - { - let locker = composer.get_locker().clone(); - let mut locker = locker.borrow_mut(); - if !locker.is_locked() { - return Err(UnexpectedValueException { - message: "Valid composer.json and composer.lock files are required to run this command with --locked".to_string(), - code: 0, - }.into()); - } - let locked_repo = locker.get_locked_repository( - !input - .borrow() - .get_option("no-dev")? - .as_bool() - .unwrap_or(false), - )?; - return locked_repo.borrow_mut().get_packages(); - } - - let root_pkg = composer.get_package(); - let local_repo = composer - .get_repository_manager() - .borrow() - .get_local_repository(); - let mut installed_repo = InstalledRepository::new(vec![local_repo]); - - if input - .borrow() - .get_option("no-dev")? - .as_bool() - .unwrap_or(false) - { - return Ok(RepositoryUtils::filter_required_packages( - &installed_repo.get_packages()?, - root_pkg.clone().into(), - false, - vec![], - )); - } - - installed_repo.get_packages() - } -} diff --git a/crates/shirabe/src/command/config_command.rs b/crates/shirabe/src/command/config_command.rs index c4409f20..2727f36b 100644 --- a/crates/shirabe/src/command/config_command.rs +++ b/crates/shirabe/src/command/config_command.rs @@ -61,15 +61,7 @@ impl ConfigCommand { "suggest", "extra", ]; -} - -impl Default for ConfigCommand { - fn default() -> Self { - Self::new() - } -} -impl ConfigCommand { pub fn new() -> Self { let command = ConfigCommand { base_command_data: BaseCommandData::new(None), @@ -84,652 +76,1018 @@ impl ConfigCommand { .expect("ConfigCommand::configure uses static, valid metadata"); command } -} - -impl Command for ConfigCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("config")?; - self.set_description("Sets config options"); - self.set_definition(&[ - InputOption::new("global", Some(PhpMixed::String("g".to_string())), Some(InputOption::VALUE_NONE), "Apply command to the global config file", None).unwrap().into(), - InputOption::new("editor", Some(PhpMixed::String("e".to_string())), Some(InputOption::VALUE_NONE), "Open editor", None).unwrap().into(), - InputOption::new("auth", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_NONE), "Affect auth config file (only used for --editor)", None).unwrap().into(), - InputOption::new("unset", None, Some(InputOption::VALUE_NONE), "Unset the given setting-key", None).unwrap().into(), - InputOption::new("list", Some(PhpMixed::String("l".to_string())), Some(InputOption::VALUE_NONE), "List configuration settings", None).unwrap().into(), - InputOption::new("file", Some(PhpMixed::String("f".to_string())), Some(InputOption::VALUE_REQUIRED), "If you want to choose a different composer.json or config.json", None).unwrap().into(), - InputOption::new("absolute", None, Some(InputOption::VALUE_NONE), "Returns absolute paths when fetching *-dir config values instead of relative", None).unwrap().into(), - InputOption::new("json", Some(PhpMixed::String("j".to_string())), Some(InputOption::VALUE_NONE), "JSON decode the setting value, to be used with extra.* keys", None).unwrap().into(), - InputOption::new("merge", Some(PhpMixed::String("m".to_string())), Some(InputOption::VALUE_NONE), "Merge the setting value with the current value, to be used with extra.* or audit.ignore[-abandoned] keys in combination with --json", None).unwrap().into(), - InputOption::new("append", None, Some(InputOption::VALUE_NONE), "When adding a repository, append it (lowest priority) to the existing ones instead of prepending it (highest priority)", None).unwrap().into(), - InputOption::new("source", None, Some(InputOption::VALUE_NONE), "Display where the config value is loaded from", None).unwrap().into(), - InputArgument::new5("setting-key", None, "Setting key", None, self.suggest_setting_keys()).unwrap().into(), - InputArgument::new("setting-value", Some(InputArgument::IS_ARRAY), "Setting value", None).unwrap().into(), - ]); - self.set_help( - "This command allows you to edit composer config settings and repositories\n\ - in either the local composer.json file or the global config.json file.\n\n\ - Additionally it lets you edit most properties in the local composer.json.\n\n\ - To set a config setting:\n\n\ - \t%command.full_name% bin-dir bin/\n\n\ - To read a config setting:\n\n\ - \t%command.full_name% bin-dir\n\ - \tOutputs: bin\n\n\ - To edit the global config.json file:\n\n\ - \t%command.full_name% --global\n\n\ - To add a repository:\n\n\ - \t%command.full_name% repositories.foo vcs https://bar.com\n\n\ - To remove a repository (repo is a short alias for repositories):\n\n\ - \t%command.full_name% --unset repo.foo\n\n\ - To disable packagist.org:\n\n\ - \t%command.full_name% repo.packagist.org false\n\n\ - You can alter repositories in the global config.json file by passing in the\n\ - --global option.\n\n\ - To add or edit suggested packages you can use:\n\n\ - \t%command.full_name% suggest.package reason for the suggestion\n\n\ - To add or edit extra properties you can use:\n\n\ - \t%command.full_name% extra.property value\n\n\ - Or to add a complex value you can use json with:\n\n\ - \t%command.full_name% extra.property --json '{\"foo\":true, \"bar\": []}'\n\n\ - To edit the file in an external editor:\n\n\ - \t%command.full_name% --editor\n\n\ - To choose your editor you can set the \"EDITOR\" env variable.\n\n\ - To get a list of configuration values in the file:\n\n\ - \t%command.full_name% --list\n\n\ - You can always pass more than one option. As an example, if you want to edit the\n\ - global config.json file.\n\n\ - \t%command.full_name% --editor --global\n\n\ - Read more at https://getcomposer.org/doc/03-cli.md#config", - ); - Ok(()) - } - fn initialize( + pub(crate) fn handle_single_value( &self, - input: std::rc::Rc>, - output: std::rc::Rc>, + key: &str, + callbacks: &(ValidatorFn, NormalizerFn), + values: &[String], + method: &str, ) -> anyhow::Result<()> { - ::initialize( - self, - input.clone(), - output, - )?; + let (validator, normalizer) = callbacks; + if 1 != values.len() { + return Err(RuntimeException { + message: "You can only pass one value. Example: shirabe config process-timeout 300" + .to_string(), + code: 0, + } + .into()); + } - let config = self.config.borrow().as_ref().unwrap().clone(); - let auth_config_file = self.get_auth_config_file(input.clone(), &config.borrow())?; + let validation = validator(&PhpMixed::String(values[0].clone())); + if validation.as_bool() != Some(true) { + let suffix = if !validation.is_null() && validation.as_bool() != Some(false) { + format!(" ({})", validation.as_string().unwrap_or("")) + } else { + String::new() + }; + return Err(RuntimeException { + message: format!("\"{}\" is an invalid value{}", values[0].clone(), suffix), + code: 0, + } + .into()); + } - let auth_config_file_jf = std::rc::Rc::new(std::cell::RefCell::new(JsonFile::new( - auth_config_file, - None, - Some(self.get_io().clone()), - )?)); - *self.auth_config_file.borrow_mut() = Some(auth_config_file_jf.clone()); - *self.auth_config_source.borrow_mut() = - Some(JsonConfigSource::new(auth_config_file_jf, true)); + let normalized_value = normalizer(&PhpMixed::String(values[0].clone())); - // Initialize the global file if it's not there, ignoring any warnings or notices - let auth_config_file = self.auth_config_file.borrow().as_ref().unwrap().clone(); - if input.borrow().get_option("global")?.as_bool() == Some(true) - && !auth_config_file.borrow().exists() - { - touch(auth_config_file.borrow().get_path()); - let mut empty_objs: IndexMap = IndexMap::new(); - for k in &[ - "bitbucket-oauth", - "github-oauth", - "gitlab-oauth", - "gitlab-token", - "http-basic", - "bearer", - "forgejo-token", - ] { - empty_objs.insert(k.to_string(), PhpMixed::Object(IndexMap::new())); + if key == "disable-tls" { + let config = self.config.borrow().as_ref().unwrap().clone(); + if !normalized_value.as_bool().unwrap_or(false) + && config + .borrow() + .get("disable-tls") + .as_bool() + .unwrap_or(false) + { + self.get_io().write_error( + "You are now running Composer with SSL/TLS protection enabled.", + ); + } else if normalized_value.as_bool().unwrap_or(false) + && !config + .borrow() + .get("disable-tls") + .as_bool() + .unwrap_or(false) + { + self.get_io().write_error("You are now running Composer with SSL/TLS protection disabled."); } - auth_config_file - .borrow() - .write(PhpMixed::Array(empty_objs))?; - let path_clone = auth_config_file.borrow().get_path().to_string(); - Silencer::call(|| { - shirabe_php_shim::chmod(&path_clone, 0o600); - Ok(()) - }); + } + + let mut config_source = self.config_source.borrow_mut(); + let config_source = config_source.as_mut().unwrap(); + match method { + "addConfigSetting" => config_source.add_config_setting(key, normalized_value)?, + "addProperty" => config_source.add_property(key, normalized_value)?, + _ => unreachable!(), } Ok(()) } - fn execute( + pub(crate) fn handle_multi_value( &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - // Open file in editor - if input.borrow().get_option("editor")?.as_bool() == Some(true) { - let mut editor = Platform::get_env("EDITOR"); - if editor.is_none() || editor.as_deref() == Some("") { - if Platform::is_windows() { - editor = Some("notepad".to_string()); - } else { - for candidate in &["editor", "vim", "vi", "nano", "pico", "ed"] { - if !exec(&format!("which {}", candidate), None, None) - .unwrap_or_default() - .is_empty() - { - editor = Some(candidate.to_string()); - break; - } - } - } - } else { - editor = Some(escapeshellcmd(&editor.unwrap())); - } - - let file = if input.borrow().get_option("auth")?.as_bool() == Some(true) { - self.auth_config_file - .borrow() - .as_ref() - .unwrap() - .borrow() - .get_path() - .to_string() + key: &str, + callbacks: &(ValidatorFn, NormalizerFn), + values: &[String], + method: &str, + ) -> anyhow::Result<()> { + let (validator, normalizer) = callbacks; + let values_mixed = + PhpMixed::List(values.iter().map(|s| PhpMixed::String(s.clone())).collect()); + let validation = validator(&values_mixed); + if validation.as_bool() != Some(true) { + let suffix = if !validation.is_null() && validation.as_bool() != Some(false) { + format!(" ({})", validation.as_string().unwrap_or("")) } else { - self.config_file - .borrow() - .as_ref() - .unwrap() - .borrow() - .get_path() - .to_string() + String::new() }; - system( - &format!( - "{} {}{}", - editor.unwrap_or_default(), - file, - if Platform::is_windows() { - "" - } else { - " > `tty`" - } + return Err(RuntimeException { + message: format!( + "{} is an invalid value{}", + PhpMixed::from(json_encode(&values_mixed).ok()), + suffix ), - None, - ); + code: 0, + } + .into()); + } - return Ok(0); + let mut config_source = self.config_source.borrow_mut(); + let config_source = config_source.as_mut().unwrap(); + match method { + "addConfigSetting" => { + config_source.add_config_setting(key, normalizer(&values_mixed))? + } + "addProperty" => config_source.add_property(key, normalizer(&values_mixed))?, + _ => unreachable!(), } + Ok(()) + } - let config = self.config.borrow().as_ref().unwrap().clone(); - let config_file = self.config_file.borrow().as_ref().unwrap().clone(); - let auth_config_file = self.auth_config_file.borrow().as_ref().unwrap().clone(); - if input.borrow().get_option("global")?.as_bool() != Some(true) { - let config_read = config_file.borrow_mut().read()?; - let config_map = match config_read { - PhpMixed::Array(m) => m, - _ => IndexMap::new(), + /// Display the contents of the file in a pretty formatted way + pub(crate) fn list_configuration( + &self, + contents: PhpMixed, + raw_contents: PhpMixed, + output: std::rc::Rc>, + k: Option, + show_source: bool, + ) { + let orig_k = k.clone(); + let contents_arr = contents.as_array().cloned().unwrap_or_default(); + let raw_contents_arr = raw_contents.as_array().cloned().unwrap_or_default(); + let mut k = k; + for (key, value) in &contents_arr { + if k.is_none() && !matches!(key.as_str(), "config" | "repositories") { + continue; + } + + let raw_val = raw_contents_arr.get(key).cloned().unwrap_or(PhpMixed::Null); + + let value_inner = value.clone(); + + if is_array(&value_inner) + && (!is_numeric(&key_first_key(&value_inner).unwrap_or_default().into()) + || (key == "repositories" && k.is_none())) + { + let mut new_k = k.clone().unwrap_or_default(); + new_k.push_str(&Preg::replace( + php_regex!("{^config\\.}"), + "", + &format!("{}.", key), + )); + k = Some(new_k); + self.list_configuration( + value_inner, + raw_val, + output.clone(), + k.clone(), + show_source, + ); + k = orig_k.clone(); + + continue; + } + + let value_display: String = if is_array(&value_inner) { + let arr_strs: Vec = value_inner + .as_list() + .map(|l| { + l.iter() + .map(|val| { + if is_array(val) { + json_encode(val).unwrap_or_default() + } else { + val.as_string().unwrap_or("").to_string() + } + }) + .collect::>() + }) + .unwrap_or_default(); + format!("[{}]", implode(", ", &arr_strs)) + } else if is_bool(&value_inner) { + var_export(&value_inner, true) + } else { + value_inner.as_string().unwrap_or("").to_string() }; - let config_file_path = config_file.borrow().get_path().to_string(); - config.borrow_mut().merge(&config_map, &config_file_path); - let auth_data: PhpMixed = if auth_config_file.borrow().exists() { - auth_config_file.borrow_mut().read()? + + let source = if show_source { + format!( + " ({})", + self.config + .borrow() + .as_ref() + .unwrap() + .borrow_mut() + .get_source_of_value(&format!("{}{}", k.clone().unwrap_or_default(), key)) + ) } else { - PhpMixed::Array(IndexMap::new()) + String::new() }; - let mut wrap: IndexMap = IndexMap::new(); - wrap.insert("config".to_string(), auth_data); - let auth_config_file_path = auth_config_file.borrow().get_path().to_string(); - config.borrow_mut().merge(&wrap, &auth_config_file_path); - } - { - let config_rc = config.clone(); - self.get_io() - .borrow_mut() - .load_configuration(&mut config_rc.borrow_mut())?; + let link: String = + if k.is_some() && strpos(k.as_ref().unwrap(), "repositories") == Some(0) { + "https://getcomposer.org/doc/05-repositories.md".to_string() + } else { + let id_source = if k.as_deref() == Some("") || k.is_none() { + key.clone() + } else { + k.clone().unwrap() + }; + let id = Preg::replace(php_regex!("{\\..*$}"), "", &id_source); + let id = Preg::replace( + php_regex!("{[^a-z0-9]}i"), + "-", + &strtolower(&shirabe_php_shim::trim(&id, Some(" \t\n\r\0\u{0B}"))), + ); + let id = Preg::replace(php_regex!("{-+}"), "-", &id); + format!("https://getcomposer.org/doc/06-config.md#{}", id) + }; + if is_string(&raw_val) + && raw_val + .as_string() + .map(|s| s.to_string()) + .unwrap_or_default() + != value_display + { + self.get_io().write3( + &format!( + "[{}{}] {} ({}){}", + link, + k.clone().unwrap_or_default(), + key, + raw_val.as_string().unwrap_or(""), + value_display, + source + ), + true, + io_interface::QUIET, + ); + } else { + self.get_io().write3( + &format!( + "[{}{}] {}{}", + link, + k.clone().unwrap_or_default(), + key, + value_display, + source + ), + true, + io_interface::QUIET, + ); + } } + } - // List the configuration of the file settings - if input.borrow().get_option("list")?.as_bool() == Some(true) { - let all_map = config.borrow_mut().all(0)?; - let raw_map = config.borrow().raw(); - let to_mixed = |m: IndexMap| -> PhpMixed { - PhpMixed::Array(m.into_iter().collect()) - }; - self.list_configuration( - to_mixed(all_map), - to_mixed(raw_map), - output, - None, - input.borrow().get_option("source")?.as_bool() == Some(true), - ); + /// Suggest setting-keys, while taking given options in account. + fn suggest_setting_keys(&self) -> crate::console::input::SuggestedValues { + crate::console::input::SuggestedValues::Closure(Box::new(|this, input, _suggestions| { + if input.get_option("list")?.to_bool() + || input.get_option("editor")?.to_bool() + || input.get_option("auth")?.to_bool() + { + return Ok(vec![]); + } - return Ok(0); - } + let this = this + .as_any() + .downcast_ref::() + .expect("suggestSettingKeys is bound to ConfigCommand"); + // PHP passes the CompletionInput itself; the accessors only read from it, so a + // clone behind a fresh handle is equivalent. + let input_handle: std::rc::Rc< + std::cell::RefCell< + dyn shirabe_external_packages::symfony::console::input::InputInterface, + >, + > = std::rc::Rc::new(std::cell::RefCell::new(input.clone())); - let setting_key_arg = input.borrow().get_argument("setting-key")?; - let setting_key = match setting_key_arg.as_string() { - Some(s) => s.to_string(), - None => return Ok(0), - }; + // initialize configuration + let mut config = Factory::create_config(None, None)?; - // If the user enters in a config variable, parse it and save to file - let setting_values_raw = input.borrow().get_argument("setting-value")?; - let setting_values: Vec = setting_values_raw - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - if !setting_values.is_empty() && input.borrow().get_option("unset")?.as_bool() == Some(true) - { - return Err(RuntimeException { - message: "You can not combine a setting value with --unset".to_string(), - code: 0, + // load configuration + let config_file = JsonFile::new( + this.get_composer_config_file(input_handle.clone(), &config)?, + None, + None, + )?; + if config_file.exists() { + let path = config_file.get_path().to_string(); + let data = config_file.read()?.as_array().cloned().unwrap_or_default(); + config.merge(&data, &path); } - .into()); - } - // show the value if no value is provided - if setting_values.is_empty() && input.borrow().get_option("unset")?.as_bool() != Some(true) - { - let properties: Vec<&'static str> = Self::CONFIGURABLE_PACKAGE_PROPERTIES.to_vec(); - let mut properties_defaults: IndexMap = IndexMap::new(); - properties_defaults.insert("type".to_string(), PhpMixed::String("library".to_string())); - properties_defaults.insert("description".to_string(), PhpMixed::String(String::new())); - properties_defaults.insert("homepage".to_string(), PhpMixed::String(String::new())); - properties_defaults.insert( - "minimum-stability".to_string(), - PhpMixed::String("stable".to_string()), - ); - properties_defaults.insert("prefer-stable".to_string(), PhpMixed::Bool(false)); - properties_defaults.insert("keywords".to_string(), PhpMixed::List(vec![])); - properties_defaults.insert("license".to_string(), PhpMixed::List(vec![])); - properties_defaults.insert("suggest".to_string(), PhpMixed::List(vec![])); - properties_defaults.insert("extra".to_string(), PhpMixed::List(vec![])); - let raw_data = config_file.borrow_mut().read()?; - let data = config.borrow_mut().all(0)?; - let mut source = config.borrow_mut().get_source_of_value(&setting_key); + // load auth-configuration + let auth_config_file = JsonFile::new( + this.get_auth_config_file(input_handle.clone(), &config)?, + None, + None, + )?; + if auth_config_file.exists() { + let path = auth_config_file.get_path().to_string(); + let mut data = IndexMap::new(); + data.insert("config".to_string(), auth_config_file.read()?); + config.merge(&data, &path); + } - let mut value: PhpMixed; - let mut matches: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("/^repos?(?:itories)?(?:\\.(.+))?/"), - &setting_key, - Some(&mut matches), - ) { - if matches.get(&CaptureKey::ByIndex(1)).is_none() { - value = data - .get("repositories") - .cloned() - .unwrap_or_else(|| PhpMixed::Array(IndexMap::new())); - } else { - let repo_key = matches - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - let repos = data.get("repositories").cloned(); - value = match repos - .as_ref() - .and_then(|r| r.as_array().and_then(|a| a.get(&repo_key))) - { - Some(v) => v.clone(), - None => { - return Err(InvalidArgumentException { - message: format!("There is no {} repository defined", repo_key), - code: 0, - } - .into()); - } - }; - } - } else if strpos(&setting_key, ".").is_some() { - let bits = explode(".", &setting_key); - // PHP: $data here is the mixed dot-segment cursor; the rest of the loop walks it. - let mut cursor: PhpMixed = if bits[0] == "extra" || bits[0] == "suggest" { - PhpMixed::Array(raw_data.as_array().cloned().unwrap_or_else(IndexMap::new)) - } else { - data.get("config").cloned().unwrap_or(PhpMixed::Null) - }; - let mut r#match = false; - let mut key_acc: Option = None; - for bit in &bits { - let new_key = match &key_acc { - Some(k) => format!("{}.{}", k, bit), - None => bit.clone(), - }; - key_acc = Some(new_key.clone()); - r#match = false; - if let Some(arr) = cursor.as_array() - && let Some(v) = arr.get(&new_key) - { - r#match = true; - cursor = v.clone(); - key_acc = None; - } - } - - if !r#match { - return Err(RuntimeException { - message: format!("{} is not defined.", setting_key), - code: 0, - } - .into()); - } + // collect all configuration setting-keys + let raw_config = config.raw(); + let mut keys = flatten_setting_keys( + raw_config.get("config").cloned().unwrap_or(PhpMixed::Null), + "", + ); + keys.extend(flatten_setting_keys( + raw_config + .get("repositories") + .cloned() + .unwrap_or(PhpMixed::Null), + "repositories.", + )); - value = cursor; - } else if data - .get("config") - .and_then(|c| c.as_array()) - .map(|c| c.contains_key(&setting_key)) - .unwrap_or(false) - { - value = config.borrow_mut().get_with_flags( - &setting_key, - if input.borrow().get_option("absolute")?.as_bool() == Some(true) { - 0 - } else { - Config::RELATIVE_PATHS - }, - )?; - // ensure we get {} output for properties which are objects - if value.as_array().map(|a| a.is_empty()).unwrap_or(false) { - let schema = JsonFile::parse_json( - Some(JsonFile::COMPOSER_SCHEMA_JSON), - Some("composer.schema.json"), - )?; - let type_value = schema - .as_array() - .and_then(|a| a.get("properties")) - .and_then(|v| v.as_array()) - .and_then(|a| a.get("config")) - .and_then(|v| v.as_array()) - .and_then(|a| a.get("properties")) - .and_then(|v| v.as_array()) - .and_then(|a| a.get(&setting_key)) - .and_then(|v| v.as_array()) - .and_then(|a| a.get("type")) - .cloned(); - if let Some(tv) = type_value { - let type_array = match &tv { - PhpMixed::List(_) | PhpMixed::Array(_) => tv, - other => PhpMixed::List(vec![other.clone()]), - }; - let type_strings: Vec = type_array - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect::>() - }) - .unwrap_or_default(); - if type_strings.iter().any(|s| s == "object") { - value = PhpMixed::Object(IndexMap::new()); - } - } - } - } else if raw_data - .as_array() - .and_then(|a| a.get(&setting_key)) - .is_some() - && in_array_strict( - setting_key.as_str(), - &properties - .iter() - .map(|s| PhpMixed::String(s.to_string())) - .collect::>(), - ) - { - value = raw_data - .as_array() - .unwrap() - .get(&setting_key) - .unwrap() - .clone(); - source = config_file.borrow().get_path().to_string(); - } else if let Some(v) = properties_defaults.get(&setting_key) { - value = v.clone(); - source = "defaults".to_string(); - } else { - return Err(RuntimeException { - message: format!("{} is not defined", setting_key), - code: 0, - } - .into()); - } + // if unsetting … + if input.get_option("unset")?.to_bool() { + // … keep only the currently customized setting-keys … + let sources = [ + config_file.get_path().to_string(), + auth_config_file.get_path().to_string(), + ]; + keys.retain(|key| sources.contains(&config.get_source_of_value(key))); - let value_str = if is_array(&value) || is_object(&value) || is_bool(&value) { - JsonFile::encode_with_options( - &value, - JsonEncodeOptions { - pretty_print: false, - ..Default::default() - }, - )? + // … else if showing or setting a value … } else { - value.as_string().unwrap_or("").to_string() - }; + // … add all configurable package-properties, no matter if it exist + keys.extend( + Self::CONFIGURABLE_PACKAGE_PROPERTIES + .iter() + .map(|property| property.to_string()), + ); - let mut source_of_config_value = String::new(); - if input.borrow().get_option("source")?.as_bool() == Some(true) { - source_of_config_value = format!(" ({})", source); + // it would be nice to distinguish between showing and setting + // a value, but that makes the implementation much more complex + // and partially impossible because symfony's implementation + // does not complete arguments followed by other arguments } - self.get_io().write3( - &format!("{}{}", value_str, source_of_config_value), - true, - io_interface::QUIET, - ); + // add all existing configurable package-properties + if config_file.exists() { + let properties: IndexMap = config_file + .read()? + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|(key, _)| { + Self::CONFIGURABLE_PACKAGE_PROPERTIES.contains(&key.as_str()) + }) + .collect(); - return Ok(0); - } + keys.extend(flatten_setting_keys(PhpMixed::Array(properties), "")); + } - let values: Vec = setting_values; // what the user is trying to add/change + // filter settings-keys by completion value + let completion_value = input.get_completion_value(); - let boolean_validator = |val: &PhpMixed| -> bool { - matches!(val.as_string().unwrap_or(""), "true" | "false" | "1" | "0") - }; - let boolean_normalizer = |val: &PhpMixed| -> PhpMixed { - let s = val.as_string().unwrap_or(""); - PhpMixed::Bool(s != "false" && !s.is_empty() && s != "0") - }; + if !completion_value.is_empty() { + keys.retain(|key| key.starts_with(&completion_value)); + } - // handle config values - let unique_config_values = build_unique_config_values(); - let multi_config_values = build_multi_config_values(); + keys.sort(); - // allow unsetting audit config entirely - if input.borrow().get_option("unset")?.as_bool() == Some(true) && setting_key == "audit" { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&setting_key); + keys.dedup(); + Ok(keys) + })) + } +} - return Ok(0); - } +impl Default for ConfigCommand { + fn default() -> Self { + Self::new() + } +} - if input.borrow().get_option("unset")?.as_bool() == Some(true) - && (unique_config_values.contains_key(&setting_key) - || multi_config_values.contains_key(&setting_key)) - { - if setting_key == "disable-tls" - && config - .borrow() - .get("disable-tls") - .as_bool() - .unwrap_or(false) - { - self.get_io().write_error( - "You are now running Composer with SSL/TLS protection enabled.", - ); - } - - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&setting_key); +impl Command for ConfigCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("config")?; + self.set_description("Sets config options"); + self.set_definition(&[ + InputOption::new("global", Some(PhpMixed::String("g".to_string())), Some(InputOption::VALUE_NONE), "Apply command to the global config file", None).unwrap().into(), + InputOption::new("editor", Some(PhpMixed::String("e".to_string())), Some(InputOption::VALUE_NONE), "Open editor", None).unwrap().into(), + InputOption::new("auth", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_NONE), "Affect auth config file (only used for --editor)", None).unwrap().into(), + InputOption::new("unset", None, Some(InputOption::VALUE_NONE), "Unset the given setting-key", None).unwrap().into(), + InputOption::new("list", Some(PhpMixed::String("l".to_string())), Some(InputOption::VALUE_NONE), "List configuration settings", None).unwrap().into(), + InputOption::new("file", Some(PhpMixed::String("f".to_string())), Some(InputOption::VALUE_REQUIRED), "If you want to choose a different composer.json or config.json", None).unwrap().into(), + InputOption::new("absolute", None, Some(InputOption::VALUE_NONE), "Returns absolute paths when fetching *-dir config values instead of relative", None).unwrap().into(), + InputOption::new("json", Some(PhpMixed::String("j".to_string())), Some(InputOption::VALUE_NONE), "JSON decode the setting value, to be used with extra.* keys", None).unwrap().into(), + InputOption::new("merge", Some(PhpMixed::String("m".to_string())), Some(InputOption::VALUE_NONE), "Merge the setting value with the current value, to be used with extra.* or audit.ignore[-abandoned] keys in combination with --json", None).unwrap().into(), + InputOption::new("append", None, Some(InputOption::VALUE_NONE), "When adding a repository, append it (lowest priority) to the existing ones instead of prepending it (highest priority)", None).unwrap().into(), + InputOption::new("source", None, Some(InputOption::VALUE_NONE), "Display where the config value is loaded from", None).unwrap().into(), + InputArgument::new5("setting-key", None, "Setting key", None, self.suggest_setting_keys()).unwrap().into(), + InputArgument::new("setting-value", Some(InputArgument::IS_ARRAY), "Setting value", None).unwrap().into(), + ]); + self.set_help( + "This command allows you to edit composer config settings and repositories\n\ + in either the local composer.json file or the global config.json file.\n\n\ + Additionally it lets you edit most properties in the local composer.json.\n\n\ + To set a config setting:\n\n\ + \t%command.full_name% bin-dir bin/\n\n\ + To read a config setting:\n\n\ + \t%command.full_name% bin-dir\n\ + \tOutputs: bin\n\n\ + To edit the global config.json file:\n\n\ + \t%command.full_name% --global\n\n\ + To add a repository:\n\n\ + \t%command.full_name% repositories.foo vcs https://bar.com\n\n\ + To remove a repository (repo is a short alias for repositories):\n\n\ + \t%command.full_name% --unset repo.foo\n\n\ + To disable packagist.org:\n\n\ + \t%command.full_name% repo.packagist.org false\n\n\ + You can alter repositories in the global config.json file by passing in the\n\ + --global option.\n\n\ + To add or edit suggested packages you can use:\n\n\ + \t%command.full_name% suggest.package reason for the suggestion\n\n\ + To add or edit extra properties you can use:\n\n\ + \t%command.full_name% extra.property value\n\n\ + Or to add a complex value you can use json with:\n\n\ + \t%command.full_name% extra.property --json '{\"foo\":true, \"bar\": []}'\n\n\ + To edit the file in an external editor:\n\n\ + \t%command.full_name% --editor\n\n\ + To choose your editor you can set the \"EDITOR\" env variable.\n\n\ + To get a list of configuration values in the file:\n\n\ + \t%command.full_name% --list\n\n\ + You can always pass more than one option. As an example, if you want to edit the\n\ + global config.json file.\n\n\ + \t%command.full_name% --editor --global\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#config", + ); + Ok(()) + } - return Ok(0); - } - if let Some(callbacks) = unique_config_values.get(&setting_key) { - self.handle_single_value(&setting_key, callbacks, &values, "addConfigSetting")?; + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + ::initialize( + self, + input.clone(), + output, + )?; - return Ok(0); - } - if let Some(callbacks) = multi_config_values.get(&setting_key) { - self.handle_multi_value(&setting_key, callbacks, &values, "addConfigSetting")?; + let config = self.config.borrow().as_ref().unwrap().clone(); + let auth_config_file = self.get_auth_config_file(input.clone(), &config.borrow())?; - return Ok(0); - } - // handle preferred-install per-package config - let mut matches: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("/^preferred-install\\.(.+)/"), - &setting_key, - Some(&mut matches), - ) { - if input.borrow().get_option("unset")?.as_bool() == Some(true) { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&setting_key); + let auth_config_file_jf = std::rc::Rc::new(std::cell::RefCell::new(JsonFile::new( + auth_config_file, + None, + Some(self.get_io().clone()), + )?)); + *self.auth_config_file.borrow_mut() = Some(auth_config_file_jf.clone()); + *self.auth_config_source.borrow_mut() = + Some(JsonConfigSource::new(auth_config_file_jf, true)); - return Ok(0); + // Initialize the global file if it's not there, ignoring any warnings or notices + let auth_config_file = self.auth_config_file.borrow().as_ref().unwrap().clone(); + if input.borrow().get_option("global")?.as_bool() == Some(true) + && !auth_config_file.borrow().exists() + { + touch(auth_config_file.borrow().get_path()); + let mut empty_objs: IndexMap = IndexMap::new(); + for k in &[ + "bitbucket-oauth", + "github-oauth", + "gitlab-oauth", + "gitlab-token", + "http-basic", + "bearer", + "forgejo-token", + ] { + empty_objs.insert(k.to_string(), PhpMixed::Object(IndexMap::new())); } + auth_config_file + .borrow() + .write(PhpMixed::Array(empty_objs))?; + let path_clone = auth_config_file.borrow().get_path().to_string(); + Silencer::call(|| { + shirabe_php_shim::chmod(&path_clone, 0o600); + Ok(()) + }); + } + Ok(()) + } - let validator = &unique_config_values.get("preferred-install").unwrap().0; - if !validator(&PhpMixed::String(values[0].clone())) - .as_bool() - .unwrap_or(false) - { - return Err(RuntimeException { - message: format!( - "Invalid value for {}. Should be one of: auto, source, or dist", - setting_key - ), - code: 0, + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + // Open file in editor + if input.borrow().get_option("editor")?.as_bool() == Some(true) { + let mut editor = Platform::get_env("EDITOR"); + if editor.is_none() || editor.as_deref() == Some("") { + if Platform::is_windows() { + editor = Some("notepad".to_string()); + } else { + for candidate in &["editor", "vim", "vi", "nano", "pico", "ed"] { + if !exec(&format!("which {}", candidate), None, None) + .unwrap_or_default() + .is_empty() + { + editor = Some(candidate.to_string()); + break; + } + } } - .into()); + } else { + editor = Some(escapeshellcmd(&editor.unwrap())); } - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .add_config_setting(&setting_key, PhpMixed::String(values[0].clone())); + let file = if input.borrow().get_option("auth")?.as_bool() == Some(true) { + self.auth_config_file + .borrow() + .as_ref() + .unwrap() + .borrow() + .get_path() + .to_string() + } else { + self.config_file + .borrow() + .as_ref() + .unwrap() + .borrow() + .get_path() + .to_string() + }; + system( + &format!( + "{} {}{}", + editor.unwrap_or_default(), + file, + if Platform::is_windows() { + "" + } else { + " > `tty`" + } + ), + None, + ); return Ok(0); } - // handle allow-plugins config setting elements true or false to add/remove - let mut matches: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("{^allow-plugins\\.([a-zA-Z0-9/*-]+)}"), - &setting_key, - Some(&mut matches), - ) { - if input.borrow().get_option("unset")?.as_bool() == Some(true) { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&setting_key); - - return Ok(0); - } - - if !boolean_validator(&PhpMixed::String(values[0].clone())) { - return Err(RuntimeException { - message: format!("\"{}\" is an invalid value", values[0].clone()), - code: 0, - } - .into()); - } - - let normalized_value = boolean_normalizer(&PhpMixed::String(values[0].clone())); + let config = self.config.borrow().as_ref().unwrap().clone(); + let config_file = self.config_file.borrow().as_ref().unwrap().clone(); + let auth_config_file = self.auth_config_file.borrow().as_ref().unwrap().clone(); + if input.borrow().get_option("global")?.as_bool() != Some(true) { + let config_read = config_file.borrow_mut().read()?; + let config_map = match config_read { + PhpMixed::Array(m) => m, + _ => IndexMap::new(), + }; + let config_file_path = config_file.borrow().get_path().to_string(); + config.borrow_mut().merge(&config_map, &config_file_path); + let auth_data: PhpMixed = if auth_config_file.borrow().exists() { + auth_config_file.borrow_mut().read()? + } else { + PhpMixed::Array(IndexMap::new()) + }; + let mut wrap: IndexMap = IndexMap::new(); + wrap.insert("config".to_string(), auth_data); + let auth_config_file_path = auth_config_file.borrow().get_path().to_string(); + config.borrow_mut().merge(&wrap, &auth_config_file_path); + } - self.config_source + { + let config_rc = config.clone(); + self.get_io() .borrow_mut() - .as_mut() - .unwrap() - .add_config_setting(&setting_key, normalized_value); + .load_configuration(&mut config_rc.borrow_mut())?; + } + + // List the configuration of the file settings + if input.borrow().get_option("list")?.as_bool() == Some(true) { + let all_map = config.borrow_mut().all(0)?; + let raw_map = config.borrow().raw(); + let to_mixed = |m: IndexMap| -> PhpMixed { + PhpMixed::Array(m.into_iter().collect()) + }; + self.list_configuration( + to_mixed(all_map), + to_mixed(raw_map), + output, + None, + input.borrow().get_option("source")?.as_bool() == Some(true), + ); return Ok(0); } - // handle properties - let unique_props = build_unique_props(); - let multi_props = build_multi_props(); + let setting_key_arg = input.borrow().get_argument("setting-key")?; + let setting_key = match setting_key_arg.as_string() { + Some(s) => s.to_string(), + None => return Ok(0), + }; - if input.borrow().get_option("global")?.as_bool() == Some(true) - && (unique_props.contains_key(&setting_key) - || multi_props.contains_key(&setting_key) - || strpos(&setting_key, "extra.") == Some(0)) + // If the user enters in a config variable, parse it and save to file + let setting_values_raw = input.borrow().get_argument("setting-value")?; + let setting_values: Vec = setting_values_raw + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if !setting_values.is_empty() && input.borrow().get_option("unset")?.as_bool() == Some(true) { - return Err(InvalidArgumentException { - message: format!("The {} property can not be set in the global config.json file. Use `composer global config` to apply changes to the global composer.json", setting_key), + return Err(RuntimeException { + message: "You can not combine a setting value with --unset".to_string(), code: 0, } .into()); } - if input.borrow().get_option("unset")?.as_bool() == Some(true) - && (unique_props.contains_key(&setting_key) || multi_props.contains_key(&setting_key)) - { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_property(&setting_key); - return Ok(0); - } - if let Some(callbacks) = unique_props.get(&setting_key) { - self.handle_single_value(&setting_key, callbacks, &values, "addProperty")?; + // show the value if no value is provided + if setting_values.is_empty() && input.borrow().get_option("unset")?.as_bool() != Some(true) + { + let properties: Vec<&'static str> = Self::CONFIGURABLE_PACKAGE_PROPERTIES.to_vec(); + let mut properties_defaults: IndexMap = IndexMap::new(); + properties_defaults.insert("type".to_string(), PhpMixed::String("library".to_string())); + properties_defaults.insert("description".to_string(), PhpMixed::String(String::new())); + properties_defaults.insert("homepage".to_string(), PhpMixed::String(String::new())); + properties_defaults.insert( + "minimum-stability".to_string(), + PhpMixed::String("stable".to_string()), + ); + properties_defaults.insert("prefer-stable".to_string(), PhpMixed::Bool(false)); + properties_defaults.insert("keywords".to_string(), PhpMixed::List(vec![])); + properties_defaults.insert("license".to_string(), PhpMixed::List(vec![])); + properties_defaults.insert("suggest".to_string(), PhpMixed::List(vec![])); + properties_defaults.insert("extra".to_string(), PhpMixed::List(vec![])); + let raw_data = config_file.borrow_mut().read()?; + let data = config.borrow_mut().all(0)?; + let mut source = config.borrow_mut().get_source_of_value(&setting_key); - return Ok(0); - } - if let Some(callbacks) = multi_props.get(&setting_key) { - self.handle_multi_value(&setting_key, callbacks, &values, "addProperty")?; + let mut value: PhpMixed; + let mut matches: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("/^repos?(?:itories)?(?:\\.(.+))?/"), + &setting_key, + Some(&mut matches), + ) { + if matches.get(&CaptureKey::ByIndex(1)).is_none() { + value = data + .get("repositories") + .cloned() + .unwrap_or_else(|| PhpMixed::Array(IndexMap::new())); + } else { + let repo_key = matches + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); + let repos = data.get("repositories").cloned(); + value = match repos + .as_ref() + .and_then(|r| r.as_array().and_then(|a| a.get(&repo_key))) + { + Some(v) => v.clone(), + None => { + return Err(InvalidArgumentException { + message: format!("There is no {} repository defined", repo_key), + code: 0, + } + .into()); + } + }; + } + } else if strpos(&setting_key, ".").is_some() { + let bits = explode(".", &setting_key); + // PHP: $data here is the mixed dot-segment cursor; the rest of the loop walks it. + let mut cursor: PhpMixed = if bits[0] == "extra" || bits[0] == "suggest" { + PhpMixed::Array(raw_data.as_array().cloned().unwrap_or_else(IndexMap::new)) + } else { + data.get("config").cloned().unwrap_or(PhpMixed::Null) + }; + let mut r#match = false; + let mut key_acc: Option = None; + for bit in &bits { + let new_key = match &key_acc { + Some(k) => format!("{}.{}", k, bit), + None => bit.clone(), + }; + key_acc = Some(new_key.clone()); + r#match = false; + if let Some(arr) = cursor.as_array() + && let Some(v) = arr.get(&new_key) + { + r#match = true; + cursor = v.clone(); + key_acc = None; + } + } - return Ok(0); - } + if !r#match { + return Err(RuntimeException { + message: format!("{} is not defined.", setting_key), + code: 0, + } + .into()); + } - // handle repositories - let mut matches: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("/^repos?(?:itories)?\\.(.+)/"), - &setting_key, - Some(&mut matches), - ) { - if input.borrow().get_option("unset")?.as_bool() == Some(true) { - self.config_source - .borrow_mut() - .as_mut() + value = cursor; + } else if data + .get("config") + .and_then(|c| c.as_array()) + .map(|c| c.contains_key(&setting_key)) + .unwrap_or(false) + { + value = config.borrow_mut().get_with_flags( + &setting_key, + if input.borrow().get_option("absolute")?.as_bool() == Some(true) { + 0 + } else { + Config::RELATIVE_PATHS + }, + )?; + // ensure we get {} output for properties which are objects + if value.as_array().map(|a| a.is_empty()).unwrap_or(false) { + let schema = JsonFile::parse_json( + Some(JsonFile::COMPOSER_SCHEMA_JSON), + Some("composer.schema.json"), + )?; + let type_value = schema + .as_array() + .and_then(|a| a.get("properties")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get("config")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get("properties")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get(&setting_key)) + .and_then(|v| v.as_array()) + .and_then(|a| a.get("type")) + .cloned(); + if let Some(tv) = type_value { + let type_array = match &tv { + PhpMixed::List(_) | PhpMixed::Array(_) => tv, + other => PhpMixed::List(vec![other.clone()]), + }; + let type_strings: Vec = type_array + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + if type_strings.iter().any(|s| s == "object") { + value = PhpMixed::Object(IndexMap::new()); + } + } + } + } else if raw_data + .as_array() + .and_then(|a| a.get(&setting_key)) + .is_some() + && in_array_strict( + setting_key.as_str(), + &properties + .iter() + .map(|s| PhpMixed::String(s.to_string())) + .collect::>(), + ) + { + value = raw_data + .as_array() .unwrap() - .remove_repository(&matches[1]); - - return Ok(0); - } - - if 2 == values.len() { - let mut repo: IndexMap = IndexMap::new(); - repo.insert("type".to_string(), PhpMixed::String(values[0].clone())); - repo.insert("url".to_string(), PhpMixed::String(values[1].clone())); - self.config_source - .borrow_mut() - .as_mut() + .get(&setting_key) .unwrap() - .add_repository( - &matches[1], - PhpMixed::Array(repo), - input.borrow().get_option("append")?.as_bool() == Some(true), - ); - - return Ok(0); + .clone(); + source = config_file.borrow().get_path().to_string(); + } else if let Some(v) = properties_defaults.get(&setting_key) { + value = v.clone(); + source = "defaults".to_string(); + } else { + return Err(RuntimeException { + message: format!("{} is not defined", setting_key), + code: 0, + } + .into()); } - if 1 == values.len() { + let value_str = if is_array(&value) || is_object(&value) || is_bool(&value) { + JsonFile::encode_with_options( + &value, + JsonEncodeOptions { + pretty_print: false, + ..Default::default() + }, + )? + } else { + value.as_string().unwrap_or("").to_string() + }; + + let mut source_of_config_value = String::new(); + if input.borrow().get_option("source")?.as_bool() == Some(true) { + source_of_config_value = format!(" ({})", source); + } + + self.get_io().write3( + &format!("{}{}", value_str, source_of_config_value), + true, + io_interface::QUIET, + ); + + return Ok(0); + } + + let values: Vec = setting_values; // what the user is trying to add/change + + let boolean_validator = |val: &PhpMixed| -> bool { + matches!(val.as_string().unwrap_or(""), "true" | "false" | "1" | "0") + }; + let boolean_normalizer = |val: &PhpMixed| -> PhpMixed { + let s = val.as_string().unwrap_or(""); + PhpMixed::Bool(s != "false" && !s.is_empty() && s != "0") + }; + + // handle config values + let unique_config_values = build_unique_config_values(); + let multi_config_values = build_multi_config_values(); + + // allow unsetting audit config entirely + if input.borrow().get_option("unset")?.as_bool() == Some(true) && setting_key == "audit" { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&setting_key); + + return Ok(0); + } + + if input.borrow().get_option("unset")?.as_bool() == Some(true) + && (unique_config_values.contains_key(&setting_key) + || multi_config_values.contains_key(&setting_key)) + { + if setting_key == "disable-tls" + && config + .borrow() + .get("disable-tls") + .as_bool() + .unwrap_or(false) + { + self.get_io().write_error( + "You are now running Composer with SSL/TLS protection enabled.", + ); + } + + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&setting_key); + + return Ok(0); + } + if let Some(callbacks) = unique_config_values.get(&setting_key) { + self.handle_single_value(&setting_key, callbacks, &values, "addConfigSetting")?; + + return Ok(0); + } + if let Some(callbacks) = multi_config_values.get(&setting_key) { + self.handle_multi_value(&setting_key, callbacks, &values, "addConfigSetting")?; + + return Ok(0); + } + // handle preferred-install per-package config + let mut matches: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("/^preferred-install\\.(.+)/"), + &setting_key, + Some(&mut matches), + ) { + if input.borrow().get_option("unset")?.as_bool() == Some(true) { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&setting_key); + + return Ok(0); + } + + let validator = &unique_config_values.get("preferred-install").unwrap().0; + if !validator(&PhpMixed::String(values[0].clone())) + .as_bool() + .unwrap_or(false) + { + return Err(RuntimeException { + message: format!( + "Invalid value for {}. Should be one of: auto, source, or dist", + setting_key + ), + code: 0, + } + .into()); + } + + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_config_setting(&setting_key, PhpMixed::String(values[0].clone())); + + return Ok(0); + } + + // handle allow-plugins config setting elements true or false to add/remove + let mut matches: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("{^allow-plugins\\.([a-zA-Z0-9/*-]+)}"), + &setting_key, + Some(&mut matches), + ) { + if input.borrow().get_option("unset")?.as_bool() == Some(true) { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&setting_key); + + return Ok(0); + } + + if !boolean_validator(&PhpMixed::String(values[0].clone())) { + return Err(RuntimeException { + message: format!("\"{}\" is an invalid value", values[0].clone()), + code: 0, + } + .into()); + } + + let normalized_value = boolean_normalizer(&PhpMixed::String(values[0].clone())); + + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_config_setting(&setting_key, normalized_value); + + return Ok(0); + } + + // handle properties + let unique_props = build_unique_props(); + let multi_props = build_multi_props(); + + if input.borrow().get_option("global")?.as_bool() == Some(true) + && (unique_props.contains_key(&setting_key) + || multi_props.contains_key(&setting_key) + || strpos(&setting_key, "extra.") == Some(0)) + { + return Err(InvalidArgumentException { + message: format!("The {} property can not be set in the global config.json file. Use `composer global config` to apply changes to the global composer.json", setting_key), + code: 0, + } + .into()); + } + if input.borrow().get_option("unset")?.as_bool() == Some(true) + && (unique_props.contains_key(&setting_key) || multi_props.contains_key(&setting_key)) + { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_property(&setting_key); + + return Ok(0); + } + if let Some(callbacks) = unique_props.get(&setting_key) { + self.handle_single_value(&setting_key, callbacks, &values, "addProperty")?; + + return Ok(0); + } + if let Some(callbacks) = multi_props.get(&setting_key) { + self.handle_multi_value(&setting_key, callbacks, &values, "addProperty")?; + + return Ok(0); + } + + // handle repositories + let mut matches: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("/^repos?(?:itories)?\\.(.+)/"), + &setting_key, + Some(&mut matches), + ) { + if input.borrow().get_option("unset")?.as_bool() == Some(true) { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_repository(&matches[1]); + + return Ok(0); + } + + if 2 == values.len() { + let mut repo: IndexMap = IndexMap::new(); + repo.insert("type".to_string(), PhpMixed::String(values[0].clone())); + repo.insert("url".to_string(), PhpMixed::String(values[1].clone())); + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_repository( + &matches[1], + PhpMixed::Array(repo), + input.borrow().get_option("append")?.as_bool() == Some(true), + ); + + return Ok(0); + } + + if 1 == values.len() { let value = strtolower(&values[0]); if boolean_validator(&PhpMixed::String(value.clone())) { if !boolean_normalizer(&PhpMixed::String(value)) @@ -1099,542 +1457,180 @@ impl Command for ConfigCommand { .as_mut() .unwrap() .add_config_setting(&key, PhpMixed::String(values[0].clone())); - } else if matches[1] == "http-basic" { - if 2 != values.len() { - return Err(RuntimeException { - message: format!( - "Expected two arguments (username, password), got {}", - values.len() - ), - code: 0, - } - .into()); - } - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&key); - let mut obj: IndexMap = IndexMap::new(); - obj.insert("username".to_string(), PhpMixed::String(values[0].clone())); - obj.insert("password".to_string(), PhpMixed::String(values[1].clone())); - self.auth_config_source - .borrow_mut() - .as_mut() - .unwrap() - .add_config_setting(&key, PhpMixed::Array(obj)); - } else if matches[1] == "custom-headers" { - if values.is_empty() { - return Err(RuntimeException { - message: "Expected at least one argument (header), got none".to_string(), - code: 0, - } - .into()); - } - - // Validate headers format - let mut formatted_headers: Vec = vec![]; - for header in &values { - if !is_string(&PhpMixed::String(header.clone())) { - return Err(RuntimeException { - message: - "Headers must be strings in \"Header-Name: Header-Value\" format" - .to_string(), - code: 0, - } - .into()); - } - - // Check if the header is in correct "Name: Value" format - let mut header_parts: IndexMap = IndexMap::new(); - if !Preg::is_match3( - php_regex!("/^[^:]+:\\s*.+$/"), - header, - Some(&mut header_parts), - ) { - return Err(RuntimeException { - message: format!( - "Header \"{}\" is not in \"Header-Name: Header-Value\" format", - header - ), - code: 0, - } - .into()); - } - - formatted_headers.push(PhpMixed::String(header.clone())); - } - - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&key); - self.auth_config_source - .borrow_mut() - .as_mut() - .unwrap() - .add_config_setting(&key, PhpMixed::List(formatted_headers)); - } else if matches[1] == "forgejo-token" { - if 2 != values.len() { - return Err(RuntimeException { - message: format!( - "Expected two arguments (username, access token), got {}", - values.len() - ), - code: 0, - } - .into()); - } - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_config_setting(&key); - let mut obj: IndexMap = IndexMap::new(); - obj.insert("username".to_string(), PhpMixed::String(values[0].clone())); - obj.insert("token".to_string(), PhpMixed::String(values[1].clone())); - self.auth_config_source - .borrow_mut() - .as_mut() - .unwrap() - .add_config_setting(&key, PhpMixed::Array(obj)); - } - - return Ok(0); - } - - // handle script - let mut matches: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("/^scripts\\.(.+)/"), - &setting_key, - Some(&mut matches), - ) { - if input.borrow().get_option("unset")?.as_bool() == Some(true) { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_property(&setting_key); - - return Ok(0); - } - - let value: PhpMixed = if values.len() > 1 { - PhpMixed::List(values.iter().map(|s| PhpMixed::String(s.clone())).collect()) - } else { - PhpMixed::String(values[0].clone()) - }; - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .add_property(&setting_key, value); - - return Ok(0); - } - - // handle unsetting other top level properties - if input.borrow().get_option("unset")?.as_bool() == Some(true) { - self.config_source - .borrow_mut() - .as_mut() - .unwrap() - .remove_property(&setting_key); - - return Ok(0); - } - - Err(InvalidArgumentException { - message: format!( - "Setting {} does not exist or is not supported by this command", - setting_key - ), - code: 0, - } - .into()) - } - - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } - - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} - -impl BaseCommand for ConfigCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } - - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} - -impl ConfigCommand { - pub(crate) fn handle_single_value( - &self, - key: &str, - callbacks: &(ValidatorFn, NormalizerFn), - values: &[String], - method: &str, - ) -> anyhow::Result<()> { - let (validator, normalizer) = callbacks; - if 1 != values.len() { - return Err(RuntimeException { - message: "You can only pass one value. Example: shirabe config process-timeout 300" - .to_string(), - code: 0, - } - .into()); - } - - let validation = validator(&PhpMixed::String(values[0].clone())); - if validation.as_bool() != Some(true) { - let suffix = if !validation.is_null() && validation.as_bool() != Some(false) { - format!(" ({})", validation.as_string().unwrap_or("")) - } else { - String::new() - }; - return Err(RuntimeException { - message: format!("\"{}\" is an invalid value{}", values[0].clone(), suffix), - code: 0, - } - .into()); - } - - let normalized_value = normalizer(&PhpMixed::String(values[0].clone())); - - if key == "disable-tls" { - let config = self.config.borrow().as_ref().unwrap().clone(); - if !normalized_value.as_bool().unwrap_or(false) - && config - .borrow() - .get("disable-tls") - .as_bool() - .unwrap_or(false) - { - self.get_io().write_error( - "You are now running Composer with SSL/TLS protection enabled.", - ); - } else if normalized_value.as_bool().unwrap_or(false) - && !config - .borrow() - .get("disable-tls") - .as_bool() - .unwrap_or(false) - { - self.get_io().write_error("You are now running Composer with SSL/TLS protection disabled."); - } - } - - let mut config_source = self.config_source.borrow_mut(); - let config_source = config_source.as_mut().unwrap(); - match method { - "addConfigSetting" => config_source.add_config_setting(key, normalized_value)?, - "addProperty" => config_source.add_property(key, normalized_value)?, - _ => unreachable!(), - } - Ok(()) - } - - pub(crate) fn handle_multi_value( - &self, - key: &str, - callbacks: &(ValidatorFn, NormalizerFn), - values: &[String], - method: &str, - ) -> anyhow::Result<()> { - let (validator, normalizer) = callbacks; - let values_mixed = - PhpMixed::List(values.iter().map(|s| PhpMixed::String(s.clone())).collect()); - let validation = validator(&values_mixed); - if validation.as_bool() != Some(true) { - let suffix = if !validation.is_null() && validation.as_bool() != Some(false) { - format!(" ({})", validation.as_string().unwrap_or("")) - } else { - String::new() - }; - return Err(RuntimeException { - message: format!( - "{} is an invalid value{}", - PhpMixed::from(json_encode(&values_mixed).ok()), - suffix - ), - code: 0, - } - .into()); - } - - let mut config_source = self.config_source.borrow_mut(); - let config_source = config_source.as_mut().unwrap(); - match method { - "addConfigSetting" => { - config_source.add_config_setting(key, normalizer(&values_mixed))? - } - "addProperty" => config_source.add_property(key, normalizer(&values_mixed))?, - _ => unreachable!(), - } - Ok(()) - } - - /// Display the contents of the file in a pretty formatted way - pub(crate) fn list_configuration( - &self, - contents: PhpMixed, - raw_contents: PhpMixed, - output: std::rc::Rc>, - k: Option, - show_source: bool, - ) { - let orig_k = k.clone(); - let contents_arr = contents.as_array().cloned().unwrap_or_default(); - let raw_contents_arr = raw_contents.as_array().cloned().unwrap_or_default(); - let mut k = k; - for (key, value) in &contents_arr { - if k.is_none() && !matches!(key.as_str(), "config" | "repositories") { - continue; - } - - let raw_val = raw_contents_arr.get(key).cloned().unwrap_or(PhpMixed::Null); - - let value_inner = value.clone(); - - if is_array(&value_inner) - && (!is_numeric(&key_first_key(&value_inner).unwrap_or_default().into()) - || (key == "repositories" && k.is_none())) - { - let mut new_k = k.clone().unwrap_or_default(); - new_k.push_str(&Preg::replace( - php_regex!("{^config\\.}"), - "", - &format!("{}.", key), - )); - k = Some(new_k); - self.list_configuration( - value_inner, - raw_val, - output.clone(), - k.clone(), - show_source, - ); - k = orig_k.clone(); - - continue; - } - - let value_display: String = if is_array(&value_inner) { - let arr_strs: Vec = value_inner - .as_list() - .map(|l| { - l.iter() - .map(|val| { - if is_array(val) { - json_encode(val).unwrap_or_default() - } else { - val.as_string().unwrap_or("").to_string() - } - }) - .collect::>() - }) - .unwrap_or_default(); - format!("[{}]", implode(", ", &arr_strs)) - } else if is_bool(&value_inner) { - var_export(&value_inner, true) - } else { - value_inner.as_string().unwrap_or("").to_string() - }; - - let source = if show_source { - format!( - " ({})", - self.config - .borrow() - .as_ref() - .unwrap() - .borrow_mut() - .get_source_of_value(&format!("{}{}", k.clone().unwrap_or_default(), key)) - ) - } else { - String::new() - }; - - let link: String = - if k.is_some() && strpos(k.as_ref().unwrap(), "repositories") == Some(0) { - "https://getcomposer.org/doc/05-repositories.md".to_string() - } else { - let id_source = if k.as_deref() == Some("") || k.is_none() { - key.clone() - } else { - k.clone().unwrap() - }; - let id = Preg::replace(php_regex!("{\\..*$}"), "", &id_source); - let id = Preg::replace( - php_regex!("{[^a-z0-9]}i"), - "-", - &strtolower(&shirabe_php_shim::trim(&id, Some(" \t\n\r\0\u{0B}"))), - ); - let id = Preg::replace(php_regex!("{-+}"), "-", &id); - format!("https://getcomposer.org/doc/06-config.md#{}", id) - }; - if is_string(&raw_val) - && raw_val - .as_string() - .map(|s| s.to_string()) - .unwrap_or_default() - != value_display - { - self.get_io().write3( - &format!( - "[{}{}] {} ({}){}", - link, - k.clone().unwrap_or_default(), - key, - raw_val.as_string().unwrap_or(""), - value_display, - source - ), - true, - io_interface::QUIET, - ); - } else { - self.get_io().write3( - &format!( - "[{}{}] {}{}", - link, - k.clone().unwrap_or_default(), - key, - value_display, - source - ), - true, - io_interface::QUIET, - ); - } - } - } + } else if matches[1] == "http-basic" { + if 2 != values.len() { + return Err(RuntimeException { + message: format!( + "Expected two arguments (username, password), got {}", + values.len() + ), + code: 0, + } + .into()); + } + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&key); + let mut obj: IndexMap = IndexMap::new(); + obj.insert("username".to_string(), PhpMixed::String(values[0].clone())); + obj.insert("password".to_string(), PhpMixed::String(values[1].clone())); + self.auth_config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_config_setting(&key, PhpMixed::Array(obj)); + } else if matches[1] == "custom-headers" { + if values.is_empty() { + return Err(RuntimeException { + message: "Expected at least one argument (header), got none".to_string(), + code: 0, + } + .into()); + } - /// Suggest setting-keys, while taking given options in account. - fn suggest_setting_keys(&self) -> crate::console::input::SuggestedValues { - crate::console::input::SuggestedValues::Closure(Box::new(|this, input, _suggestions| { - if input.get_option("list")?.to_bool() - || input.get_option("editor")?.to_bool() - || input.get_option("auth")?.to_bool() - { - return Ok(vec![]); - } + // Validate headers format + let mut formatted_headers: Vec = vec![]; + for header in &values { + if !is_string(&PhpMixed::String(header.clone())) { + return Err(RuntimeException { + message: + "Headers must be strings in \"Header-Name: Header-Value\" format" + .to_string(), + code: 0, + } + .into()); + } - let this = this - .as_any() - .downcast_ref::() - .expect("suggestSettingKeys is bound to ConfigCommand"); - // PHP passes the CompletionInput itself; the accessors only read from it, so a - // clone behind a fresh handle is equivalent. - let input_handle: std::rc::Rc< - std::cell::RefCell< - dyn shirabe_external_packages::symfony::console::input::InputInterface, - >, - > = std::rc::Rc::new(std::cell::RefCell::new(input.clone())); + // Check if the header is in correct "Name: Value" format + let mut header_parts: IndexMap = IndexMap::new(); + if !Preg::is_match3( + php_regex!("/^[^:]+:\\s*.+$/"), + header, + Some(&mut header_parts), + ) { + return Err(RuntimeException { + message: format!( + "Header \"{}\" is not in \"Header-Name: Header-Value\" format", + header + ), + code: 0, + } + .into()); + } - // initialize configuration - let mut config = Factory::create_config(None, None)?; + formatted_headers.push(PhpMixed::String(header.clone())); + } - // load configuration - let config_file = JsonFile::new( - this.get_composer_config_file(input_handle.clone(), &config)?, - None, - None, - )?; - if config_file.exists() { - let path = config_file.get_path().to_string(); - let data = config_file.read()?.as_array().cloned().unwrap_or_default(); - config.merge(&data, &path); + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&key); + self.auth_config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_config_setting(&key, PhpMixed::List(formatted_headers)); + } else if matches[1] == "forgejo-token" { + if 2 != values.len() { + return Err(RuntimeException { + message: format!( + "Expected two arguments (username, access token), got {}", + values.len() + ), + code: 0, + } + .into()); + } + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_config_setting(&key); + let mut obj: IndexMap = IndexMap::new(); + obj.insert("username".to_string(), PhpMixed::String(values[0].clone())); + obj.insert("token".to_string(), PhpMixed::String(values[1].clone())); + self.auth_config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_config_setting(&key, PhpMixed::Array(obj)); } - // load auth-configuration - let auth_config_file = JsonFile::new( - this.get_auth_config_file(input_handle.clone(), &config)?, - None, - None, - )?; - if auth_config_file.exists() { - let path = auth_config_file.get_path().to_string(); - let mut data = IndexMap::new(); - data.insert("config".to_string(), auth_config_file.read()?); - config.merge(&data, &path); - } + return Ok(0); + } - // collect all configuration setting-keys - let raw_config = config.raw(); - let mut keys = flatten_setting_keys( - raw_config.get("config").cloned().unwrap_or(PhpMixed::Null), - "", - ); - keys.extend(flatten_setting_keys( - raw_config - .get("repositories") - .cloned() - .unwrap_or(PhpMixed::Null), - "repositories.", - )); + // handle script + let mut matches: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("/^scripts\\.(.+)/"), + &setting_key, + Some(&mut matches), + ) { + if input.borrow().get_option("unset")?.as_bool() == Some(true) { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_property(&setting_key); - // if unsetting … - if input.get_option("unset")?.to_bool() { - // … keep only the currently customized setting-keys … - let sources = [ - config_file.get_path().to_string(), - auth_config_file.get_path().to_string(), - ]; - keys.retain(|key| sources.contains(&config.get_source_of_value(key))); + return Ok(0); + } - // … else if showing or setting a value … + let value: PhpMixed = if values.len() > 1 { + PhpMixed::List(values.iter().map(|s| PhpMixed::String(s.clone())).collect()) } else { - // … add all configurable package-properties, no matter if it exist - keys.extend( - Self::CONFIGURABLE_PACKAGE_PROPERTIES - .iter() - .map(|property| property.to_string()), - ); + PhpMixed::String(values[0].clone()) + }; + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .add_property(&setting_key, value); - // it would be nice to distinguish between showing and setting - // a value, but that makes the implementation much more complex - // and partially impossible because symfony's implementation - // does not complete arguments followed by other arguments - } + return Ok(0); + } - // add all existing configurable package-properties - if config_file.exists() { - let properties: IndexMap = config_file - .read()? - .as_array() - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|(key, _)| { - Self::CONFIGURABLE_PACKAGE_PROPERTIES.contains(&key.as_str()) - }) - .collect(); + // handle unsetting other top level properties + if input.borrow().get_option("unset")?.as_bool() == Some(true) { + self.config_source + .borrow_mut() + .as_mut() + .unwrap() + .remove_property(&setting_key); - keys.extend(flatten_setting_keys(PhpMixed::Array(properties), "")); - } + return Ok(0); + } - // filter settings-keys by completion value - let completion_value = input.get_completion_value(); + Err(InvalidArgumentException { + message: format!( + "Setting {} does not exist or is not supported by this command", + setting_key + ), + code: 0, + } + .into()) + } - if !completion_value.is_empty() { - keys.retain(|key| key.starts_with(&completion_value)); - } + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) + } - keys.sort(); + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} - keys.dedup(); - Ok(keys) - })) +impl BaseCommand for ConfigCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } // PHP signature: function ($val): bool / ($val) -> bool/string diff --git a/crates/shirabe/src/command/create_project_command.rs b/crates/shirabe/src/command/create_project_command.rs index 260685c7..dcf0e0f8 100644 --- a/crates/shirabe/src/command/create_project_command.rs +++ b/crates/shirabe/src/command/create_project_command.rs @@ -81,213 +81,7 @@ impl CreateProjectCommand { .expect("CreateProjectCommand::configure uses static, valid metadata"); command } -} - -impl Command for CreateProjectCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("create-project")?; - self.set_description("Creates new project from a package into given directory"); - self.set_definition(&[ - InputArgument::new5("package", Some(InputArgument::OPTIONAL), "Package name to be installed", None, self.suggest_available_package(99)).unwrap().into(), - InputArgument::new("directory", Some(InputArgument::OPTIONAL), "Directory where the files should be created", None).unwrap().into(), - InputArgument::new("version", Some(InputArgument::OPTIONAL), "Version, will default to latest", None).unwrap().into(), - InputOption::new("stability", Some(PhpMixed::String("s".to_string())), Some(InputOption::VALUE_REQUIRED), "Minimum-stability allowed (unless a version is specified).", None).unwrap().into(), - InputOption::new("prefer-source", None, Some(InputOption::VALUE_NONE), "Forces installation from package sources when possible, including VCS information.", None).unwrap().into(), - InputOption::new("prefer-dist", None, Some(InputOption::VALUE_NONE), "Forces installation from package dist (default behavior).", None).unwrap().into(), - InputOption::new6("prefer-install", None, Some(InputOption::VALUE_REQUIRED), "Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).", None, self.suggest_prefer_install()).unwrap().into(), - InputOption::new("repository", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Add custom repositories to look the package up, either by URL or using JSON arrays", None).unwrap().into(), - InputOption::new("repository-url", None, Some(InputOption::VALUE_REQUIRED), "DEPRECATED: Use --repository instead.", None).unwrap().into(), - InputOption::new("add-repository", None, Some(InputOption::VALUE_NONE), "Add the custom repository in the composer.json. If a lock file is present it will be deleted and an update will be run instead of install.", None).unwrap().into(), - InputOption::new("dev", None, Some(InputOption::VALUE_NONE), "Enables installation of require-dev packages (enabled by default, only present for BC).", None).unwrap().into(), - InputOption::new("no-dev", None, Some(InputOption::VALUE_NONE), "Disables installation of require-dev packages.", None).unwrap().into(), - InputOption::new("no-custom-installers", None, Some(InputOption::VALUE_NONE), "DEPRECATED: Use no-plugins instead.", None).unwrap().into(), - InputOption::new("no-scripts", None, Some(InputOption::VALUE_NONE), "Whether to prevent execution of all defined scripts in the root package.", None).unwrap().into(), - InputOption::new("no-progress", None, Some(InputOption::VALUE_NONE), "Do not output download progress.", None).unwrap().into(), - InputOption::new("no-secure-http", None, Some(InputOption::VALUE_NONE), "Disable the secure-http config option temporarily while installing the root package. Use at your own risk. Using this flag is a bad idea.", None).unwrap().into(), - InputOption::new("keep-vcs", None, Some(InputOption::VALUE_NONE), "Whether to prevent deleting the vcs folder.", None).unwrap().into(), - InputOption::new("remove-vcs", None, Some(InputOption::VALUE_NONE), "Whether to force deletion of the vcs folder without prompting.", None).unwrap().into(), - InputOption::new("no-install", None, Some(InputOption::VALUE_NONE), "Whether to skip installation of the package dependencies.", None).unwrap().into(), - InputOption::new("no-audit", None, Some(InputOption::VALUE_NONE), "Whether to skip auditing of the installed package dependencies (can also be set via the COMPOSER_NO_AUDIT=1 env var).", None).unwrap().into(), - InputOption::new6("audit-format", None, Some(InputOption::VALUE_REQUIRED), "Audit output format. Must be \"table\", \"plain\", \"json\" or \"summary\".", Some(PhpMixed::String(Auditor::FORMAT_SUMMARY.to_string())), SuggestedValues::List(Auditor::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), - InputOption::new("no-security-blocking", None, Some(InputOption::VALUE_NONE), "Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).", None).unwrap().into(), - InputOption::new("ignore-platform-req", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Ignore a specific platform requirement (php & ext- packages).", None).unwrap().into(), - InputOption::new("ignore-platform-reqs", None, Some(InputOption::VALUE_NONE), "Ignore all platform requirements (php & ext- packages).", None).unwrap().into(), - InputOption::new("ask", None, Some(InputOption::VALUE_NONE), "Whether to ask for project directory.", None).unwrap().into(), - ]); - self.set_help( - "The create-project command creates a new project from a given\n\ - package into a new directory. If executed without params and in a directory\n\ - with a composer.json file it installs the packages for the current project.\n\n\ - You can use this command to bootstrap new projects or setup a clean\n\ - version-controlled installation for developers of your project.\n\n\ - shirabe create-project vendor/project target-directory [version]\n\n\ - You can also specify the version with the package name using = or : as separator.\n\n\ - shirabe create-project vendor/project:version target-directory\n\n\ - To install unstable packages, either specify the version you want, or use the\n\ - --stability=dev (where dev can be one of RC, beta, alpha or dev).\n\n\ - To setup a developer workable version you should create the project using the source\n\ - controlled code by appending the '--prefer-source' flag.\n\n\ - To install a package from another repository than the default one you\n\ - can pass the '--repository=https://myrepository.org' flag.\n\n\ - Read more at https://getcomposer.org/doc/03-cli.md#create-project" - ); - Ok(()) - } - - fn execute( - &self, - input: std::rc::Rc>, - _output: std::rc::Rc>, - ) -> anyhow::Result { - let config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)); - let io: std::rc::Rc> = self.get_io(); - - let (prefer_source, prefer_dist) = - self.get_preferred_install_options(&config.borrow(), input.clone(), true)?; - - if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { - io.write_error("You are using the deprecated option \"dev\". Dev packages are installed by default now."); - } - if input - .borrow() - .get_option("no-custom-installers")? - .as_bool() - .unwrap_or(false) - { - io.write_error("You are using the deprecated option \"no-custom-installers\". Use \"no-plugins\" instead."); - input - .borrow_mut() - .set_option("no-plugins", PhpMixed::Bool(true)); - } - - if input.borrow().is_interactive() - && input.borrow().get_option("ask")?.as_bool().unwrap_or(false) - { - let package = input.borrow().get_argument("package")?; - if package.is_null() { - return Err(RuntimeException { - message: "Not enough arguments (missing: \"package\").".to_string(), - code: 0, - } - .into()); - } - let mut parts = - explode_with_limit("/", &strtolower(package.as_string().unwrap_or("")), 2); - let prompt = format!( - "New project directory [{}]: ", - array_pop(&mut parts).unwrap_or_default() - ); - input - .borrow_mut() - .set_argument("directory", io.ask(prompt, PhpMixed::Null)?); - } - - let repository_opt = input.borrow().get_option("repository")?; - let repository_url_opt = input.borrow().get_option("repository-url")?; - let repositories = if repository_opt - .as_list() - .map(|l| !l.is_empty()) - .unwrap_or(false) - { - Some(repository_opt) - } else { - Some(repository_url_opt) - }; - - self.install_project( - io, - config, - input.clone(), - input - .borrow() - .get_argument("package")? - .as_string() - .map(|s| s.to_string()), - input - .borrow() - .get_argument("directory")? - .as_string() - .map(|s| s.to_string()), - input - .borrow() - .get_argument("version")? - .as_string() - .map(|s| s.to_string()), - input - .borrow() - .get_option("stability")? - .as_string() - .map(|s| s.to_string()), - prefer_source, - prefer_dist, - !input - .borrow() - .get_option("no-dev")? - .as_bool() - .unwrap_or(false), - repositories, - input - .borrow() - .get_option("no-plugins")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("no-scripts")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("no-progress")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("no-install")? - .as_bool() - .unwrap_or(false), - Some(self.get_platform_requirement_filter(input.clone())?), - !input - .borrow() - .get_option("no-secure-http")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("add-repository")? - .as_bool() - .unwrap_or(false), - ) - } - - fn initialize( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result<()> { - base_command_initialize(self, input, output) - } - - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } - - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} - -impl BaseCommand for CreateProjectCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } - - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} -impl CreateProjectCommand { /// @throws \Exception #[allow(clippy::too_many_arguments)] pub fn install_project( @@ -1057,3 +851,207 @@ impl CreateProjectCommand { Ok(installed_from_vcs) } } + +impl Command for CreateProjectCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("create-project")?; + self.set_description("Creates new project from a package into given directory"); + self.set_definition(&[ + InputArgument::new5("package", Some(InputArgument::OPTIONAL), "Package name to be installed", None, self.suggest_available_package(99)).unwrap().into(), + InputArgument::new("directory", Some(InputArgument::OPTIONAL), "Directory where the files should be created", None).unwrap().into(), + InputArgument::new("version", Some(InputArgument::OPTIONAL), "Version, will default to latest", None).unwrap().into(), + InputOption::new("stability", Some(PhpMixed::String("s".to_string())), Some(InputOption::VALUE_REQUIRED), "Minimum-stability allowed (unless a version is specified).", None).unwrap().into(), + InputOption::new("prefer-source", None, Some(InputOption::VALUE_NONE), "Forces installation from package sources when possible, including VCS information.", None).unwrap().into(), + InputOption::new("prefer-dist", None, Some(InputOption::VALUE_NONE), "Forces installation from package dist (default behavior).", None).unwrap().into(), + InputOption::new6("prefer-install", None, Some(InputOption::VALUE_REQUIRED), "Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).", None, self.suggest_prefer_install()).unwrap().into(), + InputOption::new("repository", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Add custom repositories to look the package up, either by URL or using JSON arrays", None).unwrap().into(), + InputOption::new("repository-url", None, Some(InputOption::VALUE_REQUIRED), "DEPRECATED: Use --repository instead.", None).unwrap().into(), + InputOption::new("add-repository", None, Some(InputOption::VALUE_NONE), "Add the custom repository in the composer.json. If a lock file is present it will be deleted and an update will be run instead of install.", None).unwrap().into(), + InputOption::new("dev", None, Some(InputOption::VALUE_NONE), "Enables installation of require-dev packages (enabled by default, only present for BC).", None).unwrap().into(), + InputOption::new("no-dev", None, Some(InputOption::VALUE_NONE), "Disables installation of require-dev packages.", None).unwrap().into(), + InputOption::new("no-custom-installers", None, Some(InputOption::VALUE_NONE), "DEPRECATED: Use no-plugins instead.", None).unwrap().into(), + InputOption::new("no-scripts", None, Some(InputOption::VALUE_NONE), "Whether to prevent execution of all defined scripts in the root package.", None).unwrap().into(), + InputOption::new("no-progress", None, Some(InputOption::VALUE_NONE), "Do not output download progress.", None).unwrap().into(), + InputOption::new("no-secure-http", None, Some(InputOption::VALUE_NONE), "Disable the secure-http config option temporarily while installing the root package. Use at your own risk. Using this flag is a bad idea.", None).unwrap().into(), + InputOption::new("keep-vcs", None, Some(InputOption::VALUE_NONE), "Whether to prevent deleting the vcs folder.", None).unwrap().into(), + InputOption::new("remove-vcs", None, Some(InputOption::VALUE_NONE), "Whether to force deletion of the vcs folder without prompting.", None).unwrap().into(), + InputOption::new("no-install", None, Some(InputOption::VALUE_NONE), "Whether to skip installation of the package dependencies.", None).unwrap().into(), + InputOption::new("no-audit", None, Some(InputOption::VALUE_NONE), "Whether to skip auditing of the installed package dependencies (can also be set via the COMPOSER_NO_AUDIT=1 env var).", None).unwrap().into(), + InputOption::new6("audit-format", None, Some(InputOption::VALUE_REQUIRED), "Audit output format. Must be \"table\", \"plain\", \"json\" or \"summary\".", Some(PhpMixed::String(Auditor::FORMAT_SUMMARY.to_string())), SuggestedValues::List(Auditor::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), + InputOption::new("no-security-blocking", None, Some(InputOption::VALUE_NONE), "Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).", None).unwrap().into(), + InputOption::new("ignore-platform-req", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Ignore a specific platform requirement (php & ext- packages).", None).unwrap().into(), + InputOption::new("ignore-platform-reqs", None, Some(InputOption::VALUE_NONE), "Ignore all platform requirements (php & ext- packages).", None).unwrap().into(), + InputOption::new("ask", None, Some(InputOption::VALUE_NONE), "Whether to ask for project directory.", None).unwrap().into(), + ]); + self.set_help( + "The create-project command creates a new project from a given\n\ + package into a new directory. If executed without params and in a directory\n\ + with a composer.json file it installs the packages for the current project.\n\n\ + You can use this command to bootstrap new projects or setup a clean\n\ + version-controlled installation for developers of your project.\n\n\ + shirabe create-project vendor/project target-directory [version]\n\n\ + You can also specify the version with the package name using = or : as separator.\n\n\ + shirabe create-project vendor/project:version target-directory\n\n\ + To install unstable packages, either specify the version you want, or use the\n\ + --stability=dev (where dev can be one of RC, beta, alpha or dev).\n\n\ + To setup a developer workable version you should create the project using the source\n\ + controlled code by appending the '--prefer-source' flag.\n\n\ + To install a package from another repository than the default one you\n\ + can pass the '--repository=https://myrepository.org' flag.\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#create-project" + ); + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + _output: std::rc::Rc>, + ) -> anyhow::Result { + let config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)); + let io: std::rc::Rc> = self.get_io(); + + let (prefer_source, prefer_dist) = + self.get_preferred_install_options(&config.borrow(), input.clone(), true)?; + + if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { + io.write_error("You are using the deprecated option \"dev\". Dev packages are installed by default now."); + } + if input + .borrow() + .get_option("no-custom-installers")? + .as_bool() + .unwrap_or(false) + { + io.write_error("You are using the deprecated option \"no-custom-installers\". Use \"no-plugins\" instead."); + input + .borrow_mut() + .set_option("no-plugins", PhpMixed::Bool(true)); + } + + if input.borrow().is_interactive() + && input.borrow().get_option("ask")?.as_bool().unwrap_or(false) + { + let package = input.borrow().get_argument("package")?; + if package.is_null() { + return Err(RuntimeException { + message: "Not enough arguments (missing: \"package\").".to_string(), + code: 0, + } + .into()); + } + let mut parts = + explode_with_limit("/", &strtolower(package.as_string().unwrap_or("")), 2); + let prompt = format!( + "New project directory [{}]: ", + array_pop(&mut parts).unwrap_or_default() + ); + input + .borrow_mut() + .set_argument("directory", io.ask(prompt, PhpMixed::Null)?); + } + + let repository_opt = input.borrow().get_option("repository")?; + let repository_url_opt = input.borrow().get_option("repository-url")?; + let repositories = if repository_opt + .as_list() + .map(|l| !l.is_empty()) + .unwrap_or(false) + { + Some(repository_opt) + } else { + Some(repository_url_opt) + }; + + self.install_project( + io, + config, + input.clone(), + input + .borrow() + .get_argument("package")? + .as_string() + .map(|s| s.to_string()), + input + .borrow() + .get_argument("directory")? + .as_string() + .map(|s| s.to_string()), + input + .borrow() + .get_argument("version")? + .as_string() + .map(|s| s.to_string()), + input + .borrow() + .get_option("stability")? + .as_string() + .map(|s| s.to_string()), + prefer_source, + prefer_dist, + !input + .borrow() + .get_option("no-dev")? + .as_bool() + .unwrap_or(false), + repositories, + input + .borrow() + .get_option("no-plugins")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("no-scripts")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("no-progress")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("no-install")? + .as_bool() + .unwrap_or(false), + Some(self.get_platform_requirement_filter(input.clone())?), + !input + .borrow() + .get_option("no-secure-http")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("add-repository")? + .as_bool() + .unwrap_or(false), + ) + } + + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + base_command_initialize(self, input, output) + } + + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) + } + + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} + +impl BaseCommand for CreateProjectCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data + } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); +} diff --git a/crates/shirabe/src/command/diagnose_command.rs b/crates/shirabe/src/command/diagnose_command.rs index 1674e10e..8483eb56 100644 --- a/crates/shirabe/src/command/diagnose_command.rs +++ b/crates/shirabe/src/command/diagnose_command.rs @@ -77,604 +77,215 @@ impl DiagnoseCommand { .expect("DiagnoseCommand::configure uses static, valid metadata"); command } -} - -impl Command for DiagnoseCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("diagnose")?; - self.set_description("Diagnoses the system to identify common errors"); - self.set_help( - "The diagnose command checks common errors to help debugging problems.\n\n\ - The process exit code will be 1 in case of warnings and 2 for errors.\n\n\ - Read more at https://getcomposer.org/doc/03-cli.md#diagnose", - ); - Ok(()) - } - fn execute( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - let mut composer = self.try_composer(None, None); - let io: std::rc::Rc> = self.get_io().clone(); + fn check_composer_schema(&self) -> anyhow::Result { + let validator = ConfigValidator::new(self.get_io().clone()); + let (errors, _, warnings) = validator.validate(&Factory::get_composer_file()?, 0, 0); - let config: std::rc::Rc>; - if let Some(ref mut c) = composer { - let c = crate::composer::composer_full(c); - config = c.get_config(); + if !errors.is_empty() || !warnings.is_empty() { + let mut messages: IndexMap> = IndexMap::new(); + messages.insert("error".to_string(), errors); + messages.insert("warning".to_string(), warnings); - let command_event = CommandEvent::new6( - PluginEvents::COMMAND, - "diagnose", - input, - output, - vec![], - IndexMap::new(), - ); - c.get_event_dispatcher() - .borrow_mut() - .dispatch(Some(command_event.get_name()), None); - *self.process.borrow_mut() = Some( - c.get_loop() - .borrow() - .get_process_executor() - .map(std::rc::Rc::clone) - .unwrap_or_else(|| { - std::rc::Rc::new(std::cell::RefCell::new(ProcessExecutor::new(Some( - io.clone(), - )))) - }), - ); - } else { - config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)); + let mut output = String::new(); + for (style, msgs) in &messages { + for msg in msgs { + output.push_str(&format!("<{}>{}{}", style, msg, style, PHP_EOL)); + } + } - *self.process.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new( - ProcessExecutor::new(Some(io.clone())), - ))); + return Ok(PhpMixed::String(rtrim(&output, Some(" \t\n\r\0\u{0B}")))); } - let mut config_inner = IndexMap::new(); - config_inner.insert("secure-http".to_string(), PhpMixed::Bool(false)); - let mut secure_http_wrap: IndexMap = IndexMap::new(); - secure_http_wrap.insert("config".to_string(), PhpMixed::Array(config_inner)); - let config = config; - config - .borrow_mut() - .merge(&secure_http_wrap, Config::SOURCE_COMMAND); - let _ = config.borrow_mut().prohibit_url_by_config( - "http://repo.packagist.org", - Some(std::rc::Rc::new(std::cell::RefCell::new(NullIO::new()))), - &IndexMap::new(), - ); - *self.http_downloader.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new( - Factory::create_http_downloader(io.clone(), &config, indexmap::IndexMap::new())?, - ))); + Ok(PhpMixed::Bool(true)) + } - if strpos(file!(), "phar:") == Some(0) { - io.write_no_newline("Checking pubkeys: "); - let r = self.check_pub_keys(&config.borrow())?; - self.output_result(r); + fn check_composer_lock_schema(&self, locker: &dyn LockerInterface) -> anyhow::Result { + let json = locker.get_json_file(); - io.write_no_newline("Checking Composer version: "); - let r = self.check_version(&config)?; - self.output_result(r); + match json.validate_schema(JsonFile::LOCK_SCHEMA, None) { + Ok(_) => {} + Err(e) => { + if let Some(jve) = e.downcast_ref::() { + let mut output = String::new(); + for error in jve.get_errors() { + output.push_str(&format!("{}{}", error, PHP_EOL)); + } + + return Ok(PhpMixed::String(trim(&output, Some(" \t\n\r\0\u{0B}")))); + } + return Err(e); + } } - io.write(&format!( - "Composer version: {}", - composer::get_version() - )); + Ok(PhpMixed::Bool(true)) + } - io.write_no_newline("Checking Composer and its dependencies for vulnerabilities: "); - let r = self.check_composer_audit(&config)?; - self.output_result(r); + fn check_git(&self) -> String { + if !shirabe_php_rpc::get_diagnostics().function_exists("proc_open") { + return "proc_open is not available, git cannot be used".to_string(); + } - let platform_overrides = config + let mut output = String::new(); + let _ = self + .process + .borrow() + .as_ref() + .unwrap() .borrow_mut() - .get("platform") - .as_array() - .cloned() - .unwrap_or_default(); - let platform_overrides_unboxed: indexmap::IndexMap = - platform_overrides.into_iter().collect(); - let mut platform_repo = - PlatformRepository::new(vec![], platform_overrides_unboxed).unwrap(); - let php_pkg = ::find_package( - &mut platform_repo, - "php", - crate::repository::FindPackageConstraint::String("*".to_string()), - )? - .unwrap(); - let mut php_version = php_pkg.get_pretty_version(); - if let Some(cp) = php_pkg.as_complete() - && str_contains(&cp.get_description().unwrap_or_default(), "overridden") - { - php_version = format!( - "{} - {}", - php_version, - cp.get_description().unwrap_or_default() + .execute( + vec![ + "git".to_string(), + "config".to_string(), + "color.ui".to_string(), + ], + &mut output, + None, ); + if strtolower(&trim(&output, Some(" \t\n\r\0\u{0B}"))) == "always" { + return "Your git color.ui setting is set to always, this is known to create issues. Use \"git config --global color.ui true\" to set it correctly.".to_string(); } - io.write(&format!("PHP version: {}", php_version)); + let process = self.process.borrow(); + let git_version = Git::get_version(process.as_ref().unwrap()); + let git_version = match git_version { + Some(v) => v, + None => return "No git process found".to_string(), + }; - let diagnostics = shirabe_php_rpc::get_diagnostics(); + if version_compare("2.24.0", &git_version, ">") { + return format!( + "Your git version ({}) is too old and possibly will cause issues. Please upgrade to git 2.24 or above", + git_version + ); + } - if let Some(php_binary) = &diagnostics.php_binary { - io.write(&format!( - "PHP binary path: {}", - php_binary - )); + format!("OK git version {}", git_version) + } + + fn check_http( + &self, + proto: &str, + config: &std::rc::Rc>, + ) -> anyhow::Result { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); } - io.write(&format!( - "OpenSSL version: {}", - match &diagnostics.openssl_version_text { - Some(text) => format!("{}", text), - None => "missing".to_string(), + let mut result_list: Vec = vec![]; + let mut tls_warning: Option = None; + if proto == "https" && config.borrow().get("disable-tls").as_bool() == Some(true) { + tls_warning = Some("Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.".to_string()); + } + + match self + .http_downloader + .borrow() + .as_ref() + .unwrap() + .borrow_mut() + .get( + &format!("{}://repo.packagist.org/packages.json", proto), + IndexMap::new(), + ) { + Ok(_) => {} + Err(e) => { + if let Some(te) = e.downcast_ref::() { + let hints = HttpDownloader::get_exception_hints(&e).unwrap_or_default(); + if !hints.is_empty() { + for hint in hints { + result_list.push(PhpMixed::String(hint)); + } + } + + result_list.push(PhpMixed::String(format!( + "[{}] {}", + std::any::type_name_of_val(te), + te.message + ))); + } else { + return Err(e); + } } - )); - io.write(&format!("curl version: {}", self.get_curl_version())); + } - let finder = ExecutableFinder::new(); - let has_system_unzip = finder.find("unzip", None, &[]).is_some(); - let mut bin_7zip = String::new(); - let has_system_7zip = if finder - .find("7z", None, &["C:\\Program Files\\7-Zip".to_string()]) - .is_some() - { - bin_7zip = "7z".to_string(); - true - } else if !Platform::is_windows() && finder.find("7zz", None, &[]).is_some() { - bin_7zip = "7zz".to_string(); - true - } else if !Platform::is_windows() && finder.find("7za", None, &[]).is_some() { - bin_7zip = "7za".to_string(); - true - } else { - false - }; + if let Some(w) = tls_warning { + result_list.push(PhpMixed::String(w)); + } - io.write(&format!( - "zip: {}, {}, {}{}", - if diagnostics.extension_loaded("zip") { - "extension present" - } else { - "extension not loaded" - }, - if has_system_unzip { - "unzip present".to_string() - } else { - "unzip not available".to_string() - }, - if has_system_7zip { - format!("7-Zip present ({})", bin_7zip) - } else { - "7-Zip not available".to_string() - }, - if (has_system_7zip || has_system_unzip) && !diagnostics.function_exists("proc_open") { - ", proc_open is disabled or not present, unzip/7-z will not be usable" - } else { - "" - } - )); - - if let Some(ref mut c) = composer { - let c = crate::composer::composer_full(c); - io.write(&format!( - "Active plugins: {}", - implode( - ", ", - &c.get_plugin_manager().borrow().get_registered_plugins() - ) - )); - - io.write_no_newline("Checking composer.json: "); - let r = self.check_composer_schema()?; - self.output_result(r); - - if c.get_locker().borrow_mut().is_locked() { - io.write_no_newline("Checking composer.lock: "); - let locker = c.get_locker().clone(); - let locker = locker.borrow(); - let r = self.check_composer_lock_schema(&*locker)?; - self.output_result(r); - } + if !result_list.is_empty() { + return Ok(PhpMixed::List(result_list)); } - io.write_no_newline("Checking platform settings: "); - let r = self.check_platform()?; - self.output_result(r); - - io.write_no_newline("Checking git settings: "); - let r = self.check_git(); - self.output_result(PhpMixed::String(r)); - - io.write_no_newline("Checking http connectivity to packagist: "); - let r = self.check_http("http", &config)?; - self.output_result(r); - - io.write_no_newline("Checking https connectivity to packagist: "); - let r = self.check_http("https", &config)?; - self.output_result(r); + Ok(PhpMixed::Bool(true)) + } - let repositories = config.borrow().get_repositories(); - for repo in repositories { - let repo_arr = repo.1.as_array().cloned().unwrap_or_default(); - if repo_arr.get("type").and_then(|v| v.as_string()) == Some("composer") - && repo_arr.get("url").is_some() - { - let repo_arr_unboxed: indexmap::IndexMap = repo_arr - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let composer_repo = ComposerRepository::new( - repo_arr_unboxed, - self.get_io().clone(), - &config.borrow(), - self.http_downloader.borrow().clone().unwrap(), - None, - ) - .unwrap(); - // PHP: ReflectionMethod($composerRepo, 'getPackagesJsonUrl') - // We surface the same internal call by directly invoking the equivalent method. - // TODO(plugin): support reflection-based access if plugin code requires it. - let url = composer_repo.get_packages_json_url(); - if !str_starts_with(&url, "http") { - continue; - } - if str_starts_with(&url, "https://repo.packagist.org") { - continue; - } - io.write_no_newline(&format!( - "Checking connectivity to {}: ", - repo_arr - .get("url") - .and_then(|v| v.as_string()) - .unwrap_or("") - )); - let r = self.check_composer_repo(&url, &config)?; - self.output_result(r); - } + fn check_composer_repo( + &self, + url: &str, + config: &std::rc::Rc>, + ) -> anyhow::Result { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); } - let protos: Vec<&str> = if config.borrow_mut().get("disable-tls").as_bool() == Some(true) { - vec!["http"] - } else { - vec!["http", "https"] - }; - let proxy_check_result: anyhow::Result<(), anyhow::Error> = (|| -> anyhow::Result<()> { - for proto in &protos { - // Compute the proxy under a short-lived lock: `check_http_proxy` below transitively - // re-enters `ProxyManager::get_instance()` (via HttpDownloader -> CurlDownloader / - // RemoteFilesystem), and `std::sync::Mutex` is not reentrant, so the guard must not - // still be held when that call happens. - let proxy = ProxyManager::get_instance() - .as_ref() - .unwrap() - .get_proxy_for_request(&format!("{}://repo.packagist.org", proto)) - .map_err(|e| anyhow::anyhow!(e))?; - if !proxy.get_status(None)?.is_empty() { - let r#type = if proxy.is_secure() { "HTTPS" } else { "HTTP" }; - io.write_no_newline(&format!("Checking {} proxy with {}: ", r#type, proto)); - let r = self.check_http_proxy(&proxy, proto)?; - self.output_result(r); - } - } - Ok(()) - })(); - if let Err(e) = proxy_check_result { - if let Some(_te) = e.downcast_ref::() { - io.write_no_newline("Checking HTTP proxy: "); - let status = self.check_connectivity_and_composer_network_http_enablement(); - self.output_result(if is_string(&status) { - status - } else { - PhpMixed::String(format!("[{}] {}", get_class_err(&e), e)) - }); - } else { - return Err(e); - } + let mut result_list: Vec = vec![]; + let mut tls_warning: Option = None; + if str_starts_with(url, "https://") + && config.borrow().get("disable-tls").as_bool() == Some(true) + { + tls_warning = Some("Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.".to_string()); } - let oauth = config + match self + .http_downloader + .borrow() + .as_ref() + .unwrap() .borrow_mut() - .get("github-oauth") - .as_array() - .cloned() - .unwrap_or_default(); - if oauth.len() as i64 > 0 { - for (domain, token) in &oauth { - io.write_no_newline(&format!("Checking {} oauth access: ", domain)); - let r = self.check_github_oauth(domain, token.as_string().unwrap_or(""))?; - self.output_result(r); - } - } else { - io.write_no_newline("Checking github.com rate limit: "); - match self.get_github_rate_limit("github.com", None) { - Ok(rate) => { - if !is_array(&rate) { - self.output_result(rate); - } else if let Some(arr) = rate.as_array() { - let remaining = arr.get("remaining").and_then(|v| v.as_int()).unwrap_or(0); - let limit = arr.get("limit").and_then(|v| v.as_int()).unwrap_or(0); - if 10 > remaining { - io.write("WARNING"); - io.write(&format!( - "GitHub has a rate limit on their API. You currently have {} out of {} requests left.\nSee https://developer.github.com/v3/#rate-limiting and also\n https://getcomposer.org/doc/articles/troubleshooting.md#api-rate-limit-and-oauth-tokens", - remaining, limit, - )); - } else { - self.output_result(PhpMixed::Bool(true)); - } - } - } - Err(e) => { - if let Some(te) = e.downcast_ref::() { - if te.get_code() == 401 { - self.output_result(PhpMixed::String("The oauth token for github.com seems invalid, run \"composer config --global --unset github-oauth.github.com\" to remove it".to_string())); - } else { - self.output_result(PhpMixed::String(format!( - "[{}] {}", - get_class_err(&e), - e - ))); + .get(url, IndexMap::new()) + { + Ok(_) => {} + Err(e) => { + if let Some(te) = e.downcast_ref::() { + let hints = HttpDownloader::get_exception_hints(&e).unwrap_or_default(); + if !hints.is_empty() { + for hint in hints { + result_list.push(PhpMixed::String(hint)); } - } else { - self.output_result(PhpMixed::String(format!( - "[{}] {}", - get_class_err(&e), - e - ))); } + + result_list.push(PhpMixed::String(format!( + "[{}] {}", + std::any::type_name_of_val(te), + te.message + ))); + } else { + return Err(e); } } } - io.write_no_newline("Checking disk free space: "); - let r = self.check_disk_space(&config.borrow()); - self.output_result(r); - - Ok(self.exit_code.get()) - } + if let Some(w) = tls_warning { + result_list.push(PhpMixed::String(w)); + } - fn initialize( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result<()> { - base_command_initialize(self, input, output) - } + if !result_list.is_empty() { + return Ok(PhpMixed::List(result_list)); + } - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) + Ok(PhpMixed::Bool(true)) } - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} + fn check_http_proxy(&self, proxy: &RequestProxy, protocol: &str) -> anyhow::Result { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } -impl BaseCommand for DiagnoseCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } - - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} - -impl DiagnoseCommand { - fn check_composer_schema(&self) -> anyhow::Result { - let validator = ConfigValidator::new(self.get_io().clone()); - let (errors, _, warnings) = validator.validate(&Factory::get_composer_file()?, 0, 0); - - if !errors.is_empty() || !warnings.is_empty() { - let mut messages: IndexMap> = IndexMap::new(); - messages.insert("error".to_string(), errors); - messages.insert("warning".to_string(), warnings); - - let mut output = String::new(); - for (style, msgs) in &messages { - for msg in msgs { - output.push_str(&format!("<{}>{}{}", style, msg, style, PHP_EOL)); - } - } - - return Ok(PhpMixed::String(rtrim(&output, Some(" \t\n\r\0\u{0B}")))); - } - - Ok(PhpMixed::Bool(true)) - } - - fn check_composer_lock_schema(&self, locker: &dyn LockerInterface) -> anyhow::Result { - let json = locker.get_json_file(); - - match json.validate_schema(JsonFile::LOCK_SCHEMA, None) { - Ok(_) => {} - Err(e) => { - if let Some(jve) = e.downcast_ref::() { - let mut output = String::new(); - for error in jve.get_errors() { - output.push_str(&format!("{}{}", error, PHP_EOL)); - } - - return Ok(PhpMixed::String(trim(&output, Some(" \t\n\r\0\u{0B}")))); - } - return Err(e); - } - } - - Ok(PhpMixed::Bool(true)) - } - - fn check_git(&self) -> String { - if !shirabe_php_rpc::get_diagnostics().function_exists("proc_open") { - return "proc_open is not available, git cannot be used".to_string(); - } - - let mut output = String::new(); - let _ = self - .process - .borrow() - .as_ref() - .unwrap() - .borrow_mut() - .execute( - vec![ - "git".to_string(), - "config".to_string(), - "color.ui".to_string(), - ], - &mut output, - None, - ); - if strtolower(&trim(&output, Some(" \t\n\r\0\u{0B}"))) == "always" { - return "Your git color.ui setting is set to always, this is known to create issues. Use \"git config --global color.ui true\" to set it correctly.".to_string(); - } - - let process = self.process.borrow(); - let git_version = Git::get_version(process.as_ref().unwrap()); - let git_version = match git_version { - Some(v) => v, - None => return "No git process found".to_string(), - }; - - if version_compare("2.24.0", &git_version, ">") { - return format!( - "Your git version ({}) is too old and possibly will cause issues. Please upgrade to git 2.24 or above", - git_version - ); - } - - format!("OK git version {}", git_version) - } - - fn check_http( - &self, - proto: &str, - config: &std::rc::Rc>, - ) -> anyhow::Result { - let result = self.check_connectivity_and_composer_network_http_enablement(); - if result.as_bool() != Some(true) { - return Ok(result); - } - - let mut result_list: Vec = vec![]; - let mut tls_warning: Option = None; - if proto == "https" && config.borrow().get("disable-tls").as_bool() == Some(true) { - tls_warning = Some("Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.".to_string()); - } - - match self - .http_downloader - .borrow() - .as_ref() - .unwrap() - .borrow_mut() - .get( - &format!("{}://repo.packagist.org/packages.json", proto), - IndexMap::new(), - ) { - Ok(_) => {} - Err(e) => { - if let Some(te) = e.downcast_ref::() { - let hints = HttpDownloader::get_exception_hints(&e).unwrap_or_default(); - if !hints.is_empty() { - for hint in hints { - result_list.push(PhpMixed::String(hint)); - } - } - - result_list.push(PhpMixed::String(format!( - "[{}] {}", - std::any::type_name_of_val(te), - te.message - ))); - } else { - return Err(e); - } - } - } - - if let Some(w) = tls_warning { - result_list.push(PhpMixed::String(w)); - } - - if !result_list.is_empty() { - return Ok(PhpMixed::List(result_list)); - } - - Ok(PhpMixed::Bool(true)) - } - - fn check_composer_repo( - &self, - url: &str, - config: &std::rc::Rc>, - ) -> anyhow::Result { - let result = self.check_connectivity_and_composer_network_http_enablement(); - if result.as_bool() != Some(true) { - return Ok(result); - } - - let mut result_list: Vec = vec![]; - let mut tls_warning: Option = None; - if str_starts_with(url, "https://") - && config.borrow().get("disable-tls").as_bool() == Some(true) - { - tls_warning = Some("Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.".to_string()); - } - - match self - .http_downloader - .borrow() - .as_ref() - .unwrap() - .borrow_mut() - .get(url, IndexMap::new()) - { - Ok(_) => {} - Err(e) => { - if let Some(te) = e.downcast_ref::() { - let hints = HttpDownloader::get_exception_hints(&e).unwrap_or_default(); - if !hints.is_empty() { - for hint in hints { - result_list.push(PhpMixed::String(hint)); - } - } - - result_list.push(PhpMixed::String(format!( - "[{}] {}", - std::any::type_name_of_val(te), - te.message - ))); - } else { - return Err(e); - } - } - } - - if let Some(w) = tls_warning { - result_list.push(PhpMixed::String(w)); - } - - if !result_list.is_empty() { - return Ok(PhpMixed::List(result_list)); - } - - Ok(PhpMixed::Bool(true)) - } - - fn check_http_proxy(&self, proxy: &RequestProxy, protocol: &str) -> anyhow::Result { - let result = self.check_connectivity_and_composer_network_http_enablement(); - if result.as_bool() != Some(true) { - return Ok(result); - } - - let proxy_status = proxy.get_status(None).unwrap_or_default(); + let proxy_status = proxy.get_status(None).unwrap_or_default(); if proxy.is_excluded_by_no_proxy() { return Ok(PhpMixed::String(format!( @@ -1066,448 +677,835 @@ impl DiagnoseCommand { return "disabled via disable_functions, using php streams fallback, which reduces performance".to_string(); } - let version = shirabe_php_rpc::get_diagnostics() - .curl - .as_ref() - .expect("the diagnose payload carries curl details while the extension is loaded"); - let libz_version = version - .libz_version - .as_deref() - .filter(|v| !v.is_empty()) - .unwrap_or("missing"); - let brotli_version = version - .brotli_version - .as_deref() - .filter(|v| !v.is_empty()) - .unwrap_or("missing"); - let ssl_version = version - .ssl_version - .as_deref() - .filter(|v| !v.is_empty()) - .unwrap_or("missing"); - let has_zstd = match (version.features, version.version_zstd) { - (Some(features), Some(zstd)) => features & zstd != 0, - _ => false, - }; - let mut http_versions = "1.0, 1.1".to_string(); - if let (Some(features), Some(http2)) = (version.features, version.version_http2) - && version.has_http_version_2_0 - && http2 & features != 0 - { - http_versions.push_str(", 2"); - } - if let (Some(features), Some(http3)) = (version.features, version.version_http3) - && features & http3 != 0 - { - http_versions.push_str(", 3"); - } + let version = shirabe_php_rpc::get_diagnostics() + .curl + .as_ref() + .expect("the diagnose payload carries curl details while the extension is loaded"); + let libz_version = version + .libz_version + .as_deref() + .filter(|v| !v.is_empty()) + .unwrap_or("missing"); + let brotli_version = version + .brotli_version + .as_deref() + .filter(|v| !v.is_empty()) + .unwrap_or("missing"); + let ssl_version = version + .ssl_version + .as_deref() + .filter(|v| !v.is_empty()) + .unwrap_or("missing"); + let has_zstd = match (version.features, version.version_zstd) { + (Some(features), Some(zstd)) => features & zstd != 0, + _ => false, + }; + let mut http_versions = "1.0, 1.1".to_string(); + if let (Some(features), Some(http2)) = (version.features, version.version_http2) + && version.has_http_version_2_0 + && http2 & features != 0 + { + http_versions.push_str(", 2"); + } + if let (Some(features), Some(http3)) = (version.features, version.version_http3) + && features & http3 != 0 + { + http_versions.push_str(", 3"); + } + + return format!( + "{} libz {} brotli {} zstd {} ssl {} HTTP {}", + version.version, + libz_version, + brotli_version, + if has_zstd { "supported" } else { "missing" }, + ssl_version, + http_versions, + ); + } + + "missing, using php streams fallback, which reduces performance".to_string() + } + + fn output_result(&self, result: PhpMixed) { + let prev_exit_code = self.exit_code.get(); + let io = self.get_io(); + if result.as_bool() == Some(true) { + io.write("OK"); + + return; + } + + let mut had_error = false; + let mut had_warning = false; + let mut result = result; + // PHP: $result instanceof \Exception → already converted to string at call sites here + if !result.as_bool().unwrap_or(true) && result.as_string().is_none() && !is_array(&result) { + // falsey results should be considered as an error, even if there is nothing to output + had_error = true; + } else { + let result_list: Vec = match &result { + PhpMixed::List(l) => l.clone(), + other => vec![other.clone()], + }; + for message in &result_list { + let s = message.as_string().unwrap_or(""); + if strpos(s, "").is_some() { + had_error = true; + } else if strpos(s, "").is_some() { + had_warning = true; + } + } + // re-wrap so the final output loop works the same + result = PhpMixed::List(result_list); + } + + if had_error { + io.write("FAIL"); + } else if had_warning { + io.write("WARNING"); + } + + if !result.as_bool().unwrap_or(false) { + // PHP: if ($result) — falsey skips; this branch matches truthy + } + if let Some(list) = result.as_list() { + for message in list { + io.write(&trim( + message.as_string().unwrap_or(""), + Some(" \t\n\r\0\u{0B}"), + )); + } + } + // Apply exit code updates after io borrow ends + if had_error { + self.exit_code.set(prev_exit_code.max(2)); + } else if had_warning { + self.exit_code.set(prev_exit_code.max(1)); + } + } + + fn check_platform(&self) -> anyhow::Result { + let mut output = String::new(); + let mut display_ini_message = false; + + let mut ini_message = format!("{}{}{}", PHP_EOL, PHP_EOL, IniHelper::get_message()); + ini_message.push_str(&format!("{}If you can not modify the ini file, you can also run `php -d option=value` to modify ini values on the fly. You can use -d multiple times.", PHP_EOL)); + + let diagnostics = shirabe_php_rpc::get_diagnostics(); + + let mut errors: IndexMap = IndexMap::new(); + let mut warnings: IndexMap = IndexMap::new(); + + if !diagnostics.function_exists("json_decode") { + errors.insert("json".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.extension_loaded("Phar") { + errors.insert("phar".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.extension_loaded("filter") { + errors.insert("filter".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.extension_loaded("hash") { + errors.insert("hash".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.extension_loaded("iconv") && !diagnostics.extension_loaded("mbstring") { + errors.insert("iconv_mbstring".to_string(), PhpMixed::Bool(true)); + } + + if !filter_var_boolean(diagnostics.ini_get("allow_url_fopen").unwrap_or("")) { + errors.insert("allow_url_fopen".to_string(), PhpMixed::Bool(true)); + } + + if diagnostics.extension_loaded("ionCube Loader") + && diagnostics.ioncube_loader_iversion < 40009 + { + errors.insert( + "ioncube".to_string(), + PhpMixed::String(diagnostics.ioncube_loader_version.clone()), + ); + } + + if diagnostics.php_version_id < 70205 { + errors.insert( + "php".to_string(), + PhpMixed::String(diagnostics.php_version.clone()), + ); + } + + if !diagnostics.extension_loaded("openssl") { + errors.insert("openssl".to_string(), PhpMixed::Bool(true)); + } + + if diagnostics.extension_loaded("openssl") + && diagnostics.openssl_version_number < 0x1000100f + { + warnings.insert("openssl_version".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.has_hhvm_version + && !diagnostics.extension_loaded("apcu") + && filter_var_boolean(diagnostics.ini_get("apc.enable_cli").unwrap_or("")) + { + warnings.insert("apc_cli".to_string(), PhpMixed::Bool(true)); + } + + if !diagnostics.extension_loaded("zlib") { + warnings.insert("zlib".to_string(), PhpMixed::Bool(true)); + } + + let mut phpinfo_match: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!("{Configure Command(?: *| *=> *)(.*?)(?:|$)}m"), + &diagnostics.phpinfo_general, + Some(&mut phpinfo_match), + ) { + let configure = phpinfo_match + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); + let configure = configure.as_str(); + + if str_contains(configure, "--enable-sigchild") { + warnings.insert("sigchild".to_string(), PhpMixed::Bool(true)); + } + + if str_contains(configure, "--with-curlwrappers") { + warnings.insert("curlwrappers".to_string(), PhpMixed::Bool(true)); + } + } + + if filter_var_boolean(diagnostics.ini_get("xdebug.profiler_enabled").unwrap_or("")) { + warnings.insert("xdebug_profile".to_string(), PhpMixed::Bool(true)); + } else if diagnostics.xdebug_active { + // PHP: XdebugHandler::isXdebugActive(). As with IniHelper::get_all, the port of that + // method in shirabe_external_packages cannot reach the PHP RPC bridge (the dependency + // would cycle), so the real runtime is queried through the diagnose payload instead. + warnings.insert("xdebug_loaded".to_string(), PhpMixed::Bool(true)); + } + + if diagnostics.has_php_windows_version_build + && (version_compare(&diagnostics.php_version, "7.2.23", "<") + || (version_compare(&diagnostics.php_version, "7.3.0", ">=") + && version_compare(&diagnostics.php_version, "7.3.10", "<"))) + { + warnings.insert( + "onedrive".to_string(), + PhpMixed::String(diagnostics.php_version.clone()), + ); + } + + if diagnostics.extension_loaded("uopz") + && !(filter_var_boolean(diagnostics.ini_get("uopz.disable").unwrap_or("")) + || filter_var_boolean(diagnostics.ini_get("uopz.exit").unwrap_or(""))) + { + warnings.insert("uopz".to_string(), PhpMixed::Bool(true)); + } + + let out_fn = |msg: &str, style: &str, output: &mut String| { + output.push_str(&format!("<{}>{}{}", style, msg, style, PHP_EOL)); + }; + + if !errors.is_empty() { + for (error, current) in &errors { + let text = match error.as_str() { + "json" => format!( + "{}The json extension is missing.{}Install it or recompile php without --disable-json", + PHP_EOL, PHP_EOL + ), + "phar" => format!( + "{}The phar extension is missing.{}Install it or recompile php without --disable-phar", + PHP_EOL, PHP_EOL + ), + "filter" => format!( + "{}The filter extension is missing.{}Install it or recompile php without --disable-filter", + PHP_EOL, PHP_EOL + ), + "hash" => format!( + "{}The hash extension is missing.{}Install it or recompile php without --disable-hash", + PHP_EOL, PHP_EOL + ), + "iconv_mbstring" => format!( + "{}The iconv OR mbstring extension is required and both are missing.{}Install either of them or recompile php without --disable-iconv", + PHP_EOL, PHP_EOL + ), + "php" => format!( + "{}Your PHP ({}) is too old, you must upgrade to PHP 7.2.5 or higher.", + PHP_EOL, + current.as_string().unwrap_or("") + ), + "allow_url_fopen" => { + display_ini_message = true; + format!( + "{}The allow_url_fopen setting is incorrect.{}Add the following to the end of your `php.ini`:{} allow_url_fopen = On", + PHP_EOL, PHP_EOL, PHP_EOL + ) + } + "ioncube" => { + display_ini_message = true; + format!( + "{}Your ionCube Loader extension ({}) is incompatible with Phar files.{}Upgrade to ionCube 4.0.9 or higher or remove this line (path may be different) from your `php.ini` to disable it:{} zend_extension = /usr/lib/php5/20090626+lfs/ioncube_loader_lin_5.3.so", + PHP_EOL, + current.as_string().unwrap_or(""), + PHP_EOL, + PHP_EOL + ) + } + "openssl" => format!( + "{}The openssl extension is missing, which means that secure HTTPS transfers are impossible.{}If possible you should enable it or recompile php with --with-openssl", + PHP_EOL, PHP_EOL + ), + other => { + return Err(InvalidArgumentException { + message: format!( + "DiagnoseCommand: Unknown error type \"{}\". Please report at https://github.com/composer/composer/issues/new.", + other, + ), + code: 0, + } + .into()); + } + }; + out_fn(&text, "error", &mut output); + } + + output.push_str(PHP_EOL); + } + + if !warnings.is_empty() { + for (warning, current) in &warnings { + let text = match warning.as_str() { + "apc_cli" => { + display_ini_message = true; + format!( + "The apc.enable_cli setting is incorrect.{}Add the following to the end of your `php.ini`:{} apc.enable_cli = Off", + PHP_EOL, PHP_EOL + ) + } + "zlib" => { + display_ini_message = true; + format!( + "The zlib extension is not loaded, this can slow down Composer a lot.{}If possible, enable it or recompile php with --with-zlib{}", + PHP_EOL, PHP_EOL + ) + } + "sigchild" => format!( + "PHP was compiled with --enable-sigchild which can cause issues on some platforms.{}Recompile it without this flag if possible, see also:{} https://bugs.php.net/bug.php?id=22999", + PHP_EOL, PHP_EOL + ), + "curlwrappers" => format!( + "PHP was compiled with --with-curlwrappers which will cause issues with HTTP authentication and GitHub.{} Recompile it without this flag if possible", + PHP_EOL + ), + "openssl_version" => { + // Attempt to parse version number out, fallback to whole string value. + let openssl_version_text = + diagnostics.openssl_version_text.clone().unwrap_or_default(); + let openssl_trimmed = trim( + &strstr(&openssl_version_text, " ").unwrap_or_default(), + Some(" \t\n\r\0\u{0B}"), + ); + let mut openssl_version = + strstr3(&openssl_trimmed, " ", true).unwrap_or_default(); + if openssl_version.is_empty() { + openssl_version = openssl_version_text; + } + + format!( + "The OpenSSL library ({}) used by PHP does not support TLSv1.2 or TLSv1.1.{}If possible you should upgrade OpenSSL to version 1.0.1 or above.", + openssl_version, PHP_EOL + ) + } + "xdebug_loaded" => format!( + "The xdebug extension is loaded, this can slow down Composer a little.{} Disabling it when using Composer is recommended.", + PHP_EOL + ), + "xdebug_profile" => { + display_ini_message = true; + format!( + "The xdebug.profiler_enabled setting is enabled, this can slow down Composer a lot.{}Add the following to the end of your `php.ini` to disable it:{} xdebug.profiler_enabled = 0", + PHP_EOL, PHP_EOL + ) + } + "onedrive" => format!( + "The Windows OneDrive folder is not supported on PHP versions below 7.2.23 and 7.3.10.{}Upgrade your PHP ({}) to use this location with Composer.{}", + PHP_EOL, + current.as_string().unwrap_or(""), + PHP_EOL + ), + "uopz" => format!( + "The uopz extension ignores exit calls and may not work with all Composer commands.{}Disabling it when using Composer is recommended.", + PHP_EOL + ), + other => { + return Err(InvalidArgumentException { + message: format!( + "DiagnoseCommand: Unknown warning type \"{}\". Please report at https://github.com/composer/composer/issues/new.", + other, + ), + code: 0, + } + .into()); + } + }; + out_fn(&text, "comment", &mut output); + } + } + + if display_ini_message { + out_fn(&ini_message, "comment", &mut output); + } - return format!( - "{} libz {} brotli {} zstd {} ssl {} HTTP {}", - version.version, - libz_version, - brotli_version, - if has_zstd { "supported" } else { "missing" }, - ssl_version, - http_versions, + let composer_ipresolve = Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default(); + if ["4".to_string(), "6".to_string()].contains(&composer_ipresolve) { + warnings.insert("ipresolve".to_string(), PhpMixed::Bool(true)); + out_fn( + &format!( + "The COMPOSER_IPRESOLVE env var is set to {} which may result in network failures below.", + Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default() + ), + "comment", + &mut output, ); } - "missing, using php streams fallback, which reduces performance".to_string() + Ok(if warnings.is_empty() && errors.is_empty() { + PhpMixed::Bool(true) + } else { + PhpMixed::String(output) + }) } - fn output_result(&self, result: PhpMixed) { - let prev_exit_code = self.exit_code.get(); - let io = self.get_io(); - if result.as_bool() == Some(true) { - io.write("OK"); - - return; + /// Check if allow_url_fopen is ON + fn check_connectivity(&self) -> PhpMixed { + // PHP: if (!ini_get('allow_url_fopen')) — a missing setting, "" and "0" are all falsey. + let allow_url_fopen = shirabe_php_rpc::get_diagnostics().ini_get("allow_url_fopen"); + if !allow_url_fopen.is_some_and(|value| !value.is_empty() && value != "0") { + return PhpMixed::String( + "SKIP Because allow_url_fopen is missing.".to_string(), + ); } - let mut had_error = false; - let mut had_warning = false; - let mut result = result; - // PHP: $result instanceof \Exception → already converted to string at call sites here - if !result.as_bool().unwrap_or(true) && result.as_string().is_none() && !is_array(&result) { - // falsey results should be considered as an error, even if there is nothing to output - had_error = true; - } else { - let result_list: Vec = match &result { - PhpMixed::List(l) => l.clone(), - other => vec![other.clone()], - }; - for message in &result_list { - let s = message.as_string().unwrap_or(""); - if strpos(s, "").is_some() { - had_error = true; - } else if strpos(s, "").is_some() { - had_warning = true; - } - } - // re-wrap so the final output loop works the same - result = PhpMixed::List(result_list); - } + PhpMixed::Bool(true) + } - if had_error { - io.write("FAIL"); - } else if had_warning { - io.write("WARNING"); + fn check_connectivity_and_composer_network_http_enablement(&self) -> PhpMixed { + let result = self.check_connectivity(); + if result.as_bool() != Some(true) { + return result; } - if !result.as_bool().unwrap_or(false) { - // PHP: if ($result) — falsey skips; this branch matches truthy - } - if let Some(list) = result.as_list() { - for message in list { - io.write(&trim( - message.as_string().unwrap_or(""), - Some(" \t\n\r\0\u{0B}"), - )); - } + let result = self.check_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return result; } - // Apply exit code updates after io borrow ends - if had_error { - self.exit_code.set(prev_exit_code.max(2)); - } else if had_warning { - self.exit_code.set(prev_exit_code.max(1)); + + PhpMixed::Bool(true) + } + + /// Check if Composer network is enabled for HTTP/S + fn check_composer_network_http_enablement(&self) -> PhpMixed { + if Platform::get_env("COMPOSER_DISABLE_NETWORK") + .map(|v| !v.is_empty() && v != "0") + .unwrap_or(false) + { + return PhpMixed::String( + "SKIP Network is disabled by COMPOSER_DISABLE_NETWORK." + .to_string(), + ); } + + PhpMixed::Bool(true) } +} - fn check_platform(&self) -> anyhow::Result { - let mut output = String::new(); - let mut display_ini_message = false; +impl Command for DiagnoseCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("diagnose")?; + self.set_description("Diagnoses the system to identify common errors"); + self.set_help( + "The diagnose command checks common errors to help debugging problems.\n\n\ + The process exit code will be 1 in case of warnings and 2 for errors.\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#diagnose", + ); + Ok(()) + } - let mut ini_message = format!("{}{}{}", PHP_EOL, PHP_EOL, IniHelper::get_message()); - ini_message.push_str(&format!("{}If you can not modify the ini file, you can also run `php -d option=value` to modify ini values on the fly. You can use -d multiple times.", PHP_EOL)); + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let mut composer = self.try_composer(None, None); + let io: std::rc::Rc> = self.get_io().clone(); - let diagnostics = shirabe_php_rpc::get_diagnostics(); + let config: std::rc::Rc>; + if let Some(ref mut c) = composer { + let c = crate::composer::composer_full(c); + config = c.get_config(); - let mut errors: IndexMap = IndexMap::new(); - let mut warnings: IndexMap = IndexMap::new(); + let command_event = CommandEvent::new6( + PluginEvents::COMMAND, + "diagnose", + input, + output, + vec![], + IndexMap::new(), + ); + c.get_event_dispatcher() + .borrow_mut() + .dispatch(Some(command_event.get_name()), None); + *self.process.borrow_mut() = Some( + c.get_loop() + .borrow() + .get_process_executor() + .map(std::rc::Rc::clone) + .unwrap_or_else(|| { + std::rc::Rc::new(std::cell::RefCell::new(ProcessExecutor::new(Some( + io.clone(), + )))) + }), + ); + } else { + config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config(None, None)?)); - if !diagnostics.function_exists("json_decode") { - errors.insert("json".to_string(), PhpMixed::Bool(true)); + *self.process.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new( + ProcessExecutor::new(Some(io.clone())), + ))); } + let mut config_inner = IndexMap::new(); + config_inner.insert("secure-http".to_string(), PhpMixed::Bool(false)); + let mut secure_http_wrap: IndexMap = IndexMap::new(); + secure_http_wrap.insert("config".to_string(), PhpMixed::Array(config_inner)); + let config = config; + config + .borrow_mut() + .merge(&secure_http_wrap, Config::SOURCE_COMMAND); + let _ = config.borrow_mut().prohibit_url_by_config( + "http://repo.packagist.org", + Some(std::rc::Rc::new(std::cell::RefCell::new(NullIO::new()))), + &IndexMap::new(), + ); - if !diagnostics.extension_loaded("Phar") { - errors.insert("phar".to_string(), PhpMixed::Bool(true)); - } + *self.http_downloader.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new( + Factory::create_http_downloader(io.clone(), &config, indexmap::IndexMap::new())?, + ))); - if !diagnostics.extension_loaded("filter") { - errors.insert("filter".to_string(), PhpMixed::Bool(true)); - } + if strpos(file!(), "phar:") == Some(0) { + io.write_no_newline("Checking pubkeys: "); + let r = self.check_pub_keys(&config.borrow())?; + self.output_result(r); - if !diagnostics.extension_loaded("hash") { - errors.insert("hash".to_string(), PhpMixed::Bool(true)); + io.write_no_newline("Checking Composer version: "); + let r = self.check_version(&config)?; + self.output_result(r); } - if !diagnostics.extension_loaded("iconv") && !diagnostics.extension_loaded("mbstring") { - errors.insert("iconv_mbstring".to_string(), PhpMixed::Bool(true)); - } + io.write(&format!( + "Composer version: {}", + composer::get_version() + )); - if !filter_var_boolean(diagnostics.ini_get("allow_url_fopen").unwrap_or("")) { - errors.insert("allow_url_fopen".to_string(), PhpMixed::Bool(true)); - } + io.write_no_newline("Checking Composer and its dependencies for vulnerabilities: "); + let r = self.check_composer_audit(&config)?; + self.output_result(r); - if diagnostics.extension_loaded("ionCube Loader") - && diagnostics.ioncube_loader_iversion < 40009 + let platform_overrides = config + .borrow_mut() + .get("platform") + .as_array() + .cloned() + .unwrap_or_default(); + let platform_overrides_unboxed: indexmap::IndexMap = + platform_overrides.into_iter().collect(); + let mut platform_repo = + PlatformRepository::new(vec![], platform_overrides_unboxed).unwrap(); + let php_pkg = ::find_package( + &mut platform_repo, + "php", + crate::repository::FindPackageConstraint::String("*".to_string()), + )? + .unwrap(); + let mut php_version = php_pkg.get_pretty_version(); + if let Some(cp) = php_pkg.as_complete() + && str_contains(&cp.get_description().unwrap_or_default(), "overridden") { - errors.insert( - "ioncube".to_string(), - PhpMixed::String(diagnostics.ioncube_loader_version.clone()), + php_version = format!( + "{} - {}", + php_version, + cp.get_description().unwrap_or_default() ); } - if diagnostics.php_version_id < 70205 { - errors.insert( - "php".to_string(), - PhpMixed::String(diagnostics.php_version.clone()), - ); - } + io.write(&format!("PHP version: {}", php_version)); - if !diagnostics.extension_loaded("openssl") { - errors.insert("openssl".to_string(), PhpMixed::Bool(true)); - } + let diagnostics = shirabe_php_rpc::get_diagnostics(); - if diagnostics.extension_loaded("openssl") - && diagnostics.openssl_version_number < 0x1000100f - { - warnings.insert("openssl_version".to_string(), PhpMixed::Bool(true)); + if let Some(php_binary) = &diagnostics.php_binary { + io.write(&format!( + "PHP binary path: {}", + php_binary + )); } - if !diagnostics.has_hhvm_version - && !diagnostics.extension_loaded("apcu") - && filter_var_boolean(diagnostics.ini_get("apc.enable_cli").unwrap_or("")) + io.write(&format!( + "OpenSSL version: {}", + match &diagnostics.openssl_version_text { + Some(text) => format!("{}", text), + None => "missing".to_string(), + } + )); + io.write(&format!("curl version: {}", self.get_curl_version())); + + let finder = ExecutableFinder::new(); + let has_system_unzip = finder.find("unzip", None, &[]).is_some(); + let mut bin_7zip = String::new(); + let has_system_7zip = if finder + .find("7z", None, &["C:\\Program Files\\7-Zip".to_string()]) + .is_some() { - warnings.insert("apc_cli".to_string(), PhpMixed::Bool(true)); - } + bin_7zip = "7z".to_string(); + true + } else if !Platform::is_windows() && finder.find("7zz", None, &[]).is_some() { + bin_7zip = "7zz".to_string(); + true + } else if !Platform::is_windows() && finder.find("7za", None, &[]).is_some() { + bin_7zip = "7za".to_string(); + true + } else { + false + }; - if !diagnostics.extension_loaded("zlib") { - warnings.insert("zlib".to_string(), PhpMixed::Bool(true)); - } + io.write(&format!( + "zip: {}, {}, {}{}", + if diagnostics.extension_loaded("zip") { + "extension present" + } else { + "extension not loaded" + }, + if has_system_unzip { + "unzip present".to_string() + } else { + "unzip not available".to_string() + }, + if has_system_7zip { + format!("7-Zip present ({})", bin_7zip) + } else { + "7-Zip not available".to_string() + }, + if (has_system_7zip || has_system_unzip) && !diagnostics.function_exists("proc_open") { + ", proc_open is disabled or not present, unzip/7-z will not be usable" + } else { + "" + } + )); - let mut phpinfo_match: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!("{Configure Command(?: *| *=> *)(.*?)(?:|$)}m"), - &diagnostics.phpinfo_general, - Some(&mut phpinfo_match), - ) { - let configure = phpinfo_match - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - let configure = configure.as_str(); + if let Some(ref mut c) = composer { + let c = crate::composer::composer_full(c); + io.write(&format!( + "Active plugins: {}", + implode( + ", ", + &c.get_plugin_manager().borrow().get_registered_plugins() + ) + )); - if str_contains(configure, "--enable-sigchild") { - warnings.insert("sigchild".to_string(), PhpMixed::Bool(true)); - } + io.write_no_newline("Checking composer.json: "); + let r = self.check_composer_schema()?; + self.output_result(r); - if str_contains(configure, "--with-curlwrappers") { - warnings.insert("curlwrappers".to_string(), PhpMixed::Bool(true)); + if c.get_locker().borrow_mut().is_locked() { + io.write_no_newline("Checking composer.lock: "); + let locker = c.get_locker().clone(); + let locker = locker.borrow(); + let r = self.check_composer_lock_schema(&*locker)?; + self.output_result(r); } } - if filter_var_boolean(diagnostics.ini_get("xdebug.profiler_enabled").unwrap_or("")) { - warnings.insert("xdebug_profile".to_string(), PhpMixed::Bool(true)); - } else if diagnostics.xdebug_active { - // PHP: XdebugHandler::isXdebugActive(). As with IniHelper::get_all, the port of that - // method in shirabe_external_packages cannot reach the PHP RPC bridge (the dependency - // would cycle), so the real runtime is queried through the diagnose payload instead. - warnings.insert("xdebug_loaded".to_string(), PhpMixed::Bool(true)); - } + io.write_no_newline("Checking platform settings: "); + let r = self.check_platform()?; + self.output_result(r); - if diagnostics.has_php_windows_version_build - && (version_compare(&diagnostics.php_version, "7.2.23", "<") - || (version_compare(&diagnostics.php_version, "7.3.0", ">=") - && version_compare(&diagnostics.php_version, "7.3.10", "<"))) - { - warnings.insert( - "onedrive".to_string(), - PhpMixed::String(diagnostics.php_version.clone()), - ); - } + io.write_no_newline("Checking git settings: "); + let r = self.check_git(); + self.output_result(PhpMixed::String(r)); - if diagnostics.extension_loaded("uopz") - && !(filter_var_boolean(diagnostics.ini_get("uopz.disable").unwrap_or("")) - || filter_var_boolean(diagnostics.ini_get("uopz.exit").unwrap_or(""))) - { - warnings.insert("uopz".to_string(), PhpMixed::Bool(true)); - } + io.write_no_newline("Checking http connectivity to packagist: "); + let r = self.check_http("http", &config)?; + self.output_result(r); - let out_fn = |msg: &str, style: &str, output: &mut String| { - output.push_str(&format!("<{}>{}{}", style, msg, style, PHP_EOL)); - }; + io.write_no_newline("Checking https connectivity to packagist: "); + let r = self.check_http("https", &config)?; + self.output_result(r); - if !errors.is_empty() { - for (error, current) in &errors { - let text = match error.as_str() { - "json" => format!( - "{}The json extension is missing.{}Install it or recompile php without --disable-json", - PHP_EOL, PHP_EOL - ), - "phar" => format!( - "{}The phar extension is missing.{}Install it or recompile php without --disable-phar", - PHP_EOL, PHP_EOL - ), - "filter" => format!( - "{}The filter extension is missing.{}Install it or recompile php without --disable-filter", - PHP_EOL, PHP_EOL - ), - "hash" => format!( - "{}The hash extension is missing.{}Install it or recompile php without --disable-hash", - PHP_EOL, PHP_EOL - ), - "iconv_mbstring" => format!( - "{}The iconv OR mbstring extension is required and both are missing.{}Install either of them or recompile php without --disable-iconv", - PHP_EOL, PHP_EOL - ), - "php" => format!( - "{}Your PHP ({}) is too old, you must upgrade to PHP 7.2.5 or higher.", - PHP_EOL, - current.as_string().unwrap_or("") - ), - "allow_url_fopen" => { - display_ini_message = true; - format!( - "{}The allow_url_fopen setting is incorrect.{}Add the following to the end of your `php.ini`:{} allow_url_fopen = On", - PHP_EOL, PHP_EOL, PHP_EOL - ) - } - "ioncube" => { - display_ini_message = true; - format!( - "{}Your ionCube Loader extension ({}) is incompatible with Phar files.{}Upgrade to ionCube 4.0.9 or higher or remove this line (path may be different) from your `php.ini` to disable it:{} zend_extension = /usr/lib/php5/20090626+lfs/ioncube_loader_lin_5.3.so", - PHP_EOL, - current.as_string().unwrap_or(""), - PHP_EOL, - PHP_EOL - ) - } - "openssl" => format!( - "{}The openssl extension is missing, which means that secure HTTPS transfers are impossible.{}If possible you should enable it or recompile php with --with-openssl", - PHP_EOL, PHP_EOL - ), - other => { - return Err(InvalidArgumentException { - message: format!( - "DiagnoseCommand: Unknown error type \"{}\". Please report at https://github.com/composer/composer/issues/new.", - other, - ), - code: 0, - } - .into()); - } - }; - out_fn(&text, "error", &mut output); + let repositories = config.borrow().get_repositories(); + for repo in repositories { + let repo_arr = repo.1.as_array().cloned().unwrap_or_default(); + if repo_arr.get("type").and_then(|v| v.as_string()) == Some("composer") + && repo_arr.get("url").is_some() + { + let repo_arr_unboxed: indexmap::IndexMap = repo_arr + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let composer_repo = ComposerRepository::new( + repo_arr_unboxed, + self.get_io().clone(), + &config.borrow(), + self.http_downloader.borrow().clone().unwrap(), + None, + ) + .unwrap(); + // PHP: ReflectionMethod($composerRepo, 'getPackagesJsonUrl') + // We surface the same internal call by directly invoking the equivalent method. + // TODO(plugin): support reflection-based access if plugin code requires it. + let url = composer_repo.get_packages_json_url(); + if !str_starts_with(&url, "http") { + continue; + } + if str_starts_with(&url, "https://repo.packagist.org") { + continue; + } + io.write_no_newline(&format!( + "Checking connectivity to {}: ", + repo_arr + .get("url") + .and_then(|v| v.as_string()) + .unwrap_or("") + )); + let r = self.check_composer_repo(&url, &config)?; + self.output_result(r); } + } - output.push_str(PHP_EOL); + let protos: Vec<&str> = if config.borrow_mut().get("disable-tls").as_bool() == Some(true) { + vec!["http"] + } else { + vec!["http", "https"] + }; + let proxy_check_result: anyhow::Result<(), anyhow::Error> = (|| -> anyhow::Result<()> { + for proto in &protos { + // Compute the proxy under a short-lived lock: `check_http_proxy` below transitively + // re-enters `ProxyManager::get_instance()` (via HttpDownloader -> CurlDownloader / + // RemoteFilesystem), and `std::sync::Mutex` is not reentrant, so the guard must not + // still be held when that call happens. + let proxy = ProxyManager::get_instance() + .as_ref() + .unwrap() + .get_proxy_for_request(&format!("{}://repo.packagist.org", proto)) + .map_err(|e| anyhow::anyhow!(e))?; + if !proxy.get_status(None)?.is_empty() { + let r#type = if proxy.is_secure() { "HTTPS" } else { "HTTP" }; + io.write_no_newline(&format!("Checking {} proxy with {}: ", r#type, proto)); + let r = self.check_http_proxy(&proxy, proto)?; + self.output_result(r); + } + } + Ok(()) + })(); + if let Err(e) = proxy_check_result { + if let Some(_te) = e.downcast_ref::() { + io.write_no_newline("Checking HTTP proxy: "); + let status = self.check_connectivity_and_composer_network_http_enablement(); + self.output_result(if is_string(&status) { + status + } else { + PhpMixed::String(format!("[{}] {}", get_class_err(&e), e)) + }); + } else { + return Err(e); + } } - if !warnings.is_empty() { - for (warning, current) in &warnings { - let text = match warning.as_str() { - "apc_cli" => { - display_ini_message = true; - format!( - "The apc.enable_cli setting is incorrect.{}Add the following to the end of your `php.ini`:{} apc.enable_cli = Off", - PHP_EOL, PHP_EOL - ) - } - "zlib" => { - display_ini_message = true; - format!( - "The zlib extension is not loaded, this can slow down Composer a lot.{}If possible, enable it or recompile php with --with-zlib{}", - PHP_EOL, PHP_EOL - ) - } - "sigchild" => format!( - "PHP was compiled with --enable-sigchild which can cause issues on some platforms.{}Recompile it without this flag if possible, see also:{} https://bugs.php.net/bug.php?id=22999", - PHP_EOL, PHP_EOL - ), - "curlwrappers" => format!( - "PHP was compiled with --with-curlwrappers which will cause issues with HTTP authentication and GitHub.{} Recompile it without this flag if possible", - PHP_EOL - ), - "openssl_version" => { - // Attempt to parse version number out, fallback to whole string value. - let openssl_version_text = - diagnostics.openssl_version_text.clone().unwrap_or_default(); - let openssl_trimmed = trim( - &strstr(&openssl_version_text, " ").unwrap_or_default(), - Some(" \t\n\r\0\u{0B}"), - ); - let mut openssl_version = - strstr3(&openssl_trimmed, " ", true).unwrap_or_default(); - if openssl_version.is_empty() { - openssl_version = openssl_version_text; + let oauth = config + .borrow_mut() + .get("github-oauth") + .as_array() + .cloned() + .unwrap_or_default(); + if oauth.len() as i64 > 0 { + for (domain, token) in &oauth { + io.write_no_newline(&format!("Checking {} oauth access: ", domain)); + let r = self.check_github_oauth(domain, token.as_string().unwrap_or(""))?; + self.output_result(r); + } + } else { + io.write_no_newline("Checking github.com rate limit: "); + match self.get_github_rate_limit("github.com", None) { + Ok(rate) => { + if !is_array(&rate) { + self.output_result(rate); + } else if let Some(arr) = rate.as_array() { + let remaining = arr.get("remaining").and_then(|v| v.as_int()).unwrap_or(0); + let limit = arr.get("limit").and_then(|v| v.as_int()).unwrap_or(0); + if 10 > remaining { + io.write("WARNING"); + io.write(&format!( + "GitHub has a rate limit on their API. You currently have {} out of {} requests left.\nSee https://developer.github.com/v3/#rate-limiting and also\n https://getcomposer.org/doc/articles/troubleshooting.md#api-rate-limit-and-oauth-tokens", + remaining, limit, + )); + } else { + self.output_result(PhpMixed::Bool(true)); } - - format!( - "The OpenSSL library ({}) used by PHP does not support TLSv1.2 or TLSv1.1.{}If possible you should upgrade OpenSSL to version 1.0.1 or above.", - openssl_version, PHP_EOL - ) } - "xdebug_loaded" => format!( - "The xdebug extension is loaded, this can slow down Composer a little.{} Disabling it when using Composer is recommended.", - PHP_EOL - ), - "xdebug_profile" => { - display_ini_message = true; - format!( - "The xdebug.profiler_enabled setting is enabled, this can slow down Composer a lot.{}Add the following to the end of your `php.ini` to disable it:{} xdebug.profiler_enabled = 0", - PHP_EOL, PHP_EOL - ) - } - "onedrive" => format!( - "The Windows OneDrive folder is not supported on PHP versions below 7.2.23 and 7.3.10.{}Upgrade your PHP ({}) to use this location with Composer.{}", - PHP_EOL, - current.as_string().unwrap_or(""), - PHP_EOL - ), - "uopz" => format!( - "The uopz extension ignores exit calls and may not work with all Composer commands.{}Disabling it when using Composer is recommended.", - PHP_EOL - ), - other => { - return Err(InvalidArgumentException { - message: format!( - "DiagnoseCommand: Unknown warning type \"{}\". Please report at https://github.com/composer/composer/issues/new.", - other, - ), - code: 0, + } + Err(e) => { + if let Some(te) = e.downcast_ref::() { + if te.get_code() == 401 { + self.output_result(PhpMixed::String("The oauth token for github.com seems invalid, run \"composer config --global --unset github-oauth.github.com\" to remove it".to_string())); + } else { + self.output_result(PhpMixed::String(format!( + "[{}] {}", + get_class_err(&e), + e + ))); } - .into()); + } else { + self.output_result(PhpMixed::String(format!( + "[{}] {}", + get_class_err(&e), + e + ))); } - }; - out_fn(&text, "comment", &mut output); + } } } - if display_ini_message { - out_fn(&ini_message, "comment", &mut output); - } - - let composer_ipresolve = Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default(); - if ["4".to_string(), "6".to_string()].contains(&composer_ipresolve) { - warnings.insert("ipresolve".to_string(), PhpMixed::Bool(true)); - out_fn( - &format!( - "The COMPOSER_IPRESOLVE env var is set to {} which may result in network failures below.", - Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default() - ), - "comment", - &mut output, - ); - } + io.write_no_newline("Checking disk free space: "); + let r = self.check_disk_space(&config.borrow()); + self.output_result(r); - Ok(if warnings.is_empty() && errors.is_empty() { - PhpMixed::Bool(true) - } else { - PhpMixed::String(output) - }) + Ok(self.exit_code.get()) } - /// Check if allow_url_fopen is ON - fn check_connectivity(&self) -> PhpMixed { - // PHP: if (!ini_get('allow_url_fopen')) — a missing setting, "" and "0" are all falsey. - let allow_url_fopen = shirabe_php_rpc::get_diagnostics().ini_get("allow_url_fopen"); - if !allow_url_fopen.is_some_and(|value| !value.is_empty() && value != "0") { - return PhpMixed::String( - "SKIP Because allow_url_fopen is missing.".to_string(), - ); - } - - PhpMixed::Bool(true) + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + base_command_initialize(self, input, output) } - fn check_connectivity_and_composer_network_http_enablement(&self) -> PhpMixed { - let result = self.check_connectivity(); - if result.as_bool() != Some(true) { - return result; - } - - let result = self.check_composer_network_http_enablement(); - if result.as_bool() != Some(true) { - return result; - } - - PhpMixed::Bool(true) + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) } - /// Check if Composer network is enabled for HTTP/S - fn check_composer_network_http_enablement(&self) -> PhpMixed { - if Platform::get_env("COMPOSER_DISABLE_NETWORK") - .map(|v| !v.is_empty() && v != "0") - .unwrap_or(false) - { - return PhpMixed::String( - "SKIP Network is disabled by COMPOSER_DISABLE_NETWORK." - .to_string(), - ); - } + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} - PhpMixed::Bool(true) +impl BaseCommand for DiagnoseCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } diff --git a/crates/shirabe/src/command/init_command.rs b/crates/shirabe/src/command/init_command.rs index 99a7de00..f2bf19d7 100644 --- a/crates/shirabe/src/command/init_command.rs +++ b/crates/shirabe/src/command/init_command.rs @@ -84,352 +84,716 @@ impl InitCommand { .expect("InitCommand::configure uses static, valid metadata"); command } -} -impl Command for InitCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("init")?; - self.set_description("Creates a basic composer.json file in current directory"); - self.set_definition(&[ - InputOption::new("name", None, Some(InputOption::VALUE_REQUIRED), "Name of the package", None).unwrap().into(), - InputOption::new("description", None, Some(InputOption::VALUE_REQUIRED), "Description of package", None).unwrap().into(), - InputOption::new("author", None, Some(InputOption::VALUE_REQUIRED), "Author name of package", None).unwrap().into(), - InputOption::new("type", None, Some(InputOption::VALUE_REQUIRED), "Type of package (e.g. library, project, metapackage, composer-plugin)", None).unwrap().into(), - InputOption::new("homepage", None, Some(InputOption::VALUE_REQUIRED), "Homepage of package", None).unwrap().into(), - InputOption::new6("require", None, Some(InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), "Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), - InputOption::new6("require-dev", None, Some(InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), "Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), - InputOption::new("stability", Some(PhpMixed::String("s".to_string())), Some(InputOption::VALUE_REQUIRED), &format!("Minimum stability (empty or one of: {})", implode(", ", &base_package::STABILITIES.keys().map(|k| k.to_string()).collect::>())), None).unwrap().into(), - InputOption::new("license", Some(PhpMixed::String("l".to_string())), Some(InputOption::VALUE_REQUIRED), "License of package", None).unwrap().into(), - InputOption::new("repository", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Add custom repositories, either by URL or using JSON arrays", None).unwrap().into(), - InputOption::new("autoload", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_REQUIRED), "Add PSR-4 autoload mapping. Maps your package's namespace to the provided directory. (Expects a relative path, e.g. src/)", None).unwrap().into(), - ]); - self.set_help( - "The init command creates a basic composer.json file\n\ - in the current directory.\n\ - \n\ - shirabe init\n\ - \n\ - Read more at https://getcomposer.org/doc/03-cli.md#init", - ); - Ok(()) + fn parse_author_string( + &self, + author: &str, + ) -> anyhow::Result>> { + let mut m: IndexMap = IndexMap::new(); + if Preg::is_match3( + php_regex!(r#"/^(?P[- .,\p{L}\p{N}\p{Mn}\'’\"()]+)(?:\s+<(?P.+?)>)?$/u"#), + author, + Some(&mut m), + ) { + let email = m.get(&CaptureKey::ByName("email".to_string())).cloned(); + if let Some(ref email) = email + && !self.is_valid_email(email) + { + return Err(InvalidArgumentException { + message: format!("Invalid email \"{}\"", email), + code: 0, + } + .into()); + } + + let mut result: IndexMap> = IndexMap::new(); + result.insert( + "name".to_string(), + Some(trim( + &m.get(&CaptureKey::ByName("name".to_string())) + .cloned() + .unwrap_or_default(), + None, + )), + ); + result.insert("email".to_string(), email); + + return Ok(result); + } + + Err(InvalidArgumentException { + message: "Invalid author string. Must be in the formats: Jane Doe or John Smith " + .to_string(), + code: 0, + } + .into()) } - /// @throws \Seld\JsonLint\ParsingException - fn execute( + pub(crate) fn format_authors( &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - let io = self.get_io(); + author: &str, + ) -> anyhow::Result>> { + let parsed = self.parse_author_string(author)?; + let mut author_map: IndexMap = IndexMap::new(); + let name = parsed.get("name").cloned().unwrap_or(None); + let email = parsed.get("email").cloned().unwrap_or(None); + if let Some(name) = name { + author_map.insert("name".to_string(), PhpMixed::String(name)); + } + if let Some(email) = email { + author_map.insert("email".to_string(), PhpMixed::String(email)); + } - let allowlist: Vec = vec![ - "name".to_string(), - "description".to_string(), - "author".to_string(), - "type".to_string(), - "homepage".to_string(), - "require".to_string(), - "require-dev".to_string(), - "stability".to_string(), - "license".to_string(), - "autoload".to_string(), - ]; - let filtered_input: IndexMap = array_intersect_key( - &input.borrow().get_options(), - &array_flip_strings(&allowlist), - ) - .into_iter() - .collect(); - let mut options = shirabe_php_shim::array_filter_map(&filtered_input, |val: &PhpMixed| { - !matches!(val, PhpMixed::Null) && !matches!(val, PhpMixed::List(l) if l.is_empty()) - }); + Ok(vec![author_map]) + } - if options.contains_key("name") - && !Preg::is_match( - php_regex!(r"{^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$}D"), - options - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or(""), - ) + /// Extract namespace from package's vendor name. + /// + /// new_projects.acme-extra/package-name becomes "NewProjectsAcmeExtra\PackageName" + pub fn namespace_from_package_name(&self, package_name: &str) -> Option { + if package_name.is_empty() || strpos(package_name, "/").is_none() { + return None; + } + + let namespace: Vec = array_map( + |part: &String| { + let part = Preg::replace(php_regex!(r"/[^a-z0-9]/i"), " ", part); + let part = ucwords(&part); + str_replace(" ", "", &part) + }, + &explode("/", package_name), + ); + + Some(implode("\\", &namespace)) + } + + pub(crate) fn get_git_config(&self) -> IndexMap { + if self.git_config.borrow().is_some() { + return self.git_config.borrow().clone().unwrap_or_default(); + } + + let mut process = ProcessExecutor::new(Some(self.get_io().clone())); + + let mut output = String::new(); + if process.execute_args( + &["git".to_string(), "config".to_string(), "-l".to_string()], + &mut output, + None, + ) == 0 { - return Err(InvalidArgumentException { - message: format!( - "The package name {} is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name, matching: [a-z0-9_.-]+/[a-z0-9_.-]+", - options.get("name").and_then(|v| v.as_string()).unwrap_or("") - ), - code: 0, + *self.git_config.borrow_mut() = Some(IndexMap::new()); + let mut m: IndexMap> = IndexMap::new(); + if Preg::is_match_all3(php_regex!(r"{^([^=]+)=(.*)$}m"), &output, Some(&mut m)) { + let keys: Vec = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); + let values: Vec = + m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); + for (key, value) in keys.iter().zip(values.iter()) { + self.git_config + .borrow_mut() + .as_mut() + .unwrap() + .insert(key.clone(), value.clone()); + } } - .into()); + + return self.git_config.borrow().clone().unwrap_or_default(); } - if options.contains_key("author") { - let author = options - .get("author") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - options.insert( - "authors".to_string(), - PhpMixed::List( - self.format_authors(&author)? - .into_iter() - .map(|m| PhpMixed::Array(m.into_iter().collect())) - .collect(), - ), - ); - options.shift_remove("author"); + *self.git_config.borrow_mut() = Some(IndexMap::new()); + IndexMap::new() + } + + /// Checks the local .gitignore file for the Composer vendor directory. + /// + /// Tested patterns include: + /// "/$vendor" + /// "$vendor" + /// "$vendor/" + /// "/$vendor/" + /// "/$vendor/*" + /// "$vendor/*" + pub(crate) fn has_vendor_ignore(&self, ignore_file: &str, vendor: &str) -> bool { + if !file_exists(ignore_file) { + return false; } - let repositories: Vec = input - .borrow() - .get_option("repository")? - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - if (repositories.len() as i64) > 0 { - let config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config( - Some(io.clone()), - None, - )?)); - for repo in &repositories { - let repo_config = - RepositoryFactory::config_from_string(io.clone(), &config, repo, true)?; - let entry = options - .entry("repositories".to_string()) - .or_insert_with(|| PhpMixed::List(vec![])); - if let PhpMixed::List(list) = entry { - list.push(PhpMixed::Array(repo_config.into_iter().collect())); - } + let pattern = format!("{{^/?{}(/\\*?)?$}}", preg_quote(vendor, None)); + + let lines = file(ignore_file, FILE_IGNORE_NEW_LINES).unwrap_or_default(); + for line in &lines { + if Preg::is_match(&pattern, line) { + return true; } } - if options.contains_key("stability") { - let stab = options.shift_remove("stability").unwrap_or(PhpMixed::Null); - options.insert("minimum-stability".to_string(), stab); + false + } + + pub(crate) fn add_vendor_ignore(&self, ignore_file: &str, vendor: &str) { + let mut contents = String::new(); + if file_exists(ignore_file) { + contents = file_get_contents(ignore_file).unwrap_or_default(); + + if strpos(&contents, "\n") != Some(0) { + contents.push('\n'); + } } - let require_value = if options.contains_key("require") { - let req_list: Vec = options - .get("require") - .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 formatted = self.format_requirements(req_list)?; - if formatted.is_empty() { - // PHP: new \stdClass — empty JSON object - PhpMixed::Object(IndexMap::new()) - } else { - PhpMixed::Array( - formatted - .into_iter() - .map(|(k, v)| (k, PhpMixed::String(v))) - .collect(), - ) - } - } else { - // PHP: new \stdClass - PhpMixed::Object(IndexMap::new()) - }; - options.insert("require".to_string(), require_value); - - if options.contains_key("require-dev") { - let req_list: Vec = options - .get("require-dev") - .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 formatted = self.format_requirements(req_list)?; - let value = if formatted.is_empty() { - PhpMixed::Object(IndexMap::new()) - } else { - PhpMixed::Array( - formatted - .into_iter() - .map(|(k, v)| (k, PhpMixed::String(v))) - .collect(), - ) - }; - options.insert("require-dev".to_string(), value); - } - - // --autoload - create autoload object - let mut autoload_path: Option = None; - if options.contains_key("autoload") { - let ap = options - .get("autoload") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - autoload_path = Some(ap.clone()); - let name = input - .borrow() - .get_option("name")? - .as_string() - .unwrap_or("") - .to_string(); - let namespace = self.namespace_from_package_name(&name).unwrap_or_default(); - let mut psr4 = IndexMap::new(); - psr4.insert(format!("{}\\", namespace), PhpMixed::String(ap)); - let mut autoload_obj = IndexMap::new(); - autoload_obj.insert("psr-4".to_string(), PhpMixed::Array(psr4)); - options.insert("autoload".to_string(), PhpMixed::Array(autoload_obj)); - } - - let file_obj = JsonFile::new(Factory::get_composer_file()?, None, None)?; - let options_for_encode: IndexMap = options.clone().into_iter().collect(); - let json = JsonFile::encode(&PhpMixed::Array(options_for_encode.clone()))?; - - if input.borrow().is_interactive() { - io.write_error3(&format!("\n{}\n", json), true, io_interface::NORMAL); - if !io.ask_confirmation( - "Do you confirm generation [yes]? ".to_string(), - true, - ) { - io.write_error3("Command aborted", true, io_interface::NORMAL); - - return Ok(1); - } - } else { - io.write_error3( - &format!("Writing {}", file_obj.get_path()), - true, - io_interface::NORMAL, - ); - } - - file_obj.write(PhpMixed::Array(options_for_encode))?; - let validate_result = file_obj.validate_schema(JsonFile::LAX_SCHEMA, None); - if let Err(e) = validate_result { - // try to downcast to JsonValidationException - if let Some(json_err) = e.downcast_ref::() { - io.write_error3( - "Schema validation error, aborting", - true, - io_interface::NORMAL, - ); - let errors = format!( - " - {}", - implode(&format!("{} - ", PHP_EOL), json_err.get_errors()) - ); - io.write_error3( - &format!("{}:{}{}", json_err.get_message(), PHP_EOL, errors), - true, - io_interface::NORMAL, - ); - let path_to_unlink = file_obj.get_path().to_string(); - let _ = Silencer::call(|| { - shirabe_php_shim::unlink(&path_to_unlink); - Ok::<(), anyhow::Error>(()) - }); - - return Ok(1); - } - return Err(e); - } - - // --autoload - Create src folder - if let Some(ref ap) = autoload_path { - let mut filesystem = Filesystem::new(None); - filesystem.ensure_directory_exists(ap); + file_put_contents(ignore_file, format!("{}{}\n", contents, vendor).as_bytes()); + } - // dump-autoload only for projects without added dependencies. - if !self.has_dependencies(&options) { - self.run_dump_autoload_command(output.clone()); - } - } + /// For testing only: invoke the private `parse_author_string`. + pub fn __parse_author_string( + &self, + author: &str, + ) -> anyhow::Result>> { + self.parse_author_string(author) + } - if input.borrow().is_interactive() && is_dir(".git") { - let mut ignore_file = realpath(".gitignore").unwrap_or_default(); + /// For testing only: invoke the crate-private `format_authors`. + pub fn __format_authors( + &self, + author: &str, + ) -> anyhow::Result>> { + self.format_authors(author) + } - if ignore_file.is_empty() { - ignore_file = format!("{}/.gitignore", realpath(".").unwrap_or_default()); - } + /// For testing only: invoke the crate-private `get_git_config`. + pub fn __get_git_config(&self) -> IndexMap { + self.get_git_config() + } - if !self.has_vendor_ignore(&ignore_file, "vendor") { - let question = "Would you like the vendor directory added to your .gitignore [yes]? ".to_string(); + /// For testing only: invoke the crate-private `has_vendor_ignore`. + pub fn __has_vendor_ignore(&self, ignore_file: &str, vendor: &str) -> bool { + self.has_vendor_ignore(ignore_file, vendor) + } - if io.ask_confirmation(question, true) { - self.add_vendor_ignore(&ignore_file, "/vendor/"); - } - } - } + /// For testing only: invoke the crate-private `add_vendor_ignore`. + pub fn __add_vendor_ignore(&self, ignore_file: &str, vendor: &str) { + self.add_vendor_ignore(ignore_file, vendor) + } - let question = - "Would you like to install dependencies now [yes]? ".to_string(); - if input.borrow().is_interactive() - && self.has_dependencies(&options) - && io.ask_confirmation(question, true) - { - self.update_dependencies(output); - } + pub(crate) fn is_valid_email(&self, email: &str) -> bool { + shirabe_php_shim::filter_var_email(email) + } - // --autoload - Show post-install configuration info - if autoload_path.is_some() { - let name = input - .borrow() - .get_option("name")? - .as_string() - .unwrap_or("") - .to_string(); - let namespace = self.namespace_from_package_name(&name).unwrap_or_default(); + fn update_dependencies(&self, output: std::rc::Rc>) { + let result = (|| -> anyhow::Result { + let application = self + .get_application() + .expect("a Composer command's application is always set"); + let update_command = application.borrow_mut().find("update")?; + self.reset_composer()?; + let input: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new(vec![], None)?)); + let command = update_command.borrow(); + command.run(input, output) + })(); - io.write_error3( - &format!( - "PSR-4 autoloading configured. Use \"namespace {};\" in {}", - namespace, - autoload_path.as_deref().unwrap_or("") - ), - true, - io_interface::NORMAL, + if result.is_err() { + self.get_io().borrow().write_error( + "Could not update dependencies. Run `composer update` to see more information.", ); - io.write_error3("Include the Composer autoloader with: require 'vendor/autoload.php';", true, io_interface::NORMAL); } - - Ok(0) } - fn initialize( + fn run_dump_autoload_command( &self, - input: std::rc::Rc>, output: std::rc::Rc>, - ) -> anyhow::Result<()> { - base_command_initialize(self, input.clone(), output.clone())?; - - if !input.borrow().is_interactive() { - if input.borrow().get_option("name")?.is_null() { - let name = self.get_default_package_name(); - input - .borrow_mut() - .set_option("name", PhpMixed::from(name)) - .expect("name option is defined"); - } + ) { + let result = (|| -> anyhow::Result { + let application = self + .get_application() + .expect("a Composer command's application is always set"); + let command = application.borrow_mut().find("dump-autoload")?; + self.reset_composer()?; + let input: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new(vec![], None)?)); + let command = command.borrow(); + command.run(input, output) + })(); - if input.borrow().get_option("author")?.is_null() { - let author = self.get_default_author(); - input - .borrow_mut() - .set_option("author", PhpMixed::from(author)) - .expect("author option is defined"); - } + if result.is_err() { + self.get_io() + .borrow() + .write_error("Could not run dump-autoload."); } - - Ok(()) } - fn interact( + fn has_dependencies(&self, options: &IndexMap) -> bool { + let requires = options.get("require").cloned().unwrap_or(PhpMixed::Null); + let requires_arr_empty = match &requires { + PhpMixed::Array(m) => m.is_empty(), + PhpMixed::List(l) => l.is_empty(), + PhpMixed::Null => true, + _ => false, + }; + let dev_requires = options.get("require-dev").cloned(); + let dev_requires_arr_empty = match &dev_requires { + Some(PhpMixed::Array(m)) => m.is_empty(), + Some(PhpMixed::List(l)) => l.is_empty(), + Some(PhpMixed::Null) | None => true, + _ => false, + }; + + !requires_arr_empty || !dev_requires_arr_empty + } + + fn sanitize_package_name_component(&self, name: &str) -> String { + let name = Preg::replace( + php_regex!(r"{(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))}"), + "$1$3-$2$4", + name, + ); + let name = strtolower(&name); + let name = Preg::replace(php_regex!(r"{^[_.-]+|[_.-]+$|[^a-z0-9_.-]}u"), "", &name); + + Preg::replace(php_regex!(r"{([_.-]){2,}}u"), "$1", &name) + } + + fn get_default_package_name(&self) -> String { + let git = self.get_git_config(); + let cwd = realpath(".").unwrap_or_default(); + let name = basename(&cwd); + let name = self.sanitize_package_name_component(&name); + + let mut vendor = name.clone(); + let composer_default_vendor = PHP_SERVER + .lock() + .unwrap() + .get("COMPOSER_DEFAULT_VENDOR") + .map(|value| value.to_string_lossy().into_owned()); + let server_username = PHP_SERVER + .lock() + .unwrap() + .get("USERNAME") + .map(|value| value.to_string_lossy().into_owned()); + let server_user = PHP_SERVER + .lock() + .unwrap() + .get("USER") + .map(|value| value.to_string_lossy().into_owned()); + if !empty( + &composer_default_vendor + .clone() + .map(PhpMixed::String) + .unwrap_or(PhpMixed::Null), + ) { + vendor = composer_default_vendor.unwrap_or_default(); + } else if git.contains_key("github.user") { + vendor = git.get("github.user").cloned().unwrap_or_default(); + } else if !empty( + &server_username + .clone() + .map(PhpMixed::String) + .unwrap_or(PhpMixed::Null), + ) { + vendor = server_username.unwrap_or_default(); + } else if !empty( + &server_user + .clone() + .map(PhpMixed::String) + .unwrap_or(PhpMixed::Null), + ) { + vendor = server_user.unwrap_or_default(); + } else if !get_current_user().is_empty() { + vendor = get_current_user(); + } + + let vendor = self.sanitize_package_name_component(&vendor); + + format!("{}/{}", vendor, name) + } + + fn get_default_author(&self) -> Option { + let git = self.get_git_config(); + + let mut author_name: Option = None; + let composer_default_author = PHP_SERVER + .lock() + .unwrap() + .get("COMPOSER_DEFAULT_AUTHOR") + .map(|value| value.to_string_lossy().into_owned()); + if !empty( + &composer_default_author + .clone() + .map(PhpMixed::String) + .unwrap_or(PhpMixed::Null), + ) { + author_name = composer_default_author; + } else if git.contains_key("user.name") { + author_name = git.get("user.name").cloned(); + } + + let mut author_email: Option = None; + let composer_default_email = PHP_SERVER + .lock() + .unwrap() + .get("COMPOSER_DEFAULT_EMAIL") + .map(|value| value.to_string_lossy().into_owned()); + if !empty( + &composer_default_email + .clone() + .map(PhpMixed::String) + .unwrap_or(PhpMixed::Null), + ) { + author_email = composer_default_email; + } else if git.contains_key("user.email") { + author_email = git.get("user.email").cloned(); + } + + if let (Some(name), Some(email)) = (author_name, author_email) { + return Some(format!("{} <{}>", name, email)); + } + + None + } +} + +impl Command for InitCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("init")?; + self.set_description("Creates a basic composer.json file in current directory"); + self.set_definition(&[ + InputOption::new("name", None, Some(InputOption::VALUE_REQUIRED), "Name of the package", None).unwrap().into(), + InputOption::new("description", None, Some(InputOption::VALUE_REQUIRED), "Description of package", None).unwrap().into(), + InputOption::new("author", None, Some(InputOption::VALUE_REQUIRED), "Author name of package", None).unwrap().into(), + InputOption::new("type", None, Some(InputOption::VALUE_REQUIRED), "Type of package (e.g. library, project, metapackage, composer-plugin)", None).unwrap().into(), + InputOption::new("homepage", None, Some(InputOption::VALUE_REQUIRED), "Homepage of package", None).unwrap().into(), + InputOption::new6("require", None, Some(InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), "Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), + InputOption::new6("require-dev", None, Some(InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED), "Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), + InputOption::new("stability", Some(PhpMixed::String("s".to_string())), Some(InputOption::VALUE_REQUIRED), &format!("Minimum stability (empty or one of: {})", implode(", ", &base_package::STABILITIES.keys().map(|k| k.to_string()).collect::>())), None).unwrap().into(), + InputOption::new("license", Some(PhpMixed::String("l".to_string())), Some(InputOption::VALUE_REQUIRED), "License of package", None).unwrap().into(), + InputOption::new("repository", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Add custom repositories, either by URL or using JSON arrays", None).unwrap().into(), + InputOption::new("autoload", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_REQUIRED), "Add PSR-4 autoload mapping. Maps your package's namespace to the provided directory. (Expects a relative path, e.g. src/)", None).unwrap().into(), + ]); + self.set_help( + "The init command creates a basic composer.json file\n\ + in the current directory.\n\ + \n\ + shirabe init\n\ + \n\ + Read more at https://getcomposer.org/doc/03-cli.md#init", + ); + Ok(()) + } + + /// @throws \Seld\JsonLint\ParsingException + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let io = self.get_io(); + + let allowlist: Vec = vec![ + "name".to_string(), + "description".to_string(), + "author".to_string(), + "type".to_string(), + "homepage".to_string(), + "require".to_string(), + "require-dev".to_string(), + "stability".to_string(), + "license".to_string(), + "autoload".to_string(), + ]; + let filtered_input: IndexMap = array_intersect_key( + &input.borrow().get_options(), + &array_flip_strings(&allowlist), + ) + .into_iter() + .collect(); + let mut options = shirabe_php_shim::array_filter_map(&filtered_input, |val: &PhpMixed| { + !matches!(val, PhpMixed::Null) && !matches!(val, PhpMixed::List(l) if l.is_empty()) + }); + + if options.contains_key("name") + && !Preg::is_match( + php_regex!(r"{^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$}D"), + options + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or(""), + ) + { + return Err(InvalidArgumentException { + message: format!( + "The package name {} is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name, matching: [a-z0-9_.-]+/[a-z0-9_.-]+", + options.get("name").and_then(|v| v.as_string()).unwrap_or("") + ), + code: 0, + } + .into()); + } + + if options.contains_key("author") { + let author = options + .get("author") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + options.insert( + "authors".to_string(), + PhpMixed::List( + self.format_authors(&author)? + .into_iter() + .map(|m| PhpMixed::Array(m.into_iter().collect())) + .collect(), + ), + ); + options.shift_remove("author"); + } + + let repositories: Vec = input + .borrow() + .get_option("repository")? + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if (repositories.len() as i64) > 0 { + let config = std::rc::Rc::new(std::cell::RefCell::new(Factory::create_config( + Some(io.clone()), + None, + )?)); + for repo in &repositories { + let repo_config = + RepositoryFactory::config_from_string(io.clone(), &config, repo, true)?; + let entry = options + .entry("repositories".to_string()) + .or_insert_with(|| PhpMixed::List(vec![])); + if let PhpMixed::List(list) = entry { + list.push(PhpMixed::Array(repo_config.into_iter().collect())); + } + } + } + + if options.contains_key("stability") { + let stab = options.shift_remove("stability").unwrap_or(PhpMixed::Null); + options.insert("minimum-stability".to_string(), stab); + } + + let require_value = if options.contains_key("require") { + let req_list: Vec = options + .get("require") + .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 formatted = self.format_requirements(req_list)?; + if formatted.is_empty() { + // PHP: new \stdClass — empty JSON object + PhpMixed::Object(IndexMap::new()) + } else { + PhpMixed::Array( + formatted + .into_iter() + .map(|(k, v)| (k, PhpMixed::String(v))) + .collect(), + ) + } + } else { + // PHP: new \stdClass + PhpMixed::Object(IndexMap::new()) + }; + options.insert("require".to_string(), require_value); + + if options.contains_key("require-dev") { + let req_list: Vec = options + .get("require-dev") + .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 formatted = self.format_requirements(req_list)?; + let value = if formatted.is_empty() { + PhpMixed::Object(IndexMap::new()) + } else { + PhpMixed::Array( + formatted + .into_iter() + .map(|(k, v)| (k, PhpMixed::String(v))) + .collect(), + ) + }; + options.insert("require-dev".to_string(), value); + } + + // --autoload - create autoload object + let mut autoload_path: Option = None; + if options.contains_key("autoload") { + let ap = options + .get("autoload") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + autoload_path = Some(ap.clone()); + let name = input + .borrow() + .get_option("name")? + .as_string() + .unwrap_or("") + .to_string(); + let namespace = self.namespace_from_package_name(&name).unwrap_or_default(); + let mut psr4 = IndexMap::new(); + psr4.insert(format!("{}\\", namespace), PhpMixed::String(ap)); + let mut autoload_obj = IndexMap::new(); + autoload_obj.insert("psr-4".to_string(), PhpMixed::Array(psr4)); + options.insert("autoload".to_string(), PhpMixed::Array(autoload_obj)); + } + + let file_obj = JsonFile::new(Factory::get_composer_file()?, None, None)?; + let options_for_encode: IndexMap = options.clone().into_iter().collect(); + let json = JsonFile::encode(&PhpMixed::Array(options_for_encode.clone()))?; + + if input.borrow().is_interactive() { + io.write_error3(&format!("\n{}\n", json), true, io_interface::NORMAL); + if !io.ask_confirmation( + "Do you confirm generation [yes]? ".to_string(), + true, + ) { + io.write_error3("Command aborted", true, io_interface::NORMAL); + + return Ok(1); + } + } else { + io.write_error3( + &format!("Writing {}", file_obj.get_path()), + true, + io_interface::NORMAL, + ); + } + + file_obj.write(PhpMixed::Array(options_for_encode))?; + let validate_result = file_obj.validate_schema(JsonFile::LAX_SCHEMA, None); + if let Err(e) = validate_result { + // try to downcast to JsonValidationException + if let Some(json_err) = e.downcast_ref::() { + io.write_error3( + "Schema validation error, aborting", + true, + io_interface::NORMAL, + ); + let errors = format!( + " - {}", + implode(&format!("{} - ", PHP_EOL), json_err.get_errors()) + ); + io.write_error3( + &format!("{}:{}{}", json_err.get_message(), PHP_EOL, errors), + true, + io_interface::NORMAL, + ); + let path_to_unlink = file_obj.get_path().to_string(); + let _ = Silencer::call(|| { + shirabe_php_shim::unlink(&path_to_unlink); + Ok::<(), anyhow::Error>(()) + }); + + return Ok(1); + } + return Err(e); + } + + // --autoload - Create src folder + if let Some(ref ap) = autoload_path { + let mut filesystem = Filesystem::new(None); + filesystem.ensure_directory_exists(ap); + + // dump-autoload only for projects without added dependencies. + if !self.has_dependencies(&options) { + self.run_dump_autoload_command(output.clone()); + } + } + + if input.borrow().is_interactive() && is_dir(".git") { + let mut ignore_file = realpath(".gitignore").unwrap_or_default(); + + if ignore_file.is_empty() { + ignore_file = format!("{}/.gitignore", realpath(".").unwrap_or_default()); + } + + if !self.has_vendor_ignore(&ignore_file, "vendor") { + let question = "Would you like the vendor directory added to your .gitignore [yes]? ".to_string(); + + if io.ask_confirmation(question, true) { + self.add_vendor_ignore(&ignore_file, "/vendor/"); + } + } + } + + let question = + "Would you like to install dependencies now [yes]? ".to_string(); + if input.borrow().is_interactive() + && self.has_dependencies(&options) + && io.ask_confirmation(question, true) + { + self.update_dependencies(output); + } + + // --autoload - Show post-install configuration info + if autoload_path.is_some() { + let name = input + .borrow() + .get_option("name")? + .as_string() + .unwrap_or("") + .to_string(); + let namespace = self.namespace_from_package_name(&name).unwrap_or_default(); + + io.write_error3( + &format!( + "PSR-4 autoloading configured. Use \"namespace {};\" in {}", + namespace, + autoload_path.as_deref().unwrap_or("") + ), + true, + io_interface::NORMAL, + ); + io.write_error3("Include the Composer autoloader with: require 'vendor/autoload.php';", true, io_interface::NORMAL); + } + + Ok(0) + } + + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + base_command_initialize(self, input.clone(), output.clone())?; + + if !input.borrow().is_interactive() { + if input.borrow().get_option("name")?.is_null() { + let name = self.get_default_package_name(); + input + .borrow_mut() + .set_option("name", PhpMixed::from(name)) + .expect("name option is defined"); + } + + if input.borrow().get_option("author")?.is_null() { + let author = self.get_default_author(); + input + .borrow_mut() + .set_option("author", PhpMixed::from(author)) + .expect("author option is defined"); + } + } + + Ok(()) + } + + fn interact( &self, input: std::rc::Rc>, output: std::rc::Rc>, @@ -833,457 +1197,91 @@ impl Command for InitCommand { false, )? } else { - vec![] - }; - input.borrow_mut().set_option( - "require-dev", - PhpMixed::List(dev_requirements.into_iter().map(PhpMixed::String).collect()), - ); - - // --autoload - input and validation - let autoload = input - .borrow() - .get_option("autoload")? - .as_string() - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "src/".to_string()); - let name_str = input - .borrow() - .get_option("name")? - .as_string() - .unwrap_or("") - .to_string(); - let namespace = self - .namespace_from_package_name(&name_str) - .unwrap_or_default(); - let autoload_for_validate = autoload.clone(); - let autoload_default = autoload.clone(); - let autoload_value = io.ask_and_validate( - format!( - "Add PSR-4 autoload mapping? Maps namespace \"{}\" to the entered relative path. [{}, n to skip]: ", - namespace, autoload - ), - Box::new(move |value: PhpMixed| -> anyhow::Result { - if value.is_null() { - return Ok(PhpMixed::String(autoload_for_validate.clone())); - } - - let value_str = value.as_string().unwrap_or("").to_string(); - if value_str == "n" || value_str == "no" { - return Ok(PhpMixed::Null); - } - - let value_or_default = if value_str.is_empty() { - autoload_for_validate.clone() - } else { - value_str - }; - - if !Preg::is_match(php_regex!(r"{^[^/][A-Za-z0-9\-_/]+/$}"), &value_or_default) - { - return Err(InvalidArgumentException { - message: format!( - "The src folder name \"{}\" is invalid. Please add a relative path with tailing forward slash. [A-Za-z0-9_-/]+/", - value_or_default, - ), - code: 0, - } - .into()); - } - - Ok(PhpMixed::String(value_or_default)) - }), - None, - PhpMixed::String(autoload_default), - )?; - input.borrow_mut().set_option("autoload", autoload_value); - - Ok(()) - })(); - } - - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } - - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} - -impl BaseCommand for InitCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } - - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} - -impl InitCommand { - fn parse_author_string( - &self, - author: &str, - ) -> anyhow::Result>> { - let mut m: IndexMap = IndexMap::new(); - if Preg::is_match3( - php_regex!(r#"/^(?P[- .,\p{L}\p{N}\p{Mn}\'’\"()]+)(?:\s+<(?P.+?)>)?$/u"#), - author, - Some(&mut m), - ) { - let email = m.get(&CaptureKey::ByName("email".to_string())).cloned(); - if let Some(ref email) = email - && !self.is_valid_email(email) - { - return Err(InvalidArgumentException { - message: format!("Invalid email \"{}\"", email), - code: 0, - } - .into()); - } - - let mut result: IndexMap> = IndexMap::new(); - result.insert( - "name".to_string(), - Some(trim( - &m.get(&CaptureKey::ByName("name".to_string())) - .cloned() - .unwrap_or_default(), - None, - )), - ); - result.insert("email".to_string(), email); - - return Ok(result); - } - - Err(InvalidArgumentException { - message: "Invalid author string. Must be in the formats: Jane Doe or John Smith " - .to_string(), - code: 0, - } - .into()) - } - - pub(crate) fn format_authors( - &self, - author: &str, - ) -> anyhow::Result>> { - let parsed = self.parse_author_string(author)?; - let mut author_map: IndexMap = IndexMap::new(); - let name = parsed.get("name").cloned().unwrap_or(None); - let email = parsed.get("email").cloned().unwrap_or(None); - if let Some(name) = name { - author_map.insert("name".to_string(), PhpMixed::String(name)); - } - if let Some(email) = email { - author_map.insert("email".to_string(), PhpMixed::String(email)); - } - - Ok(vec![author_map]) - } - - /// Extract namespace from package's vendor name. - /// - /// new_projects.acme-extra/package-name becomes "NewProjectsAcmeExtra\PackageName" - pub fn namespace_from_package_name(&self, package_name: &str) -> Option { - if package_name.is_empty() || strpos(package_name, "/").is_none() { - return None; - } - - let namespace: Vec = array_map( - |part: &String| { - let part = Preg::replace(php_regex!(r"/[^a-z0-9]/i"), " ", part); - let part = ucwords(&part); - str_replace(" ", "", &part) - }, - &explode("/", package_name), - ); - - Some(implode("\\", &namespace)) - } - - pub(crate) fn get_git_config(&self) -> IndexMap { - if self.git_config.borrow().is_some() { - return self.git_config.borrow().clone().unwrap_or_default(); - } - - let mut process = ProcessExecutor::new(Some(self.get_io().clone())); - - let mut output = String::new(); - if process.execute_args( - &["git".to_string(), "config".to_string(), "-l".to_string()], - &mut output, - None, - ) == 0 - { - *self.git_config.borrow_mut() = Some(IndexMap::new()); - let mut m: IndexMap> = IndexMap::new(); - if Preg::is_match_all3(php_regex!(r"{^([^=]+)=(.*)$}m"), &output, Some(&mut m)) { - let keys: Vec = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); - let values: Vec = - m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); - for (key, value) in keys.iter().zip(values.iter()) { - self.git_config - .borrow_mut() - .as_mut() - .unwrap() - .insert(key.clone(), value.clone()); - } - } - - return self.git_config.borrow().clone().unwrap_or_default(); - } - - *self.git_config.borrow_mut() = Some(IndexMap::new()); - IndexMap::new() - } - - /// Checks the local .gitignore file for the Composer vendor directory. - /// - /// Tested patterns include: - /// "/$vendor" - /// "$vendor" - /// "$vendor/" - /// "/$vendor/" - /// "/$vendor/*" - /// "$vendor/*" - pub(crate) fn has_vendor_ignore(&self, ignore_file: &str, vendor: &str) -> bool { - if !file_exists(ignore_file) { - return false; - } - - let pattern = format!("{{^/?{}(/\\*?)?$}}", preg_quote(vendor, None)); - - let lines = file(ignore_file, FILE_IGNORE_NEW_LINES).unwrap_or_default(); - for line in &lines { - if Preg::is_match(&pattern, line) { - return true; - } - } - - false - } - - pub(crate) fn add_vendor_ignore(&self, ignore_file: &str, vendor: &str) { - let mut contents = String::new(); - if file_exists(ignore_file) { - contents = file_get_contents(ignore_file).unwrap_or_default(); - - if strpos(&contents, "\n") != Some(0) { - contents.push('\n'); - } - } - - file_put_contents(ignore_file, format!("{}{}\n", contents, vendor).as_bytes()); - } - - /// For testing only: invoke the private `parse_author_string`. - pub fn __parse_author_string( - &self, - author: &str, - ) -> anyhow::Result>> { - self.parse_author_string(author) - } + vec![] + }; + input.borrow_mut().set_option( + "require-dev", + PhpMixed::List(dev_requirements.into_iter().map(PhpMixed::String).collect()), + ); - /// For testing only: invoke the crate-private `format_authors`. - pub fn __format_authors( - &self, - author: &str, - ) -> anyhow::Result>> { - self.format_authors(author) - } + // --autoload - input and validation + let autoload = input + .borrow() + .get_option("autoload")? + .as_string() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "src/".to_string()); + let name_str = input + .borrow() + .get_option("name")? + .as_string() + .unwrap_or("") + .to_string(); + let namespace = self + .namespace_from_package_name(&name_str) + .unwrap_or_default(); + let autoload_for_validate = autoload.clone(); + let autoload_default = autoload.clone(); + let autoload_value = io.ask_and_validate( + format!( + "Add PSR-4 autoload mapping? Maps namespace \"{}\" to the entered relative path. [{}, n to skip]: ", + namespace, autoload + ), + Box::new(move |value: PhpMixed| -> anyhow::Result { + if value.is_null() { + return Ok(PhpMixed::String(autoload_for_validate.clone())); + } - /// For testing only: invoke the crate-private `get_git_config`. - pub fn __get_git_config(&self) -> IndexMap { - self.get_git_config() - } + let value_str = value.as_string().unwrap_or("").to_string(); + if value_str == "n" || value_str == "no" { + return Ok(PhpMixed::Null); + } - /// For testing only: invoke the crate-private `has_vendor_ignore`. - pub fn __has_vendor_ignore(&self, ignore_file: &str, vendor: &str) -> bool { - self.has_vendor_ignore(ignore_file, vendor) - } + let value_or_default = if value_str.is_empty() { + autoload_for_validate.clone() + } else { + value_str + }; - /// For testing only: invoke the crate-private `add_vendor_ignore`. - pub fn __add_vendor_ignore(&self, ignore_file: &str, vendor: &str) { - self.add_vendor_ignore(ignore_file, vendor) - } + if !Preg::is_match(php_regex!(r"{^[^/][A-Za-z0-9\-_/]+/$}"), &value_or_default) + { + return Err(InvalidArgumentException { + message: format!( + "The src folder name \"{}\" is invalid. Please add a relative path with tailing forward slash. [A-Za-z0-9_-/]+/", + value_or_default, + ), + code: 0, + } + .into()); + } - pub(crate) fn is_valid_email(&self, email: &str) -> bool { - shirabe_php_shim::filter_var_email(email) - } + Ok(PhpMixed::String(value_or_default)) + }), + None, + PhpMixed::String(autoload_default), + )?; + input.borrow_mut().set_option("autoload", autoload_value); - fn update_dependencies(&self, output: std::rc::Rc>) { - let result = (|| -> anyhow::Result { - let application = self - .get_application() - .expect("a Composer command's application is always set"); - let update_command = application.borrow_mut().find("update")?; - self.reset_composer()?; - let input: std::rc::Rc> = - std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new(vec![], None)?)); - let command = update_command.borrow(); - command.run(input, output) + Ok(()) })(); - - if result.is_err() { - self.get_io().borrow().write_error( - "Could not update dependencies. Run `composer update` to see more information.", - ); - } } - fn run_dump_autoload_command( + fn complete( &self, - output: std::rc::Rc>, - ) { - let result = (|| -> anyhow::Result { - let application = self - .get_application() - .expect("a Composer command's application is always set"); - let command = application.borrow_mut().find("dump-autoload")?; - self.reset_composer()?; - let input: std::rc::Rc> = - std::rc::Rc::new(std::cell::RefCell::new(ArrayInput::new(vec![], None)?)); - let command = command.borrow(); - command.run(input, output) - })(); - - if result.is_err() { - self.get_io() - .borrow() - .write_error("Could not run dump-autoload."); - } - } - - fn has_dependencies(&self, options: &IndexMap) -> bool { - let requires = options.get("require").cloned().unwrap_or(PhpMixed::Null); - let requires_arr_empty = match &requires { - PhpMixed::Array(m) => m.is_empty(), - PhpMixed::List(l) => l.is_empty(), - PhpMixed::Null => true, - _ => false, - }; - let dev_requires = options.get("require-dev").cloned(); - let dev_requires_arr_empty = match &dev_requires { - Some(PhpMixed::Array(m)) => m.is_empty(), - Some(PhpMixed::List(l)) => l.is_empty(), - Some(PhpMixed::Null) | None => true, - _ => false, - }; - - !requires_arr_empty || !dev_requires_arr_empty - } - - fn sanitize_package_name_component(&self, name: &str) -> String { - let name = Preg::replace( - php_regex!(r"{(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))}"), - "$1$3-$2$4", - name, - ); - let name = strtolower(&name); - let name = Preg::replace(php_regex!(r"{^[_.-]+|[_.-]+$|[^a-z0-9_.-]}u"), "", &name); - - Preg::replace(php_regex!(r"{([_.-]){2,}}u"), "$1", &name) + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) } - fn get_default_package_name(&self) -> String { - let git = self.get_git_config(); - let cwd = realpath(".").unwrap_or_default(); - let name = basename(&cwd); - let name = self.sanitize_package_name_component(&name); - - let mut vendor = name.clone(); - let composer_default_vendor = PHP_SERVER - .lock() - .unwrap() - .get("COMPOSER_DEFAULT_VENDOR") - .map(|value| value.to_string_lossy().into_owned()); - let server_username = PHP_SERVER - .lock() - .unwrap() - .get("USERNAME") - .map(|value| value.to_string_lossy().into_owned()); - let server_user = PHP_SERVER - .lock() - .unwrap() - .get("USER") - .map(|value| value.to_string_lossy().into_owned()); - if !empty( - &composer_default_vendor - .clone() - .map(PhpMixed::String) - .unwrap_or(PhpMixed::Null), - ) { - vendor = composer_default_vendor.unwrap_or_default(); - } else if git.contains_key("github.user") { - vendor = git.get("github.user").cloned().unwrap_or_default(); - } else if !empty( - &server_username - .clone() - .map(PhpMixed::String) - .unwrap_or(PhpMixed::Null), - ) { - vendor = server_username.unwrap_or_default(); - } else if !empty( - &server_user - .clone() - .map(PhpMixed::String) - .unwrap_or(PhpMixed::Null), - ) { - vendor = server_user.unwrap_or_default(); - } else if !get_current_user().is_empty() { - vendor = get_current_user(); - } - - let vendor = self.sanitize_package_name_component(&vendor); + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} - format!("{}/{}", vendor, name) +impl BaseCommand for InitCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data } - fn get_default_author(&self) -> Option { - let git = self.get_git_config(); - - let mut author_name: Option = None; - let composer_default_author = PHP_SERVER - .lock() - .unwrap() - .get("COMPOSER_DEFAULT_AUTHOR") - .map(|value| value.to_string_lossy().into_owned()); - if !empty( - &composer_default_author - .clone() - .map(PhpMixed::String) - .unwrap_or(PhpMixed::Null), - ) { - author_name = composer_default_author; - } else if git.contains_key("user.name") { - author_name = git.get("user.name").cloned(); - } - - let mut author_email: Option = None; - let composer_default_email = PHP_SERVER - .lock() - .unwrap() - .get("COMPOSER_DEFAULT_EMAIL") - .map(|value| value.to_string_lossy().into_owned()); - if !empty( - &composer_default_email - .clone() - .map(PhpMixed::String) - .unwrap_or(PhpMixed::Null), - ) { - author_email = composer_default_email; - } else if git.contains_key("user.email") { - author_email = git.get("user.email").cloned(); - } - - if let (Some(name), Some(email)) = (author_name, author_email) { - return Some(format!("{} <{}>", name, email)); - } - - None - } + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } diff --git a/crates/shirabe/src/command/require_command.rs b/crates/shirabe/src/command/require_command.rs index d2b8360d..f4410c19 100644 --- a/crates/shirabe/src/command/require_command.rs +++ b/crates/shirabe/src/command/require_command.rs @@ -89,1216 +89,1214 @@ impl RequireCommand { .expect("RequireCommand::configure uses static, valid metadata"); command } -} -impl PackageDiscoveryTrait for RequireCommand { - fn get_repos_mut( + fn get_inconsistent_require_keys( &self, - ) -> std::cell::RefMut<'_, Option> { - self.repos.borrow_mut() - } + new_requirements: &IndexMap, + require_key: &str, + ) -> Vec { + let require_keys = self.get_packages_by_require_key(); + let mut inconsistent_requirements: Vec = vec![]; + for (package, package_require_key) in &require_keys { + if !new_requirements.contains_key(package) { + continue; + } + if require_key != package_require_key { + inconsistent_requirements.push(package.clone()); + } + } - fn get_repository_sets_mut( - &self, - ) -> std::cell::RefMut<'_, IndexMap>>> - { - self.repository_sets.borrow_mut() + inconsistent_requirements } -} -impl Command for RequireCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("require")?; - self.set_aliases(vec!["r".to_string()])?; - self.set_description("Adds required packages to your composer.json and installs them"); - self.set_definition(&[ - InputArgument::new5("packages", Some(InputArgument::IS_ARRAY | InputArgument::OPTIONAL), "Optional package name can also include a version constraint, e.g. foo/bar or foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), - InputOption::new("dev", None, Some(InputOption::VALUE_NONE), "Add requirement to require-dev.", None).unwrap().into(), - InputOption::new("dry-run", None, Some(InputOption::VALUE_NONE), "Outputs the operations but will not execute anything (implicitly enables --verbose).", None).unwrap().into(), - InputOption::new("prefer-source", None, Some(InputOption::VALUE_NONE), "Forces installation from package sources when possible, including VCS information.", None).unwrap().into(), - InputOption::new("prefer-dist", None, Some(InputOption::VALUE_NONE), "Forces installation from package dist (default behavior).", None).unwrap().into(), - InputOption::new6("prefer-install", None, Some(InputOption::VALUE_REQUIRED), "Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).", None, self.suggest_prefer_install()).unwrap().into(), - InputOption::new("fixed", None, Some(InputOption::VALUE_NONE), "Write fixed version to the composer.json.", None).unwrap().into(), - InputOption::new("no-suggest", None, Some(InputOption::VALUE_NONE), "DEPRECATED: This flag does not exist anymore.", None).unwrap().into(), - InputOption::new("no-progress", None, Some(InputOption::VALUE_NONE), "Do not output download progress.", None).unwrap().into(), - InputOption::new("no-update", None, Some(InputOption::VALUE_NONE), "Disables the automatic update of the dependencies (implies --no-install).", None).unwrap().into(), - InputOption::new("no-install", None, Some(InputOption::VALUE_NONE), "Skip the install step after updating the composer.lock file.", None).unwrap().into(), - InputOption::new("no-audit", None, Some(InputOption::VALUE_NONE), "Skip the audit step after updating the composer.lock file (can also be set via the COMPOSER_NO_AUDIT=1 env var).", None).unwrap().into(), - InputOption::new6("audit-format", None, Some(InputOption::VALUE_REQUIRED), "Audit output format. Must be \"table\", \"plain\", \"json\", or \"summary\".", Some(PhpMixed::String(Auditor::FORMAT_SUMMARY.to_string())), SuggestedValues::List(Auditor::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), - InputOption::new("no-security-blocking", None, Some(InputOption::VALUE_NONE), "Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).", None).unwrap().into(), - InputOption::new("update-no-dev", None, Some(InputOption::VALUE_NONE), "Run the dependency update with the --no-dev option.", None).unwrap().into(), - InputOption::new("update-with-dependencies", Some(PhpMixed::String("w".to_string())), Some(InputOption::VALUE_NONE), "Allows inherited dependencies to be updated, except those that are root requirements (can also be set via the COMPOSER_WITH_DEPENDENCIES=1 env var).", None).unwrap().into(), - InputOption::new("update-with-all-dependencies", Some(PhpMixed::String("W".to_string())), Some(InputOption::VALUE_NONE), "Allows all inherited dependencies to be updated, including those that are root requirements (can also be set via the COMPOSER_WITH_ALL_DEPENDENCIES=1 env var).", None).unwrap().into(), - InputOption::new("with-dependencies", None, Some(InputOption::VALUE_NONE), "Alias for --update-with-dependencies", None).unwrap().into(), - InputOption::new("with-all-dependencies", None, Some(InputOption::VALUE_NONE), "Alias for --update-with-all-dependencies", None).unwrap().into(), - InputOption::new("ignore-platform-req", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Ignore a specific platform requirement (php & ext- packages).", None).unwrap().into(), - InputOption::new("ignore-platform-reqs", None, Some(InputOption::VALUE_NONE), "Ignore all platform requirements (php & ext- packages).", None).unwrap().into(), - InputOption::new("prefer-stable", None, Some(InputOption::VALUE_NONE), "Prefer stable versions of dependencies (can also be set via the COMPOSER_PREFER_STABLE=1 env var).", None).unwrap().into(), - InputOption::new("prefer-lowest", None, Some(InputOption::VALUE_NONE), "Prefer lowest versions of dependencies (can also be set via the COMPOSER_PREFER_LOWEST=1 env var).", None).unwrap().into(), - InputOption::new("minimal-changes", Some(PhpMixed::String("m".to_string())), Some(InputOption::VALUE_NONE), "During an update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).", None).unwrap().into(), - InputOption::new("sort-packages", None, Some(InputOption::VALUE_NONE), "Sorts packages when adding/updating a new dependency", None).unwrap().into(), - InputOption::new("optimize-autoloader", Some(PhpMixed::String("o".to_string())), Some(InputOption::VALUE_NONE), "Optimize autoloader during autoloader dump", None).unwrap().into(), - InputOption::new("classmap-authoritative", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_NONE), "Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.", None).unwrap().into(), - InputOption::new("apcu-autoloader", None, Some(InputOption::VALUE_NONE), "Use APCu to cache found/not-found classes.", None).unwrap().into(), - InputOption::new("apcu-autoloader-prefix", None, Some(InputOption::VALUE_REQUIRED), "Use a custom prefix for the APCu autoloader cache. Implicitly enables --apcu-autoloader", None).unwrap().into(), - ]); - self.set_help( - "The require command adds required packages to your composer.json and installs them.\n\ - \n\ - If you do not specify a package, composer will prompt you to search for a package, and given results, provide a list of\n\ - matches to require.\n\ - \n\ - If you do not specify a version constraint, composer will choose a suitable one based on the available package versions.\n\ - \n\ - If you do not want to install the new dependencies immediately you can call it with --no-update\n\ - \n\ - Read more at https://getcomposer.org/doc/03-cli.md#require-r" - ); - Ok(()) + fn get_packages_by_require_key(&self) -> IndexMap { + let json = self.json.borrow().as_ref().unwrap().clone(); + let composer_definition = json.borrow_mut().read().unwrap_or_default(); + let mut require: IndexMap = IndexMap::new(); + let mut require_dev: IndexMap = IndexMap::new(); + + if let Some(r) = composer_definition + .get("require") + .and_then(|v| v.as_array()) + { + for (k, v) in r { + require.insert(k.clone(), v.clone()); + } + } + + if let Some(r) = composer_definition + .get("require-dev") + .and_then(|v| v.as_array()) + { + for (k, v) in r { + require_dev.insert(k.clone(), v.clone()); + } + } + + array_merge( + array_fill_keys( + PhpMixed::List( + array_keys(&require) + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + PhpMixed::String("require".to_string()), + ), + array_fill_keys( + PhpMixed::List( + array_keys(&require_dev) + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + PhpMixed::String("require-dev".to_string()), + ), + ) + .as_array() + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_string().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() } - /// @throws \Seld\JsonLint\ParsingException - fn execute( + /// @throws \Exception + fn do_update( &self, input: std::rc::Rc>, output: std::rc::Rc>, + io: std::rc::Rc>, + requirements: &IndexMap, + require_key: &str, + _remove_key: &str, ) -> anyhow::Result { - *self.file.borrow_mut() = Factory::get_composer_file()?; + // Update packages + self.reset_composer()?; + let composer_handle = self.require_composer(None, None)?; + let composer = crate::composer::composer_full(&composer_handle); + + self.dependency_resolution_completed.set(false); + // PHP: $composer->getEventDispatcher()->addListener(InstallerEvents::PRE_OPERATIONS_EXEC, + // function () use (&$dependencyResolutionCompleted) { $dependencyResolutionCompleted = true; }, 10000); + let dependency_resolution_completed = self.dependency_resolution_completed.clone(); + composer.get_event_dispatcher().borrow_mut().add_listener( + InstallerEvents::PRE_OPERATIONS_EXEC, + crate::event_dispatcher::Callable::Closure(std::rc::Rc::new(move |_event| { + dependency_resolution_completed.set(true); + PhpMixed::Null + })), + 10000, + ); if input .borrow() - .get_option("no-suggest")? + .get_option("dry-run")? .as_bool() .unwrap_or(false) { - self.get_io().write_error3("You are using the deprecated option \"--no-suggest\". It has no effect and will break in Composer 3.", true, io_interface::NORMAL); - } - - let file = self.file.borrow().clone(); - self.newly_created.set(!file_exists(&file)); - let write_failed = - self.newly_created.get() && file_put_contents(&file, b"{\n}\n").is_none(); - if write_failed { - let msg = format!("{} could not be created.", file); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); - - return Ok(1); - } - if !Filesystem::is_readable(&file) { - let msg = format!("{} is not readable.", file); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); + let root_package = composer.get_package(); + let mut links: IndexMap> = + IndexMap::new(); + links.insert("require".to_string(), root_package.get_requires()); + links.insert("require-dev".to_string(), root_package.get_dev_requires()); + let loader = ArrayLoader::new(None, false); + let requirements_mixed: IndexMap = requirements + .iter() + .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) + .collect(); + let new_links = loader.parse_links( + &root_package.get_name(), + &root_package.get_pretty_version(), + base_package::SUPPORTED_LINK_TYPES + .get(require_key) + .map(|t| t.method) + .unwrap_or_default(), + requirements_mixed, + )?; + if let Some(section) = links.get_mut(require_key) { + for (k, v) in new_links { + section.insert(k, v); + } + } + for (package, _constraint) in requirements { + if let Some(section) = links.get_mut(_remove_key) { + section.shift_remove(package); + } + } + root_package.set_requires(links["require"].clone()); + root_package.set_dev_requires(links["require-dev"].clone()); - return Ok(1); - } - if filesize(&file) == Some(0) { - file_put_contents(&file, b"{\n}\n"); + // extract stability flags & references as they weren't present when loading the unmodified composer.json + let references = + RootPackageLoader::extract_references(requirements, root_package.get_references()); + root_package.set_references(references); + let stability_flags = RootPackageLoader::extract_stability_flags( + requirements, + &root_package.get_minimum_stability(), + root_package.get_stability_flags(), + ); + root_package.set_stability_flags(stability_flags); } - *self.json.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new(JsonFile::new( - file.clone(), - None, - None, - )?))); - *self.lock.borrow_mut() = Factory::get_lock_file(&file); - let json = self.json.borrow().as_ref().unwrap().clone(); - *self.composer_backup.borrow_mut() = - file_get_contents(json.borrow().get_path()).unwrap_or_default(); - let lock = self.lock.borrow().clone(); - *self.lock_backup.borrow_mut() = if file_exists(&lock) { - file_get_contents(&lock) - } else { - None - }; - - // PHP: function ($signal, $handler) use ($io, $self) { - // $io->writeError('Received '.$signal.', aborting', true, IOInterface::DEBUG); - // $self->revertComposerFile(); $handler->exitWithLastSignal(); } - // TODO(phase-c): SignalHandler::create takes a `Box + 'static` handler that cannot - // borrow &self, but the body must call self.revert_composer_file() (which mutates the - // command's composer.json backup state) and self.get_io(). Faithfully wiring this needs the - // revert state + io shared into the closure (Rc>), i.e. the shared-ownership - // rework of the command — the same pattern as InstallationManager::execute's signal handler. - let signal_handler = SignalHandler::create( - vec![ - SignalHandler::SIGINT.to_string(), - SignalHandler::SIGTERM.to_string(), - SignalHandler::SIGHUP.to_string(), - ], - Box::new(move |signal: String, handler: &SignalHandler| { - let _ = signal; - handler.exit_with_last_signal(); - }), - ); - - // check for writability by writing to the file as is_writable can not be trusted on network-mounts - // see https://github.com/composer/composer/issues/8231 and https://bugs.php.net/bug.php?id=68926 - let file_path = file.clone(); - let backup_contents = self.composer_backup.borrow().clone(); - if !is_writable(&file) - && Silencer::call(|| { - shirabe_php_shim::file_put_contents(&file_path, backup_contents.as_bytes()); - Ok::(false) - }) - .ok() - == Some(false) - { - let msg = format!("{} is not writable.", file); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); - - return Ok(1); - } - - if input.borrow().get_option("fixed")?.as_bool() == Some(true) { - let config = json.borrow_mut().read()?; - - let package_type = if empty(&config.get("type").cloned().unwrap_or(PhpMixed::Null)) { - "library".to_string() - } else { - config - .get("type") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string() - }; - - // @see https://github.com/composer/composer/pull/8313#issuecomment-532637955 - if package_type != "project" - && !input.borrow().get_option("dev")?.as_bool().unwrap_or(false) - { - self.get_io().write_error3("The \"--fixed\" option is only allowed for packages with a \"project\" type or for dev dependencies to prevent possible misuses.", true, io_interface::NORMAL); - - if config.get("type").is_none() { - self.get_io().write_error3("If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".", true, io_interface::NORMAL); - } - - return Ok(1); - } - } - - let composer = self.require_composer(None, None)?; - let composer = crate::composer::composer_full(&composer); - let repository_manager = composer.get_repository_manager().clone(); - let repository_manager = repository_manager.borrow(); - let repos = repository_manager.get_repositories(); - - let platform_overrides = composer.get_config().borrow_mut().get("platform"); - let platform_overrides_map: IndexMap = platform_overrides - .as_array() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default(); - // initialize self.repos as it is used by the PackageDiscoveryTrait - let platform_repo = - PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides_map)?); - let mut combined: Vec = - vec![platform_repo.clone().into()]; - for repo in repos { - combined.push(repo.clone()); - } - *self.get_repos_mut() = Some(crate::repository::RepositoryInterfaceHandle::new( - CompositeRepository::new(combined), - )); - - let preferred_stability = if composer.get_package().get_prefer_stable() { - "stable".to_string() - } else { - composer.get_package().get_minimum_stability() - }; - - // Hoist argument computations into locals so no borrow of `input` is held across the - // call: `determine_requirements` may prompt via ConsoleIO, which mutably borrows the - // same input RefCell. - let packages: Vec = input - .borrow() - .get_argument("packages")? - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - // if there is no update, we need to use the best possible version constraint directly as we cannot rely on the solver to guess the best constraint - let no_update = input + let update_dev_mode = !input .borrow() - .get_option("no-update")? + .get_option("update-no-dev")? .as_bool() .unwrap_or(false); - let fixed = input + let optimize = input .borrow() - .get_option("fixed")? + .get_option("optimize-autoloader")? .as_bool() - .unwrap_or(false); - let requirements_result = self.determine_requirements( - input.clone(), - output.clone(), - packages, - Some(&platform_repo), - &preferred_stability, - no_update, - fixed, + .unwrap_or(false) + || composer + .get_config() + .borrow() + .get("optimize-autoloader") + .as_bool() + .unwrap_or(false); + let authoritative = input + .borrow() + .get_option("classmap-authoritative")? + .as_bool() + .unwrap_or(false) + || composer + .get_config() + .borrow() + .get("classmap-authoritative") + .as_bool() + .unwrap_or(false); + let apcu_prefix = input + .borrow() + .get_option("apcu-autoloader-prefix")? + .as_string() + .map(|s| s.to_string()); + let apcu = apcu_prefix.is_some() + || input + .borrow() + .get_option("apcu-autoloader")? + .as_bool() + .unwrap_or(false) + || composer + .get_config() + .borrow() + .get("apcu-autoloader") + .as_bool() + .unwrap_or(false); + let minimal_changes = input + .borrow() + .get_option("minimal-changes")? + .as_bool() + .unwrap_or(false) + || composer + .get_config() + .borrow() + .get("update-with-minimal-changes") + .as_bool() + .unwrap_or(false); + + let mut update_allow_transitive_dependencies = UpdateAllowTransitiveDeps::UpdateOnlyListed; + let mut flags = String::new(); + if input + .borrow() + .get_option("update-with-all-dependencies")? + .as_bool() + .unwrap_or(false) + || input + .borrow() + .get_option("with-all-dependencies")? + .as_bool() + .unwrap_or(false) + { + update_allow_transitive_dependencies = + UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDeps; + flags += " --with-all-dependencies"; + } else if input + .borrow() + .get_option("update-with-dependencies")? + .as_bool() + .unwrap_or(false) + || input + .borrow() + .get_option("with-dependencies")? + .as_bool() + .unwrap_or(false) + { + update_allow_transitive_dependencies = + UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDepsNoRootRequire; + flags += " --with-dependencies"; + } + + io.write_error3( + &format!( + "Running composer update {}{}", + implode( + " ", + &array_keys(requirements) + .into_iter() + .collect::>() + ), + flags, + ), + true, + io_interface::NORMAL, ); - let requirements = match requirements_result { - Ok(r) => r, - Err(e) => { - if self.newly_created.get() { - self.revert_composer_file(); + let command_event = + CommandEvent::new(PluginEvents::COMMAND, "require", input.clone(), output); + composer + .get_event_dispatcher() + .borrow_mut() + .dispatch(Some(command_event.get_name()), None); - return Err(RuntimeException { - message: format!( - "No composer.json present in the current directory ({}), this may be the cause of the following exception.", - self.file.borrow() - ), - code: 0, - } - .into()); - } + composer + .get_installation_manager() + .borrow_mut() + .set_output_progress( + !input + .borrow() + .get_option("no-progress")? + .as_bool() + .unwrap_or(false), + ); - return Err(e); - } - }; + let mut install = Installer::create(io.clone(), &composer_handle); - let mut requirements = self.format_requirements(requirements)?; + let (prefer_source, prefer_dist) = self.get_preferred_install_options( + &composer.get_config().borrow(), + input.clone(), + false, + )?; - if !input.borrow().get_option("dev")?.as_bool().unwrap_or(false) - && self.get_io().is_interactive() - && !composer.is_global() - { - let mut dev_packages: Vec> = vec![]; - let dev_tags: Vec = vec![ - "dev".to_string(), - "testing".to_string(), - "static analysis".to_string(), - ]; - let current_requires_by_key = self.get_packages_by_require_key(); - for (name, _version) in &requirements { - // skip packages which are already in the composer.json as those have already been decided - if current_requires_by_key.contains_key(name) { - continue; - } + install + .set_dry_run( + input + .borrow() + .get_option("dry-run")? + .as_bool() + .unwrap_or(false), + ) + .set_verbose( + input + .borrow() + .get_option("verbose")? + .as_bool() + .unwrap_or(false), + ) + .set_prefer_source(prefer_source) + .set_prefer_dist(prefer_dist) + .set_dev_mode(update_dev_mode) + .set_optimize_autoloader(optimize) + .set_class_map_authoritative(authoritative) + .set_apcu_autoloader(apcu, apcu_prefix) + .set_update(true) + .set_install( + !input + .borrow() + .get_option("no-install")? + .as_bool() + .unwrap_or(false), + ) + .set_update_allow_transitive_dependencies(update_allow_transitive_dependencies)? + .set_platform_requirement_filter(BaseCommand::get_platform_requirement_filter( + self, + input.clone(), + )?) + .set_prefer_stable( + input + .borrow() + .get_option("prefer-stable")? + .as_bool() + .unwrap_or(false), + ) + .set_prefer_lowest( + input + .borrow() + .get_option("prefer-lowest")? + .as_bool() + .unwrap_or(false), + ) + .set_audit_config( + self.create_audit_config(&mut composer.get_config().borrow_mut(), input.clone())?, + ) + .set_minimal_update(minimal_changes); - let found_packages: Vec = self - .get_repos() - .find_packages(name, None)? + // if no lock is present, or the file is brand new, we do not do a + // partial update as this is not supported by the Installer + if !self.first_require.get() && composer.get_locker().borrow_mut().is_locked() { + install.set_update_allow_list( + array_keys(requirements) .into_iter() - .collect(); - let pkg: Option = - PackageSorter::get_most_current_version(found_packages); - let pkg_as_complete: Option = - pkg.as_ref().and_then(|p| p.as_complete()); - if let Some(pkg_complete) = pkg_as_complete { - let lowered: Vec = - array_map(|s: &String| strtolower(s), &pkg_complete.get_keywords()); - let pkg_dev_tags: Vec = array_intersect(&dev_tags, &lowered); - if (pkg_dev_tags.len() as i64) > 0 { - dev_packages.push(pkg_dev_tags); + .collect::>(), + ); + } + + let status = install.run()?; + if status != 0 && status != Installer::ERROR_AUDIT_FAILED { + if status == Installer::ERROR_DEPENDENCY_RESOLUTION_FAILED { + for req in BaseCommand::normalize_requirements( + self, + input + .borrow() + .get_argument("packages")? + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + )? { + if !req.contains_key("version") { + io.write_error3(&format!( + "You can also try re-running composer require with an explicit version constraint, e.g. \"composer require {}:*\" to figure out if any version is installable, or \"composer require {}:^2.1\" if you know which you need.", + req.get("name").cloned().unwrap_or_default(), + req.get("name").cloned().unwrap_or_default(), + ), true, io_interface::NORMAL); + break; } } - let _ = pkg; - } - - if (dev_packages.len() as i64) == (requirements.len() as i64) { - let plural = if (requirements.len() as i64) > 1 { - "s" - } else { - "" - }; - let plural2 = if (requirements.len() as i64) > 1 { - "are" - } else { - "is" - }; - let plural3 = if (requirements.len() as i64) > 1 { - "they are" - } else { - "it is" - }; - let merged: Vec = dev_packages.iter().flatten().cloned().collect(); - let pkg_dev_tags: Vec = array_unique(&merged); - let warn_msg = format!( - "The package{} you required {} recommended to be placed in require-dev (because {} tagged as \"{}\") but you did not use --dev.", - plural, - plural2, - plural3, - implode("\", \"", &pkg_dev_tags), - ); - self.get_io().warning(&warn_msg, &[]); - if self.get_io().ask_confirmation( - "Do you want to re-run the command with --dev? [yes]? " - .to_string(), - true, - ) { - input.borrow_mut().set_option("dev", PhpMixed::Bool(true))?; - } } - - // unset($devPackages, $pkgDevTags); + self.revert_composer_file(); } - let mut require_key = if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { - "require-dev" - } else { - "require" - }; - let mut remove_key = if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { - "require" + Ok(status) + } + + fn update_requirements_after_resolution( + &self, + requirements_to_update: &[String], + require_key: &str, + remove_key: &str, + sort_packages: bool, + dry_run: bool, + fixed: bool, + ) -> anyhow::Result { + let composer = self.require_composer(None, None)?; + let composer = crate::composer::composer_full(&composer); + let locker_is_locked = composer.get_locker().borrow_mut().is_locked(); + let mut requirements: IndexMap = IndexMap::new(); + let mut version_selector = VersionSelector::new( + std::rc::Rc::new(std::cell::RefCell::new(RepositorySet::new( + "stable", + IndexMap::new(), + vec![], + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ))), + None, + )?; + let repo: crate::repository::RepositoryInterfaceHandle = if locker_is_locked { + composer + .get_locker() + .borrow_mut() + .get_locked_repository(true)? + .into() } else { - "require-dev" + composer + .get_repository_manager() + .borrow() + .get_local_repository() }; - - // check which requirements need the version guessed - let mut requirements_to_guess: Vec = vec![]; - for (package, constraint) in requirements.clone().iter() { - if constraint == "guess" { - requirements.insert(package.clone(), "*".to_string()); - requirements_to_guess.push(package.clone()); + for package_name in requirements_to_update { + let mut package = repo.find_package( + package_name, + crate::repository::FindPackageConstraint::String("*".to_string()), + )?; + while let Some(alias) = package.as_ref().and_then(|p| p.as_alias()) { + package = Some(alias.get_alias_of().into()); } - } - - // validate requirements format - let version_parser = VersionParser::new(); - for (package, constraint) in &requirements { - if strtolower(package) == composer.get_package().get_name() { - let msg = format!( - "Root package '{}' cannot require itself in its composer.json", - package.clone(), - ); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); - return Ok(1); - } - if constraint == "self.version" { - continue; - } - version_parser.parse_constraints(constraint)?; - } + let package = match package { + Some(p) => p, + None => continue, + }; - let inconsistent_require_keys = - self.get_inconsistent_require_keys(&requirements, require_key); - if (inconsistent_require_keys.len() as i64) > 0 { - for package in &inconsistent_require_keys { - let warn_msg = format!( - "{} is currently present in the {} key and you ran the command {} the --dev flag, which will move it to the {} key.", - package.clone(), - remove_key, - if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { - "with" - } else { - "without" - }, - require_key, + if fixed { + requirements.insert(package_name.clone(), package.get_pretty_version()); + } else { + requirements.insert( + package_name.clone(), + version_selector.find_recommended_require_version(package.clone())?, ); - self.get_io().warning(&warn_msg, &[]); } + self.get_io().write_error3( + &format!( + "Using version {} for {}", + requirements.get(package_name).cloned().unwrap_or_default(), + package_name.clone(), + ), + true, + io_interface::NORMAL, + ); - if self.get_io().is_interactive() { - let q1 = format!( - "Do you want to move {}? [no]? ", - if (inconsistent_require_keys.len() as i64) > 1 { - "these requirements" - } else { - "this requirement" - }, + // Regex pattern compatibility: + // PCRE `{^dev-(?!main$|master$|trunk$|latest$)}` uses a negative lookahead, + // which the `regex` crate does not support. Decomposed into hand-written logic. + let requirement_str = requirements + .get(package_name) + .map(|s| s.as_str()) + .unwrap_or(""); + if requirement_str.starts_with("dev-") + && !matches!( + &requirement_str[4..], + "main" | "master" | "trunk" | "latest" + ) + { + self.get_io().warning( + &format!( + "Version {} looks like it may be a feature branch which is unlikely to keep working in the long run and may be in an unstable state", + requirements.get(package_name).cloned().unwrap_or_default(), + ), + &[], ); - if !self.get_io().ask_confirmation(q1, false) { - let q2 = format!( - "Do you want to re-run the command {} --dev? [yes]? ", - if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { - "without" - } else { - "with" - }, - ); - if !self.get_io().ask_confirmation(q2, true) { - return Ok(0); - } + if self.get_io().is_interactive() + && !self.get_io().ask_confirmation( + "Are you sure you want to use this constraint (y) or would you rather abort (n) the whole operation [y,n]? " + .to_string(), + true, + ) + { + self.revert_composer_file(); - input.borrow_mut().set_option("dev", PhpMixed::Bool(true))?; - std::mem::swap(&mut require_key, &mut remove_key); + return Ok(1); } } } - let sort_packages = input - .borrow() - .get_option("sort-packages")? - .as_bool() - .unwrap_or(false) - || composer - .get_config() - .borrow() - .get("sort-packages") - .as_bool() - .unwrap_or(false); - - self.first_require.set(self.newly_created.get()); - if !self.first_require.get() { - let composer_definition = json.borrow_mut().read()?; - let require_count = composer_definition - .get("require") - .and_then(|v| v.as_array()) - .map(|m| m.len() as i64) - .unwrap_or(0); - let require_dev_count = composer_definition - .get("require-dev") - .and_then(|v| v.as_array()) - .map(|m| m.len() as i64) - .unwrap_or(0); - if require_count == 0 && require_dev_count == 0 { - self.first_require.set(true); + if !dry_run { + let json = self.json.borrow().as_ref().unwrap().clone(); + self.update_file(&json, &requirements, require_key, remove_key, sort_packages); + if locker_is_locked + && composer + .get_config() + .borrow_mut() + .get("lock") + .as_bool() + .unwrap_or(false) + { + let stability_flags = RootPackageLoader::extract_stability_flags( + &requirements, + &composer.get_package().get_minimum_stability(), + IndexMap::new(), + ); + composer.get_locker().borrow_mut().update_hash( + &json.borrow(), + Some(Box::new(move |mut lock_data| { + let section = lock_data + .entry("stability-flags".to_string()) + .or_insert_with(|| PhpMixed::Array(IndexMap::new())); + if let Some(section) = section.as_array_mut() { + for (package_name, flag) in &stability_flags { + section.insert(package_name.clone(), PhpMixed::Int(*flag)); + } + } + lock_data + })), + )?; } } - if !input - .borrow() - .get_option("dry-run")? - .as_bool() - .unwrap_or(false) - { - self.update_file(&json, &requirements, require_key, remove_key, sort_packages); + Ok(0) + } + + fn update_file( + &self, + json: &std::rc::Rc>, + new: &IndexMap, + require_key: &str, + remove_key: &str, + sort_packages: bool, + ) { + if self.update_file_cleanly(json, new, require_key, remove_key, sort_packages) { + return; } - let updated_msg = format!( - "{} has been {}", - file, - if self.newly_created.get() { - "created" - } else { - "updated" + let composer_definition_mixed = json.borrow_mut().read().unwrap_or_default(); + let mut composer_definition = composer_definition_mixed + .as_array() + .cloned() + .unwrap_or_default(); + for (package, version) in new { + let section = composer_definition + .entry(require_key.to_string()) + .or_insert_with(|| PhpMixed::Array(IndexMap::new())); + if let Some(section) = section.as_array_mut() { + section.insert(package.clone(), PhpMixed::String(version.clone())); + } + if let Some(section) = composer_definition + .get_mut(remove_key) + .and_then(|v| v.as_array_mut()) + { + section.shift_remove(package); + } + let remove_empty = composer_definition + .get(remove_key) + .and_then(|v| v.as_array()) + .map(|m| m.is_empty()) + .unwrap_or(false); + if remove_empty && composer_definition.contains_key(remove_key) { + composer_definition.shift_remove(remove_key); } - ); - self.get_io() - .write_error3(&updated_msg, true, io_interface::NORMAL); - - if input - .borrow() - .get_option("no-update")? - .as_bool() - .unwrap_or(false) - { - return Ok(0); } + let _ = json.borrow().write(PhpMixed::Array(composer_definition)); + } - composer - .get_plugin_manager() - .borrow_mut() - .deactivate_installed_plugins()?; + fn update_file_cleanly( + &self, + json: &std::rc::Rc>, + new: &IndexMap, + require_key: &str, + remove_key: &str, + sort_packages: bool, + ) -> bool { + let contents = file_get_contents(json.borrow().get_path()).unwrap_or_default(); - let io = self.get_io().clone(); - let do_update_result = self.do_update( - input.clone(), - output, - io, - &requirements, - require_key, - remove_key, - ); - let dry_run = input - .borrow() - .get_option("dry-run")? - .as_bool() - .unwrap_or(false); + let mut manipulator = match JsonManipulator::new(contents) { + Ok(m) => m, + Err(_) => return false, + }; - let result = match do_update_result { - Ok(result) => { - let final_result = if result == 0 && (requirements_to_guess.len() as i64) > 0 { - let fixed = input - .borrow() - .get_option("fixed")? - .as_bool() - .unwrap_or(false); - self.update_requirements_after_resolution( - &requirements_to_guess, - require_key, - remove_key, - sort_packages, - dry_run, - fixed, - )? - } else { - result - }; - Ok(final_result) + for (package, constraint) in new { + if !manipulator + .add_link(require_key, package, constraint, sort_packages) + .unwrap_or(false) + { + return false; } - Err(e) => { - if !self.dependency_resolution_completed.get() { - self.revert_composer_file(); - } - Err(e) + if !manipulator + .remove_sub_node(remove_key, package) + .unwrap_or(false) + { + return false; } - }; + } - // finally - if dry_run && self.newly_created.get() { - // @unlink($this->json->getPath()); + let _ = manipulator.remove_main_key_if_empty(remove_key); + + file_put_contents( + json.borrow().get_path(), + manipulator.get_contents().as_bytes(), + ); + + true + } + + fn revert_composer_file(&self) { + let json = self.json.borrow().as_ref().unwrap().clone(); + let lock = self.lock.borrow().clone(); + if self.newly_created.get() { + let msg = format!( + "\nInstallation failed, deleting {}.", + self.file.borrow() + ); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); unlink(json.borrow().get_path()); + if file_exists(&lock) { + unlink(&lock); + } + } else { + let extra = if self.lock_backup.borrow().is_some() { + format!(" and {} to their ", lock) + } else { + " to its ".to_string() + }; + let msg = format!( + "\nInstallation failed, reverting {}{}original content.", + self.file.borrow(), + extra + ); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); + file_put_contents( + json.borrow().get_path(), + self.composer_backup.borrow().as_bytes(), + ); + if let Some(ref lock_backup) = *self.lock_backup.borrow() { + file_put_contents(&lock, lock_backup.as_bytes()); + } } - signal_handler.unregister(); - - result } +} - fn interact( +impl PackageDiscoveryTrait for RequireCommand { + fn get_repos_mut( &self, - _input: std::rc::Rc>, - _output: std::rc::Rc>, - ) { + ) -> std::cell::RefMut<'_, Option> { + self.repos.borrow_mut() } - fn initialize( + fn get_repository_sets_mut( &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result<()> { - base_command_initialize(self, input, output) + ) -> std::cell::RefMut<'_, IndexMap>>> + { + self.repository_sets.borrow_mut() + } +} + +impl Command for RequireCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("require")?; + self.set_aliases(vec!["r".to_string()])?; + self.set_description("Adds required packages to your composer.json and installs them"); + self.set_definition(&[ + InputArgument::new5("packages", Some(InputArgument::IS_ARRAY | InputArgument::OPTIONAL), "Optional package name can also include a version constraint, e.g. foo/bar or foo/bar:1.0.0 or foo/bar=1.0.0 or \"foo/bar 1.0.0\"", None, self.suggest_available_package_incl_platform()).unwrap().into(), + InputOption::new("dev", None, Some(InputOption::VALUE_NONE), "Add requirement to require-dev.", None).unwrap().into(), + InputOption::new("dry-run", None, Some(InputOption::VALUE_NONE), "Outputs the operations but will not execute anything (implicitly enables --verbose).", None).unwrap().into(), + InputOption::new("prefer-source", None, Some(InputOption::VALUE_NONE), "Forces installation from package sources when possible, including VCS information.", None).unwrap().into(), + InputOption::new("prefer-dist", None, Some(InputOption::VALUE_NONE), "Forces installation from package dist (default behavior).", None).unwrap().into(), + InputOption::new6("prefer-install", None, Some(InputOption::VALUE_REQUIRED), "Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).", None, self.suggest_prefer_install()).unwrap().into(), + InputOption::new("fixed", None, Some(InputOption::VALUE_NONE), "Write fixed version to the composer.json.", None).unwrap().into(), + InputOption::new("no-suggest", None, Some(InputOption::VALUE_NONE), "DEPRECATED: This flag does not exist anymore.", None).unwrap().into(), + InputOption::new("no-progress", None, Some(InputOption::VALUE_NONE), "Do not output download progress.", None).unwrap().into(), + InputOption::new("no-update", None, Some(InputOption::VALUE_NONE), "Disables the automatic update of the dependencies (implies --no-install).", None).unwrap().into(), + InputOption::new("no-install", None, Some(InputOption::VALUE_NONE), "Skip the install step after updating the composer.lock file.", None).unwrap().into(), + InputOption::new("no-audit", None, Some(InputOption::VALUE_NONE), "Skip the audit step after updating the composer.lock file (can also be set via the COMPOSER_NO_AUDIT=1 env var).", None).unwrap().into(), + InputOption::new6("audit-format", None, Some(InputOption::VALUE_REQUIRED), "Audit output format. Must be \"table\", \"plain\", \"json\", or \"summary\".", Some(PhpMixed::String(Auditor::FORMAT_SUMMARY.to_string())), SuggestedValues::List(Auditor::FORMATS.iter().map(|s| s.to_string()).collect())).unwrap().into(), + InputOption::new("no-security-blocking", None, Some(InputOption::VALUE_NONE), "Allows installing packages with security advisories or that are abandoned (can also be set via the COMPOSER_NO_SECURITY_BLOCKING=1 env var).", None).unwrap().into(), + InputOption::new("update-no-dev", None, Some(InputOption::VALUE_NONE), "Run the dependency update with the --no-dev option.", None).unwrap().into(), + InputOption::new("update-with-dependencies", Some(PhpMixed::String("w".to_string())), Some(InputOption::VALUE_NONE), "Allows inherited dependencies to be updated, except those that are root requirements (can also be set via the COMPOSER_WITH_DEPENDENCIES=1 env var).", None).unwrap().into(), + InputOption::new("update-with-all-dependencies", Some(PhpMixed::String("W".to_string())), Some(InputOption::VALUE_NONE), "Allows all inherited dependencies to be updated, including those that are root requirements (can also be set via the COMPOSER_WITH_ALL_DEPENDENCIES=1 env var).", None).unwrap().into(), + InputOption::new("with-dependencies", None, Some(InputOption::VALUE_NONE), "Alias for --update-with-dependencies", None).unwrap().into(), + InputOption::new("with-all-dependencies", None, Some(InputOption::VALUE_NONE), "Alias for --update-with-all-dependencies", None).unwrap().into(), + InputOption::new("ignore-platform-req", None, Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), "Ignore a specific platform requirement (php & ext- packages).", None).unwrap().into(), + InputOption::new("ignore-platform-reqs", None, Some(InputOption::VALUE_NONE), "Ignore all platform requirements (php & ext- packages).", None).unwrap().into(), + InputOption::new("prefer-stable", None, Some(InputOption::VALUE_NONE), "Prefer stable versions of dependencies (can also be set via the COMPOSER_PREFER_STABLE=1 env var).", None).unwrap().into(), + InputOption::new("prefer-lowest", None, Some(InputOption::VALUE_NONE), "Prefer lowest versions of dependencies (can also be set via the COMPOSER_PREFER_LOWEST=1 env var).", None).unwrap().into(), + InputOption::new("minimal-changes", Some(PhpMixed::String("m".to_string())), Some(InputOption::VALUE_NONE), "During an update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).", None).unwrap().into(), + InputOption::new("sort-packages", None, Some(InputOption::VALUE_NONE), "Sorts packages when adding/updating a new dependency", None).unwrap().into(), + InputOption::new("optimize-autoloader", Some(PhpMixed::String("o".to_string())), Some(InputOption::VALUE_NONE), "Optimize autoloader during autoloader dump", None).unwrap().into(), + InputOption::new("classmap-authoritative", Some(PhpMixed::String("a".to_string())), Some(InputOption::VALUE_NONE), "Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.", None).unwrap().into(), + InputOption::new("apcu-autoloader", None, Some(InputOption::VALUE_NONE), "Use APCu to cache found/not-found classes.", None).unwrap().into(), + InputOption::new("apcu-autoloader-prefix", None, Some(InputOption::VALUE_REQUIRED), "Use a custom prefix for the APCu autoloader cache. Implicitly enables --apcu-autoloader", None).unwrap().into(), + ]); + self.set_help( + "The require command adds required packages to your composer.json and installs them.\n\ + \n\ + If you do not specify a package, composer will prompt you to search for a package, and given results, provide a list of\n\ + matches to require.\n\ + \n\ + If you do not specify a version constraint, composer will choose a suitable one based on the available package versions.\n\ + \n\ + If you do not want to install the new dependencies immediately you can call it with --no-update\n\ + \n\ + Read more at https://getcomposer.org/doc/03-cli.md#require-r" + ); + Ok(()) } - fn complete( + /// @throws \Seld\JsonLint\ParsingException + fn execute( &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } - - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + *self.file.borrow_mut() = Factory::get_composer_file()?; -impl BaseCommand for RequireCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data - } + if input + .borrow() + .get_option("no-suggest")? + .as_bool() + .unwrap_or(false) + { + self.get_io().write_error3("You are using the deprecated option \"--no-suggest\". It has no effect and will break in Composer 3.", true, io_interface::NORMAL); + } - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} + let file = self.file.borrow().clone(); + self.newly_created.set(!file_exists(&file)); + let write_failed = + self.newly_created.get() && file_put_contents(&file, b"{\n}\n").is_none(); + if write_failed { + let msg = format!("{} could not be created.", file); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); -impl RequireCommand { - fn get_inconsistent_require_keys( - &self, - new_requirements: &IndexMap, - require_key: &str, - ) -> Vec { - let require_keys = self.get_packages_by_require_key(); - let mut inconsistent_requirements: Vec = vec![]; - for (package, package_require_key) in &require_keys { - if !new_requirements.contains_key(package) { - continue; - } - if require_key != package_require_key { - inconsistent_requirements.push(package.clone()); - } + return Ok(1); } + if !Filesystem::is_readable(&file) { + let msg = format!("{} is not readable.", file); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); - inconsistent_requirements - } + return Ok(1); + } + if filesize(&file) == Some(0) { + file_put_contents(&file, b"{\n}\n"); + } - fn get_packages_by_require_key(&self) -> IndexMap { + *self.json.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new(JsonFile::new( + file.clone(), + None, + None, + )?))); + *self.lock.borrow_mut() = Factory::get_lock_file(&file); let json = self.json.borrow().as_ref().unwrap().clone(); - let composer_definition = json.borrow_mut().read().unwrap_or_default(); - let mut require: IndexMap = IndexMap::new(); - let mut require_dev: IndexMap = IndexMap::new(); + *self.composer_backup.borrow_mut() = + file_get_contents(json.borrow().get_path()).unwrap_or_default(); + let lock = self.lock.borrow().clone(); + *self.lock_backup.borrow_mut() = if file_exists(&lock) { + file_get_contents(&lock) + } else { + None + }; - if let Some(r) = composer_definition - .get("require") - .and_then(|v| v.as_array()) - { - for (k, v) in r { - require.insert(k.clone(), v.clone()); - } - } + // PHP: function ($signal, $handler) use ($io, $self) { + // $io->writeError('Received '.$signal.', aborting', true, IOInterface::DEBUG); + // $self->revertComposerFile(); $handler->exitWithLastSignal(); } + // TODO(phase-c): SignalHandler::create takes a `Box + 'static` handler that cannot + // borrow &self, but the body must call self.revert_composer_file() (which mutates the + // command's composer.json backup state) and self.get_io(). Faithfully wiring this needs the + // revert state + io shared into the closure (Rc>), i.e. the shared-ownership + // rework of the command — the same pattern as InstallationManager::execute's signal handler. + let signal_handler = SignalHandler::create( + vec![ + SignalHandler::SIGINT.to_string(), + SignalHandler::SIGTERM.to_string(), + SignalHandler::SIGHUP.to_string(), + ], + Box::new(move |signal: String, handler: &SignalHandler| { + let _ = signal; + handler.exit_with_last_signal(); + }), + ); - if let Some(r) = composer_definition - .get("require-dev") - .and_then(|v| v.as_array()) + // check for writability by writing to the file as is_writable can not be trusted on network-mounts + // see https://github.com/composer/composer/issues/8231 and https://bugs.php.net/bug.php?id=68926 + let file_path = file.clone(); + let backup_contents = self.composer_backup.borrow().clone(); + if !is_writable(&file) + && Silencer::call(|| { + shirabe_php_shim::file_put_contents(&file_path, backup_contents.as_bytes()); + Ok::(false) + }) + .ok() + == Some(false) { - for (k, v) in r { - require_dev.insert(k.clone(), v.clone()); - } + let msg = format!("{} is not writable.", file); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); + + return Ok(1); } - array_merge( - array_fill_keys( - PhpMixed::List( - array_keys(&require) - .into_iter() - .map(PhpMixed::String) - .collect(), - ), - PhpMixed::String("require".to_string()), - ), - array_fill_keys( - PhpMixed::List( - array_keys(&require_dev) - .into_iter() - .map(PhpMixed::String) - .collect(), - ), - PhpMixed::String("require-dev".to_string()), - ), - ) - .as_array() - .map(|m| { - m.iter() - .filter_map(|(k, v)| v.as_string().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default() - } + if input.borrow().get_option("fixed")?.as_bool() == Some(true) { + let config = json.borrow_mut().read()?; - /// @throws \Exception - fn do_update( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - io: std::rc::Rc>, - requirements: &IndexMap, - require_key: &str, - _remove_key: &str, - ) -> anyhow::Result { - // Update packages - self.reset_composer()?; - let composer_handle = self.require_composer(None, None)?; - let composer = crate::composer::composer_full(&composer_handle); + let package_type = if empty(&config.get("type").cloned().unwrap_or(PhpMixed::Null)) { + "library".to_string() + } else { + config + .get("type") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string() + }; - self.dependency_resolution_completed.set(false); - // PHP: $composer->getEventDispatcher()->addListener(InstallerEvents::PRE_OPERATIONS_EXEC, - // function () use (&$dependencyResolutionCompleted) { $dependencyResolutionCompleted = true; }, 10000); - let dependency_resolution_completed = self.dependency_resolution_completed.clone(); - composer.get_event_dispatcher().borrow_mut().add_listener( - InstallerEvents::PRE_OPERATIONS_EXEC, - crate::event_dispatcher::Callable::Closure(std::rc::Rc::new(move |_event| { - dependency_resolution_completed.set(true); - PhpMixed::Null - })), - 10000, - ); + // @see https://github.com/composer/composer/pull/8313#issuecomment-532637955 + if package_type != "project" + && !input.borrow().get_option("dev")?.as_bool().unwrap_or(false) + { + self.get_io().write_error3("The \"--fixed\" option is only allowed for packages with a \"project\" type or for dev dependencies to prevent possible misuses.", true, io_interface::NORMAL); - if input - .borrow() - .get_option("dry-run")? - .as_bool() - .unwrap_or(false) - { - let root_package = composer.get_package(); - let mut links: IndexMap> = - IndexMap::new(); - links.insert("require".to_string(), root_package.get_requires()); - links.insert("require-dev".to_string(), root_package.get_dev_requires()); - let loader = ArrayLoader::new(None, false); - let requirements_mixed: IndexMap = requirements - .iter() - .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) - .collect(); - let new_links = loader.parse_links( - &root_package.get_name(), - &root_package.get_pretty_version(), - base_package::SUPPORTED_LINK_TYPES - .get(require_key) - .map(|t| t.method) - .unwrap_or_default(), - requirements_mixed, - )?; - if let Some(section) = links.get_mut(require_key) { - for (k, v) in new_links { - section.insert(k, v); - } - } - for (package, _constraint) in requirements { - if let Some(section) = links.get_mut(_remove_key) { - section.shift_remove(package); + if config.get("type").is_none() { + self.get_io().write_error3("If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".", true, io_interface::NORMAL); } + + return Ok(1); } - root_package.set_requires(links["require"].clone()); - root_package.set_dev_requires(links["require-dev"].clone()); + } - // extract stability flags & references as they weren't present when loading the unmodified composer.json - let references = - RootPackageLoader::extract_references(requirements, root_package.get_references()); - root_package.set_references(references); - let stability_flags = RootPackageLoader::extract_stability_flags( - requirements, - &root_package.get_minimum_stability(), - root_package.get_stability_flags(), - ); - root_package.set_stability_flags(stability_flags); + let composer = self.require_composer(None, None)?; + let composer = crate::composer::composer_full(&composer); + let repository_manager = composer.get_repository_manager().clone(); + let repository_manager = repository_manager.borrow(); + let repos = repository_manager.get_repositories(); + + let platform_overrides = composer.get_config().borrow_mut().get("platform"); + let platform_overrides_map: IndexMap = platform_overrides + .as_array() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + // initialize self.repos as it is used by the PackageDiscoveryTrait + let platform_repo = + PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides_map)?); + let mut combined: Vec = + vec![platform_repo.clone().into()]; + for repo in repos { + combined.push(repo.clone()); } + *self.get_repos_mut() = Some(crate::repository::RepositoryInterfaceHandle::new( + CompositeRepository::new(combined), + )); - let update_dev_mode = !input - .borrow() - .get_option("update-no-dev")? - .as_bool() - .unwrap_or(false); - let optimize = input - .borrow() - .get_option("optimize-autoloader")? - .as_bool() - .unwrap_or(false) - || composer - .get_config() - .borrow() - .get("optimize-autoloader") - .as_bool() - .unwrap_or(false); - let authoritative = input - .borrow() - .get_option("classmap-authoritative")? - .as_bool() - .unwrap_or(false) - || composer - .get_config() - .borrow() - .get("classmap-authoritative") - .as_bool() - .unwrap_or(false); - let apcu_prefix = input - .borrow() - .get_option("apcu-autoloader-prefix")? - .as_string() - .map(|s| s.to_string()); - let apcu = apcu_prefix.is_some() - || input - .borrow() - .get_option("apcu-autoloader")? - .as_bool() - .unwrap_or(false) - || composer - .get_config() - .borrow() - .get("apcu-autoloader") - .as_bool() - .unwrap_or(false); - let minimal_changes = input - .borrow() - .get_option("minimal-changes")? - .as_bool() - .unwrap_or(false) - || composer - .get_config() - .borrow() - .get("update-with-minimal-changes") - .as_bool() - .unwrap_or(false); + let preferred_stability = if composer.get_package().get_prefer_stable() { + "stable".to_string() + } else { + composer.get_package().get_minimum_stability() + }; - let mut update_allow_transitive_dependencies = UpdateAllowTransitiveDeps::UpdateOnlyListed; - let mut flags = String::new(); - if input + // Hoist argument computations into locals so no borrow of `input` is held across the + // call: `determine_requirements` may prompt via ConsoleIO, which mutably borrows the + // same input RefCell. + let packages: Vec = input .borrow() - .get_option("update-with-all-dependencies")? + .get_argument("packages")? + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + // if there is no update, we need to use the best possible version constraint directly as we cannot rely on the solver to guess the best constraint + let no_update = input + .borrow() + .get_option("no-update")? .as_bool() - .unwrap_or(false) - || input - .borrow() - .get_option("with-all-dependencies")? - .as_bool() - .unwrap_or(false) - { - update_allow_transitive_dependencies = - UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDeps; - flags += " --with-all-dependencies"; - } else if input + .unwrap_or(false); + let fixed = input .borrow() - .get_option("update-with-dependencies")? + .get_option("fixed")? .as_bool() - .unwrap_or(false) - || input - .borrow() - .get_option("with-dependencies")? - .as_bool() - .unwrap_or(false) - { - update_allow_transitive_dependencies = - UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDepsNoRootRequire; - flags += " --with-dependencies"; - } - - io.write_error3( - &format!( - "Running composer update {}{}", - implode( - " ", - &array_keys(requirements) - .into_iter() - .collect::>() - ), - flags, - ), - true, - io_interface::NORMAL, + .unwrap_or(false); + let requirements_result = self.determine_requirements( + input.clone(), + output.clone(), + packages, + Some(&platform_repo), + &preferred_stability, + no_update, + fixed, ); - let command_event = - CommandEvent::new(PluginEvents::COMMAND, "require", input.clone(), output); - composer - .get_event_dispatcher() - .borrow_mut() - .dispatch(Some(command_event.get_name()), None); - - composer - .get_installation_manager() - .borrow_mut() - .set_output_progress( - !input - .borrow() - .get_option("no-progress")? - .as_bool() - .unwrap_or(false), - ); + let requirements = match requirements_result { + Ok(r) => r, + Err(e) => { + if self.newly_created.get() { + self.revert_composer_file(); - let mut install = Installer::create(io.clone(), &composer_handle); + return Err(RuntimeException { + message: format!( + "No composer.json present in the current directory ({}), this may be the cause of the following exception.", + self.file.borrow() + ), + code: 0, + } + .into()); + } - let (prefer_source, prefer_dist) = self.get_preferred_install_options( - &composer.get_config().borrow(), - input.clone(), - false, - )?; + return Err(e); + } + }; - install - .set_dry_run( - input - .borrow() - .get_option("dry-run")? - .as_bool() - .unwrap_or(false), - ) - .set_verbose( - input - .borrow() - .get_option("verbose")? - .as_bool() - .unwrap_or(false), - ) - .set_prefer_source(prefer_source) - .set_prefer_dist(prefer_dist) - .set_dev_mode(update_dev_mode) - .set_optimize_autoloader(optimize) - .set_class_map_authoritative(authoritative) - .set_apcu_autoloader(apcu, apcu_prefix) - .set_update(true) - .set_install( - !input - .borrow() - .get_option("no-install")? - .as_bool() - .unwrap_or(false), - ) - .set_update_allow_transitive_dependencies(update_allow_transitive_dependencies)? - .set_platform_requirement_filter(BaseCommand::get_platform_requirement_filter( - self, - input.clone(), - )?) - .set_prefer_stable( - input - .borrow() - .get_option("prefer-stable")? - .as_bool() - .unwrap_or(false), - ) - .set_prefer_lowest( - input - .borrow() - .get_option("prefer-lowest")? - .as_bool() - .unwrap_or(false), - ) - .set_audit_config( - self.create_audit_config(&mut composer.get_config().borrow_mut(), input.clone())?, - ) - .set_minimal_update(minimal_changes); + let mut requirements = self.format_requirements(requirements)?; - // if no lock is present, or the file is brand new, we do not do a - // partial update as this is not supported by the Installer - if !self.first_require.get() && composer.get_locker().borrow_mut().is_locked() { - install.set_update_allow_list( - array_keys(requirements) - .into_iter() - .collect::>(), - ); - } + if !input.borrow().get_option("dev")?.as_bool().unwrap_or(false) + && self.get_io().is_interactive() + && !composer.is_global() + { + let mut dev_packages: Vec> = vec![]; + let dev_tags: Vec = vec![ + "dev".to_string(), + "testing".to_string(), + "static analysis".to_string(), + ]; + let current_requires_by_key = self.get_packages_by_require_key(); + for (name, _version) in &requirements { + // skip packages which are already in the composer.json as those have already been decided + if current_requires_by_key.contains_key(name) { + continue; + } - let status = install.run()?; - if status != 0 && status != Installer::ERROR_AUDIT_FAILED { - if status == Installer::ERROR_DEPENDENCY_RESOLUTION_FAILED { - for req in BaseCommand::normalize_requirements( - self, - input - .borrow() - .get_argument("packages")? - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(), - )? { - if !req.contains_key("version") { - io.write_error3(&format!( - "You can also try re-running composer require with an explicit version constraint, e.g. \"composer require {}:*\" to figure out if any version is installable, or \"composer require {}:^2.1\" if you know which you need.", - req.get("name").cloned().unwrap_or_default(), - req.get("name").cloned().unwrap_or_default(), - ), true, io_interface::NORMAL); - break; + let found_packages: Vec = self + .get_repos() + .find_packages(name, None)? + .into_iter() + .collect(); + let pkg: Option = + PackageSorter::get_most_current_version(found_packages); + let pkg_as_complete: Option = + pkg.as_ref().and_then(|p| p.as_complete()); + if let Some(pkg_complete) = pkg_as_complete { + let lowered: Vec = + array_map(|s: &String| strtolower(s), &pkg_complete.get_keywords()); + let pkg_dev_tags: Vec = array_intersect(&dev_tags, &lowered); + if (pkg_dev_tags.len() as i64) > 0 { + dev_packages.push(pkg_dev_tags); } } + let _ = pkg; } - self.revert_composer_file(); - } - Ok(status) - } + if (dev_packages.len() as i64) == (requirements.len() as i64) { + let plural = if (requirements.len() as i64) > 1 { + "s" + } else { + "" + }; + let plural2 = if (requirements.len() as i64) > 1 { + "are" + } else { + "is" + }; + let plural3 = if (requirements.len() as i64) > 1 { + "they are" + } else { + "it is" + }; + let merged: Vec = dev_packages.iter().flatten().cloned().collect(); + let pkg_dev_tags: Vec = array_unique(&merged); + let warn_msg = format!( + "The package{} you required {} recommended to be placed in require-dev (because {} tagged as \"{}\") but you did not use --dev.", + plural, + plural2, + plural3, + implode("\", \"", &pkg_dev_tags), + ); + self.get_io().warning(&warn_msg, &[]); + if self.get_io().ask_confirmation( + "Do you want to re-run the command with --dev? [yes]? " + .to_string(), + true, + ) { + input.borrow_mut().set_option("dev", PhpMixed::Bool(true))?; + } + } - fn update_requirements_after_resolution( - &self, - requirements_to_update: &[String], - require_key: &str, - remove_key: &str, - sort_packages: bool, - dry_run: bool, - fixed: bool, - ) -> anyhow::Result { - let composer = self.require_composer(None, None)?; - let composer = crate::composer::composer_full(&composer); - let locker_is_locked = composer.get_locker().borrow_mut().is_locked(); - let mut requirements: IndexMap = IndexMap::new(); - let mut version_selector = VersionSelector::new( - std::rc::Rc::new(std::cell::RefCell::new(RepositorySet::new( - "stable", - IndexMap::new(), - vec![], - IndexMap::new(), - IndexMap::new(), - IndexMap::new(), - ))), - None, - )?; - let repo: crate::repository::RepositoryInterfaceHandle = if locker_is_locked { - composer - .get_locker() - .borrow_mut() - .get_locked_repository(true)? - .into() + // unset($devPackages, $pkgDevTags); + } + + let mut require_key = if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { + "require-dev" } else { - composer - .get_repository_manager() - .borrow() - .get_local_repository() + "require" }; - for package_name in requirements_to_update { - let mut package = repo.find_package( - package_name, - crate::repository::FindPackageConstraint::String("*".to_string()), - )?; - while let Some(alias) = package.as_ref().and_then(|p| p.as_alias()) { - package = Some(alias.get_alias_of().into()); + let mut remove_key = if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { + "require" + } else { + "require-dev" + }; + + // check which requirements need the version guessed + let mut requirements_to_guess: Vec = vec![]; + for (package, constraint) in requirements.clone().iter() { + if constraint == "guess" { + requirements.insert(package.clone(), "*".to_string()); + requirements_to_guess.push(package.clone()); } + } - let package = match package { - Some(p) => p, - None => continue, - }; + // validate requirements format + let version_parser = VersionParser::new(); + for (package, constraint) in &requirements { + if strtolower(package) == composer.get_package().get_name() { + let msg = format!( + "Root package '{}' cannot require itself in its composer.json", + package.clone(), + ); + self.get_io().write_error3(&msg, true, io_interface::NORMAL); - if fixed { - requirements.insert(package_name.clone(), package.get_pretty_version()); - } else { - requirements.insert( - package_name.clone(), - version_selector.find_recommended_require_version(package.clone())?, + return Ok(1); + } + if constraint == "self.version" { + continue; + } + version_parser.parse_constraints(constraint)?; + } + + let inconsistent_require_keys = + self.get_inconsistent_require_keys(&requirements, require_key); + if (inconsistent_require_keys.len() as i64) > 0 { + for package in &inconsistent_require_keys { + let warn_msg = format!( + "{} is currently present in the {} key and you ran the command {} the --dev flag, which will move it to the {} key.", + package.clone(), + remove_key, + if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { + "with" + } else { + "without" + }, + require_key, ); + self.get_io().warning(&warn_msg, &[]); } - self.get_io().write_error3( - &format!( - "Using version {} for {}", - requirements.get(package_name).cloned().unwrap_or_default(), - package_name.clone(), - ), - true, - io_interface::NORMAL, - ); - // Regex pattern compatibility: - // PCRE `{^dev-(?!main$|master$|trunk$|latest$)}` uses a negative lookahead, - // which the `regex` crate does not support. Decomposed into hand-written logic. - let requirement_str = requirements - .get(package_name) - .map(|s| s.as_str()) - .unwrap_or(""); - if requirement_str.starts_with("dev-") - && !matches!( - &requirement_str[4..], - "main" | "master" | "trunk" | "latest" - ) - { - self.get_io().warning( - &format!( - "Version {} looks like it may be a feature branch which is unlikely to keep working in the long run and may be in an unstable state", - requirements.get(package_name).cloned().unwrap_or_default(), - ), - &[], + if self.get_io().is_interactive() { + let q1 = format!( + "Do you want to move {}? [no]? ", + if (inconsistent_require_keys.len() as i64) > 1 { + "these requirements" + } else { + "this requirement" + }, ); - if self.get_io().is_interactive() - && !self.get_io().ask_confirmation( - "Are you sure you want to use this constraint (y) or would you rather abort (n) the whole operation [y,n]? " - .to_string(), - true, - ) - { - self.revert_composer_file(); + if !self.get_io().ask_confirmation(q1, false) { + let q2 = format!( + "Do you want to re-run the command {} --dev? [yes]? ", + if input.borrow().get_option("dev")?.as_bool().unwrap_or(false) { + "without" + } else { + "with" + }, + ); + if !self.get_io().ask_confirmation(q2, true) { + return Ok(0); + } + + input.borrow_mut().set_option("dev", PhpMixed::Bool(true))?; + std::mem::swap(&mut require_key, &mut remove_key); + } + } + } + + let sort_packages = input + .borrow() + .get_option("sort-packages")? + .as_bool() + .unwrap_or(false) + || composer + .get_config() + .borrow() + .get("sort-packages") + .as_bool() + .unwrap_or(false); - return Ok(1); - } + self.first_require.set(self.newly_created.get()); + if !self.first_require.get() { + let composer_definition = json.borrow_mut().read()?; + let require_count = composer_definition + .get("require") + .and_then(|v| v.as_array()) + .map(|m| m.len() as i64) + .unwrap_or(0); + let require_dev_count = composer_definition + .get("require-dev") + .and_then(|v| v.as_array()) + .map(|m| m.len() as i64) + .unwrap_or(0); + if require_count == 0 && require_dev_count == 0 { + self.first_require.set(true); } } - if !dry_run { - let json = self.json.borrow().as_ref().unwrap().clone(); + if !input + .borrow() + .get_option("dry-run")? + .as_bool() + .unwrap_or(false) + { self.update_file(&json, &requirements, require_key, remove_key, sort_packages); - if locker_is_locked - && composer - .get_config() - .borrow_mut() - .get("lock") - .as_bool() - .unwrap_or(false) - { - let stability_flags = RootPackageLoader::extract_stability_flags( - &requirements, - &composer.get_package().get_minimum_stability(), - IndexMap::new(), - ); - composer.get_locker().borrow_mut().update_hash( - &json.borrow(), - Some(Box::new(move |mut lock_data| { - let section = lock_data - .entry("stability-flags".to_string()) - .or_insert_with(|| PhpMixed::Array(IndexMap::new())); - if let Some(section) = section.as_array_mut() { - for (package_name, flag) in &stability_flags { - section.insert(package_name.clone(), PhpMixed::Int(*flag)); - } - } - lock_data - })), - )?; - } } - Ok(0) - } + let updated_msg = format!( + "{} has been {}", + file, + if self.newly_created.get() { + "created" + } else { + "updated" + } + ); + self.get_io() + .write_error3(&updated_msg, true, io_interface::NORMAL); - fn update_file( - &self, - json: &std::rc::Rc>, - new: &IndexMap, - require_key: &str, - remove_key: &str, - sort_packages: bool, - ) { - if self.update_file_cleanly(json, new, require_key, remove_key, sort_packages) { - return; + if input + .borrow() + .get_option("no-update")? + .as_bool() + .unwrap_or(false) + { + return Ok(0); } - let composer_definition_mixed = json.borrow_mut().read().unwrap_or_default(); - let mut composer_definition = composer_definition_mixed - .as_array() - .cloned() - .unwrap_or_default(); - for (package, version) in new { - let section = composer_definition - .entry(require_key.to_string()) - .or_insert_with(|| PhpMixed::Array(IndexMap::new())); - if let Some(section) = section.as_array_mut() { - section.insert(package.clone(), PhpMixed::String(version.clone())); - } - if let Some(section) = composer_definition - .get_mut(remove_key) - .and_then(|v| v.as_array_mut()) - { - section.shift_remove(package); + composer + .get_plugin_manager() + .borrow_mut() + .deactivate_installed_plugins()?; + + let io = self.get_io().clone(); + let do_update_result = self.do_update( + input.clone(), + output, + io, + &requirements, + require_key, + remove_key, + ); + let dry_run = input + .borrow() + .get_option("dry-run")? + .as_bool() + .unwrap_or(false); + + let result = match do_update_result { + Ok(result) => { + let final_result = if result == 0 && (requirements_to_guess.len() as i64) > 0 { + let fixed = input + .borrow() + .get_option("fixed")? + .as_bool() + .unwrap_or(false); + self.update_requirements_after_resolution( + &requirements_to_guess, + require_key, + remove_key, + sort_packages, + dry_run, + fixed, + )? + } else { + result + }; + Ok(final_result) } - let remove_empty = composer_definition - .get(remove_key) - .and_then(|v| v.as_array()) - .map(|m| m.is_empty()) - .unwrap_or(false); - if remove_empty && composer_definition.contains_key(remove_key) { - composer_definition.shift_remove(remove_key); + Err(e) => { + if !self.dependency_resolution_completed.get() { + self.revert_composer_file(); + } + Err(e) } + }; + + // finally + if dry_run && self.newly_created.get() { + // @unlink($this->json->getPath()); + unlink(json.borrow().get_path()); } - let _ = json.borrow().write(PhpMixed::Array(composer_definition)); + signal_handler.unregister(); + + result } - fn update_file_cleanly( + fn interact( &self, - json: &std::rc::Rc>, - new: &IndexMap, - require_key: &str, - remove_key: &str, - sort_packages: bool, - ) -> bool { - let contents = file_get_contents(json.borrow().get_path()).unwrap_or_default(); - - let mut manipulator = match JsonManipulator::new(contents) { - Ok(m) => m, - Err(_) => return false, - }; + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) { + } - for (package, constraint) in new { - if !manipulator - .add_link(require_key, package, constraint, sort_packages) - .unwrap_or(false) - { - return false; - } - if !manipulator - .remove_sub_node(remove_key, package) - .unwrap_or(false) - { - return false; - } - } + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + base_command_initialize(self, input, output) + } - let _ = manipulator.remove_main_key_if_empty(remove_key); + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) + } - file_put_contents( - json.borrow().get_path(), - manipulator.get_contents().as_bytes(), - ); + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} - true +impl BaseCommand for RequireCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data } - fn revert_composer_file(&self) { - let json = self.json.borrow().as_ref().unwrap().clone(); - let lock = self.lock.borrow().clone(); - if self.newly_created.get() { - let msg = format!( - "\nInstallation failed, deleting {}.", - self.file.borrow() - ); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); - unlink(json.borrow().get_path()); - if file_exists(&lock) { - unlink(&lock); - } - } else { - let extra = if self.lock_backup.borrow().is_some() { - format!(" and {} to their ", lock) - } else { - " to its ".to_string() - }; - let msg = format!( - "\nInstallation failed, reverting {}{}original content.", - self.file.borrow(), - extra - ); - self.get_io().write_error3(&msg, true, io_interface::NORMAL); - file_put_contents( - json.borrow().get_path(), - self.composer_backup.borrow().as_bytes(), - ); - if let Some(ref lock_backup) = *self.lock_backup.borrow() { - file_put_contents(&lock, lock_backup.as_bytes()); - } - } - } + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } diff --git a/crates/shirabe/src/command/show_command.rs b/crates/shirabe/src/command/show_command.rs index fb663d86..44733531 100644 --- a/crates/shirabe/src/command/show_command.rs +++ b/crates/shirabe/src/command/show_command.rs @@ -81,2831 +81,2829 @@ impl ShowCommand { .expect("ShowCommand::configure uses static, valid metadata"); command } -} - -impl Command for ShowCommand { - fn configure(&self) -> anyhow::Result<()> { - self.set_name("show")?; - self.set_aliases(vec!["info".to_string()])?; - self.set_description("Shows information about packages"); - let opt_none = |name: &str, shortcut: Option<&str>, description: &str| { - InputOption::new( - name, - shortcut.map(|s| PhpMixed::String(s.to_string())), - Some(InputOption::VALUE_NONE), - description, - None, - ) - .unwrap() - .into() - }; - self.set_definition(&[ - InputArgument::new5( - "package", - Some(InputArgument::OPTIONAL), - "Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.", - None, - self.suggest_package_based_on_mode(), - ) - .unwrap() - .into(), - InputArgument::new( - "version", - Some(InputArgument::OPTIONAL), - "Version or version constraint to inspect", - None, - ) - .unwrap() - .into(), - opt_none("all", None, "List all packages"), - opt_none("locked", None, "List all locked packages"), - opt_none( - "installed", - Some("i"), - "List installed packages only (enabled by default, only present for BC).", - ), - opt_none("platform", Some("p"), "List platform packages only"), - opt_none("available", Some("a"), "List available packages only"), - opt_none("self", Some("s"), "Show the root package information"), - opt_none("name-only", Some("N"), "List package names only"), - opt_none("path", Some("P"), "Show package paths"), - opt_none("tree", Some("t"), "List the dependencies as a tree"), - opt_none("latest", Some("l"), "Show the latest version"), - opt_none( - "outdated", - Some("o"), - "Show the latest version but only for packages that are outdated", - ), - InputOption::new6( - "ignore", - None, - Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), - "Ignore specified package(s). Can contain wildcards (*). Use it with the --outdated option if you don't want to be informed about new versions of some packages.", - None, - self.suggest_installed_package(false, false), - ) - .unwrap() - .into(), - opt_none( - "major-only", - Some("M"), - "Show only packages that have major SemVer-compatible updates. Use with the --latest or --outdated option.", - ), - opt_none( - "minor-only", - Some("m"), - "Show only packages that have minor SemVer-compatible updates. Use with the --latest or --outdated option.", - ), - opt_none( - "patch-only", - None, - "Show only packages that have patch SemVer-compatible updates. Use with the --latest or --outdated option.", - ), - opt_none( - "sort-by-age", - Some("A"), - "Displays the installed version's age, and sorts packages oldest first. Use with the --latest or --outdated option.", - ), - opt_none( - "direct", - Some("D"), - "Shows only packages that are directly required by the root package", - ), - opt_none( - "strict", - None, - "Return a non-zero exit code when there are outdated packages", - ), - InputOption::new6( - "format", - Some(PhpMixed::String("f".to_string())), - Some(InputOption::VALUE_REQUIRED), - "Format of the output: text or json", - Some(PhpMixed::String("text".to_string())), - SuggestedValues::List(vec!["json".to_string(), "text".to_string()]), - ) - .unwrap() - .into(), - opt_none("no-dev", None, "Disables search in require-dev packages."), - InputOption::new( - "ignore-platform-req", - None, - Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), - "Ignore a specific platform requirement (php & ext- packages). Use with the --outdated option", - None, - ) - .unwrap() - .into(), - opt_none( - "ignore-platform-reqs", - None, - "Ignore all platform requirements (php & ext- packages). Use with the --outdated option", - ), - ]); - self.set_help( - "The show command displays detailed information about a package, or\n\ - lists all packages available.\n\n\ - Read more at https://getcomposer.org/doc/03-cli.md#show-info", - ); - Ok(()) - } - - fn execute( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result { - *self.version_parser.borrow_mut() = VersionParser::new(); - if input.borrow().get_option("tree")?.as_bool() == Some(true) { - self.init_styles(output.clone()); - } - - let composer = self.try_composer(None, None); - - if input.borrow().get_option("installed")?.as_bool() == Some(true) - && input.borrow().get_option("self")?.as_bool() != Some(true) - { - self.get_io().write_error("You are using the deprecated option \"installed\". Only installed packages are shown by default now. The --all option can be used to show all packages."); - } - - if input.borrow().get_option("outdated")?.as_bool() == Some(true) { - input - .borrow_mut() - .set_option("latest", PhpMixed::Bool(true)); - } else if input - .borrow() - .get_option("ignore")? - .as_list() - .map_or(0, |l| l.len()) - > 0 - { - self.get_io().write_error("You are using the option \"ignore\" for action other than \"outdated\", it will be ignored."); - } - if input.borrow().get_option("direct")?.as_bool() == Some(true) - && (input.borrow().get_option("all")?.as_bool() == Some(true) - || input.borrow().get_option("available")?.as_bool() == Some(true) - || input.borrow().get_option("platform")?.as_bool() == Some(true)) - { - self.get_io().write_error("The --direct (-D) option is not usable in combination with --all, --platform (-p) or --available (-a)"); - - return Ok(1); - } + /// PHP: protected function suggestPackageBasedOnMode(): \Closure + pub(crate) fn suggest_package_based_on_mode(&self) -> crate::console::input::SuggestedValues { + crate::console::input::SuggestedValues::Closure(Box::new(|this, input, suggestions| { + if input.get_option("available")?.to_bool() || input.get_option("all")?.to_bool() { + return this.suggest_available_package_incl_platform().call( + this, + input, + suggestions, + ); + } - if input.borrow().get_option("tree")?.as_bool() == Some(true) - && (input.borrow().get_option("all")?.as_bool() == Some(true) - || input.borrow().get_option("available")?.as_bool() == Some(true)) - { - self.get_io().write_error("The --tree (-t) option is not usable in combination with --all or --available (-a)"); + if input.get_option("platform")?.to_bool() { + return this + .suggest_platform_package() + .call(this, input, suggestions); + } - return Ok(1); - } + this.suggest_installed_package(false, false) + .call(this, input, suggestions) + })) + } - let only_count: usize = [ - input.borrow().get_option("patch-only")?.as_bool() == Some(true), - input.borrow().get_option("minor-only")?.as_bool() == Some(true), - input.borrow().get_option("major-only")?.as_bool() == Some(true), - ] - .iter() - .filter(|b| **b) - .count(); - if only_count > 1 { - self.get_io().write_error( - "Only one of --major-only, --minor-only or --patch-only can be used at once", - ); + #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] + fn print_packages( + &self, + packages: &[IndexMap], + indent: &str, + write_version: bool, + write_latest: bool, + write_description: bool, + width: usize, + version_length: usize, + name_length: usize, + latest_length: usize, + write_release_date: bool, + release_date_length: usize, + ) { + let io = self.get_io(); + let pad_name = write_version || write_latest || write_release_date || write_description; + let pad_version = write_latest || write_release_date || write_description; + let pad_latest = write_description || write_release_date; + let pad_release_date = write_description; + for package in packages.iter() { + let link = package + .get("source") + .and_then(|v| v.as_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| { + package + .get("homepage") + .and_then(|v| v.as_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + let name = package + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + if !link.is_empty() { + let pad = if pad_name && name_length > name.len() { + name_length - name.len() + } else { + 0 + }; + io.write_no_newline(&format!( + "{}{}{}", + indent, + OutputFormatter::escape(&link).expect("OutputFormatter::escape failed"), + name, + " ".repeat(pad) + )); + } else { + let width_pad = if pad_name { name_length } else { 0 }; + io.write_no_newline(&format!("{}{:{:", + style, + latest_version, + style, + width = width_pad + )); + if write_release_date + && let Some(age) = package.get("release-age").and_then(|v| v.as_string()) + { + let width_pad = if pad_release_date { + release_date_length + } else { + 0 + }; + io.write_no_newline(&format!(" {: remaining as usize { + description = format!( + "{}...", + description + .chars() + .take((remaining as usize).saturating_sub(3)) + .collect::() + ); + } + } else { + // Fallback when mbstring is not available: do a conservative byte-based cut. + // Ensure cut length is non-negative and leave room for the ellipsis. + let cut = (remaining - 3).max(0) as usize; + if description.len() > cut { + description = format!("{}...", &description[..cut]); + } + } - return Ok(1); + io.write_no_newline(&format!(" {}", description)); + } + if package.contains_key("path") { + let path_str = match package.get("path") { + Some(PhpMixed::String(s)) => s.clone(), + _ => "null".to_string(), + }; + io.write_no_newline(&format!(" {}", path_str)); + } + io.write(""); + if let Some(warning) = package.get("warning").and_then(|v| v.as_string()) { + io.write(&format!("{}", warning)); + } } + } - if input.borrow().get_option("tree")?.as_bool() == Some(true) - && input.borrow().get_option("path")?.as_bool() == Some(true) - { - self.get_io().write_error( - "The --tree (-t) option is not usable in combination with --path (-P)", - ); - - return Ok(1); - } + pub(crate) fn get_root_requires(&self) -> Vec { + let composer_rc = self.try_composer(None, None); + let composer_rc = match composer_rc { + None => return vec![], + Some(c) => c, + }; + let composer = crate::composer::composer_full(&composer_rc); - let format = input - .borrow() - .get_option("format")? - .as_string() - .unwrap_or("text") - .to_string(); - if !in_array_loose( - format.clone(), - &[ - PhpMixed::String("text".to_string()), - PhpMixed::String("json".to_string()), - ], - ) { - self.get_io().write_error(&format!( - "Unsupported format \"{}\". See help for supported formats.", - format - )); + let root_package = composer.get_package(); - return Ok(1); + let mut combined: IndexMap = IndexMap::new(); + for (k, v) in root_package.get_requires().iter() { + combined.insert(k.clone(), v.clone()); } - - let platform_req_filter = self.get_platform_requirement_filter(input.clone())?; - - // init repos - let mut platform_overrides: IndexMap = IndexMap::new(); - if let Some(ref composer) = composer { - let composer = crate::composer::composer_full(composer); - if let Some(p) = composer - .get_config() - .borrow() - .get("platform") - .as_array() - .cloned() - { - platform_overrides = p.into_iter().collect(); - } + for (k, v) in root_package.get_dev_requires().iter() { + combined.insert(k.clone(), v.clone()); } - let platform_repo = - PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides)?); - let mut locked_repo: Option = None; - - // The single-package $package binding from PHP gets surfaced here. - let mut single_package: Option = None; - let mut versions_map: IndexMap = IndexMap::new(); - let installed_repo: RepositoryInterfaceHandle; - let repos: RepositoryInterfaceHandle; - - if input.borrow().get_option("self")?.as_bool() == Some(true) - && input.borrow().get_option("installed")?.as_bool() != Some(true) - && input.borrow().get_option("locked")?.as_bool() != Some(true) - { - let composer = self.require_composer(None, None)?; - let package = crate::package::RootPackageInterfaceHandle::dup( - composer.borrow_partial().get_package(), - ); - if input.borrow().get_option("name-only")?.as_bool() == Some(true) { - self.get_io().write(&package.get_name()); - - return Ok(0); - } - if input - .borrow() - .get_argument("package")? - .as_string() - .is_some() - { - return Err(InvalidArgumentException { - message: "You cannot use --self together with a package name".to_string(), - code: 0, - } - .into()); - } - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - RepositoryInterfaceHandle::new(RootPackageRepository::new(package.clone())), - ])); - repos = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - RepositoryInterfaceHandle::new(RootPackageRepository::new(package.clone())), - ])); - single_package = Some(package.into()); - } else if input.borrow().get_option("platform")?.as_bool() == Some(true) { - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - platform_repo.clone().into(), - ])); - repos = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - platform_repo.clone().into(), - ])); - } else if input.borrow().get_option("available")?.as_bool() == Some(true) { - let mut ir = InstalledRepository::new(vec![platform_repo.clone().into()]); - if let Some(ref composer) = composer { - let composer = crate::composer::composer_full(composer); - repos = RepositoryInterfaceHandle::new(CompositeRepository::new( - composer - .get_repository_manager() - .borrow() - .get_repositories() - .to_vec(), - )); - ir.add_repository( - composer - .get_repository_manager() - .borrow() - .get_local_repository(), - ); - installed_repo = RepositoryInterfaceHandle::new(ir); - } else { - let default_repos = - RepositoryFactory::default_repos_with_default_manager(self.get_io())?; - let names: Vec = default_repos.keys().cloned().collect(); - repos = RepositoryInterfaceHandle::new(CompositeRepository::new( - default_repos.into_values().collect(), - )); - self.get_io().write_error(&format!( - "No composer.json found in the current directory, showing available packages from {}", - names.join(", ") - )); - installed_repo = RepositoryInterfaceHandle::new(ir); - } - } else if input.borrow().get_option("all")?.as_bool() == Some(true) && composer.is_some() { - let composer_ref = crate::composer::composer_full(composer.as_ref().unwrap()); - let local_repo = composer_ref - .get_repository_manager() - .borrow() - .get_local_repository(); - let locker_rc = composer_ref.get_locker().clone(); - let mut locker = locker_rc.borrow_mut(); - if locker.is_locked() { - let lr_handle: RepositoryInterfaceHandle = - locker.get_locked_repository(true)?.into(); - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - lr_handle.clone(), - local_repo, - platform_repo.clone().into(), - ])); - locked_repo = Some(lr_handle); - } else { - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - local_repo, - platform_repo.clone().into(), - ])); - } - let mut composite_input: Vec = - vec![RepositoryInterfaceHandle::new(FilterRepository::new( - installed_repo.clone(), - { - let mut m = IndexMap::new(); - m.insert("canonical".to_string(), PhpMixed::Bool(false)); - m - }, - )?)]; - for r in composer_ref - .get_repository_manager() - .borrow() - .get_repositories() + combined.keys().map(|k| strtolower(k)).collect() + } + + pub(crate) fn get_version_style( + &self, + latest_package: PackageInterfaceHandle, + package: PackageInterfaceHandle, + ) -> anyhow::Result { + Ok( + Self::update_status_to_version_style(&Self::get_update_status( + latest_package, + package, + )?) + .to_string(), + ) + } + + /// finds a package by name and version if provided + pub(crate) fn get_package( + &self, + installed_repo: &RepositoryInterfaceHandle, + repos: &RepositoryInterfaceHandle, + name: &str, + version: PhpMixed, + ) -> anyhow::Result<( + Option, + IndexMap, + )> { + let name = strtolower(name); + let constraint: Option = match &version { + PhpMixed::String(s) => Some(self.version_parser.borrow().parse_constraints(s)?), + PhpMixed::Null => None, + _ => None, // already a ConstraintInterface + }; + + let policy = DefaultPolicy::new(false, false, None); + let mut repository_set = RepositorySet::new( + "dev", + IndexMap::new(), + Vec::new(), + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ); + repository_set.allow_installed_repositories(true); + repository_set.add_repository(repos.clone())?; + + let mut matched_package: Option = None; + let mut versions: IndexMap = IndexMap::new(); + let mut pool = if PlatformRepository::is_platform_package(&name) { + repository_set.create_pool_with_all_packages()? + } else { + repository_set.create_pool_for_package(&name, None)? + }; + let matches = pool.what_provides(&name, constraint.as_ref()); + let mut literals: Vec = Vec::new(); + for package in matches.iter() { + // avoid showing the 9999999-dev alias if the default branch has no branch-alias set + let mut p: crate::package::PackageInterfaceHandle = package.clone(); + if let Some(alias) = p.as_alias() + && p.get_version() == VersionParser::DEFAULT_BRANCH_ALIAS { - composite_input.push(r.clone()); + p = alias.get_alias_of().into(); } - repos = RepositoryInterfaceHandle::new(CompositeRepository::new(composite_input)); - } else if input.borrow().get_option("all")?.as_bool() == Some(true) { - let default_repos = - RepositoryFactory::default_repos_with_default_manager(self.get_io())?; - let names: Vec = default_repos.keys().cloned().collect(); - self.get_io().write_error(&format!( - "No composer.json found in the current directory, showing available packages from {}", - names.join(", ") - )); - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - platform_repo.clone().into(), - ])); - let mut composite_input: Vec = vec![installed_repo.clone()]; - for (_k, v) in default_repos.into_iter() { - composite_input.push(v); + + // select an exact match if it is in the installed repo and no specific version was required + if version.is_null() && installed_repo.has_package(p.clone())? { + matched_package = Some(p.clone()); } - repos = RepositoryInterfaceHandle::new(CompositeRepository::new(composite_input)); - } else if input.borrow().get_option("locked")?.as_bool() == Some(true) { - if composer.is_none() - || !crate::composer::composer_full(composer.as_ref().unwrap()) - .get_locker() - .borrow_mut() - .is_locked() - { - return Err(UnexpectedValueException { - message: "A valid composer.json and composer.lock files is required to run this command with --locked".to_string(), + + versions.insert(p.get_pretty_version(), p.get_version()); + literals.push(p.get_id()); + } + + // select preferred package according to policy rules + if matched_package.is_none() && !literals.is_empty() { + let preferred = policy.select_preferred_packages(&pool, literals.clone(), None); + matched_package = Some(pool.literal_to_package(preferred[0])); + } + + if let Some(ref mp) = matched_package + && mp.as_complete().is_none() + { + return Err(LogicException { + message: format!( + "ShowCommand::getPackage can only work with CompletePackageInterface, but got {}", + shirabe_php_shim::get_class(&PhpMixed::Null) + ), code: 0, } .into()); + } + + let matched_package = matched_package.and_then(|mp| mp.as_complete()); + Ok((matched_package, versions)) + } + + /// Prints package info. + pub(crate) fn print_package_info( + &self, + package: CompletePackageInterfaceHandle, + versions: &IndexMap, + installed_repo: &mut dyn RepositoryInterface, + latest_package: Option, + ) -> anyhow::Result<()> { + self.print_meta(package.clone(), versions, installed_repo, latest_package)?; + self.print_links(package.clone(), Link::TYPE_REQUIRE, None); + self.print_links( + package.clone(), + Link::TYPE_DEV_REQUIRE, + Some("requires (dev)"), + ); + + if !package.get_suggests().is_empty() { + self.get_io().write("\nsuggests"); + for (suggested, reason) in package.get_suggests().iter() { + self.get_io() + .write(&format!("{} {}", suggested, reason)); } - let composer_ref = crate::composer::composer_full(composer.as_ref().unwrap()); - let locker_rc = composer_ref.get_locker().clone(); - let mut locker = locker_rc.borrow_mut(); - let lr = locker.get_locked_repository( - input.borrow().get_option("no-dev")?.as_bool() != Some(true), - )?; - if input.borrow().get_option("self")?.as_bool() == Some(true) { - lr.add_package( - crate::package::RootPackageInterfaceHandle::dup(composer_ref.get_package()) - .into(), - )?; - } - let lr_handle: RepositoryInterfaceHandle = lr.into(); - locked_repo = Some(lr_handle.clone()); - let new_repo = - RepositoryInterfaceHandle::new(InstalledRepository::new(vec![lr_handle])); - installed_repo = new_repo.clone(); - repos = new_repo; - } else { - // --installed / default case - let composer_local_owned; - let _guard_from_existing; - let composer_local = match composer.as_ref() { - Some(c) => { - _guard_from_existing = crate::composer::composer_full(c); - &*_guard_from_existing - } - None => { - composer_local_owned = self.require_composer(None, None)?; - _guard_from_existing = crate::composer::composer_full(&composer_local_owned); - &*_guard_from_existing + } + + self.print_links(package.clone(), Link::TYPE_PROVIDE, None); + self.print_links(package.clone(), Link::TYPE_CONFLICT, None); + self.print_links(package, Link::TYPE_REPLACE, None); + Ok(()) + } + + /// Prints package metadata. + pub(crate) fn print_meta( + &self, + package: CompletePackageInterfaceHandle, + versions: &IndexMap, + installed_repo: &mut dyn RepositoryInterface, + latest_package: Option, + ) -> anyhow::Result<()> { + let is_installed_package = !PlatformRepository::is_platform_package(&package.get_name()) + && installed_repo.has_package(package.clone().into())?; + + self.get_io().write(&format!( + "name : {}", + package.get_pretty_name() + )); + self.get_io().write(&format!( + "descrip. : {}", + package.get_description().unwrap_or_default() + )); + let keywords = package.get_keywords(); + self.get_io() + .write(&format!("keywords : {}", keywords.join(", "))); + self.print_versions(package.clone(), versions, installed_repo)?; + if is_installed_package && let Some(rd) = package.get_release_date() { + let rel = self.get_relative_time(&rd); + self.get_io().write(&format!( + "released : {}, {}", + rd.format(date_format_to_strftime("Y-m-d")), + rel + )); + } + let latest: PackageInterfaceHandle = if let Some(latest) = latest_package { + let style = self.get_version_style(latest.clone(), package.clone().into())?; + let released_time = match latest.get_release_date() { + None => String::new(), + Some(rd) => { + let rel = self.get_relative_time(&rd); + format!( + " released {}, {}", + rd.format(date_format_to_strftime("Y-m-d")), + rel + ) } }; - let root_pkg = composer_local.get_package(); + self.get_io().write(&format!( + "latest : <{}>{}{}", + style, + latest.get_pretty_version(), + style, + released_time + )); + latest + } else { + package.clone().into() + }; + self.get_io() + .write(&format!("type : {}", package.get_type())); + self.print_licenses(package.clone()); + self.get_io().write(&format!( + "homepage : {}", + package.get_homepage().unwrap_or_default() + )); + self.get_io().write(&format!( + "source : [{}] {} {}", + package.get_source_type().unwrap_or_default(), + package.get_source_url().unwrap_or_default(), + package.get_source_reference().unwrap_or_default() + )); + self.get_io().write(&format!( + "dist : [{}] {} {}", + package.get_dist_type().unwrap_or_default(), + package.get_dist_url().unwrap_or_default(), + package.get_dist_reference().unwrap_or_default() + )); + if is_installed_package { + let path: Option = self.require_composer(None, None).ok().and_then(|c| { + let installation_manager = c.borrow_partial().get_installation_manager(); + + installation_manager + .borrow_mut() + .get_install_path(package.clone().into()) + }); + if let Some(p) = path { + self.get_io().write(&format!( + "path : {}", + realpath(&p).unwrap_or_default() + )); + } else { + self.get_io().write("path : null"); + } + } + self.get_io().write(&format!( + "names : {}", + package.get_names(true).join(", ") + )); + + if let Some(c) = latest.as_complete() + && c.is_abandoned() + { + let replacement = match c.get_replacement_package() { + Some(rp) => format!(" The author suggests using the {} package instead.", rp), + None => String::new(), + }; - let root_repo: RepositoryInterfaceHandle = - if input.borrow().get_option("self")?.as_bool() == Some(true) { - RepositoryInterfaceHandle::new(RootPackageRepository::new( - crate::package::RootPackageInterfaceHandle::dup( - composer_local.get_package(), - ), - )) - } else { - RepositoryInterfaceHandle::new(InstalledArrayRepository::new()?) - }; - if input.borrow().get_option("no-dev")?.as_bool() == Some(true) { - let local_packages = composer_local - .get_repository_manager() - .borrow() - .get_local_repository() - .get_packages()?; - let packages = RepositoryUtils::filter_required_packages( - &local_packages, - root_pkg.clone().into(), - false, - Vec::new(), - ); - let cloned: Vec = packages - .iter() - .map(crate::package::PackageInterfaceHandle::dup) - .collect(); - let new_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - root_repo, - RepositoryInterfaceHandle::new(InstalledArrayRepository::new_with_packages( - cloned, - )?), - ])); - installed_repo = new_repo.clone(); - repos = new_repo; - } else { - let repository_manager = composer_local.get_repository_manager().clone(); - let repository_manager = repository_manager.borrow(); - let lr = repository_manager.get_local_repository(); - installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ - root_repo.clone(), - lr.clone(), - ])); - repos = - RepositoryInterfaceHandle::new(InstalledRepository::new(vec![root_repo, lr])); + self.get_io().write_error(&format!( + "Attention: This package is abandoned and no longer maintained.{}", + replacement + )); + } + + let support = package.get_support(); + if !support.is_empty() { + self.get_io().write("\nsupport"); + for (r#type, value) in support.iter() { + self.get_io() + .write(&format!("{} : {}", r#type, value)); } + } - if installed_repo.get_packages()?.is_empty() { - let has_non_platform_reqs = |reqs: &IndexMap| -> bool { - reqs.keys() - .any(|name| !PlatformRepository::is_platform_package(name)) - }; + let autoload_config = package.get_autoload(); + if !autoload_config.is_empty() { + self.get_io().write("\nautoload"); + for (r#type, autoloads) in autoload_config.iter() { + self.get_io() + .write(&format!("{}", r#type)); - if has_non_platform_reqs(&root_pkg.get_requires()) - || has_non_platform_reqs(&root_pkg.get_dev_requires()) + if r#type == "psr-0" || r#type == "psr-4" { + if let PhpMixed::Array(map) = autoloads { + for (name, path) in map.iter() { + let path_str = match path { + PhpMixed::List(l) => l + .iter() + .filter_map(|p| p.as_string().map(|s| s.to_string())) + .collect::>() + .join(", "), + PhpMixed::String(s) if !s.is_empty() => s.clone(), + _ => ".".to_string(), + }; + let name_disp = if name.is_empty() { "*" } else { name }; + self.get_io() + .write(&format!("{} => {}", name_disp, path_str)); + } + } + } else if r#type == "classmap" + && let PhpMixed::List(l) = autoloads { - // Borrow is local; release composer_local borrow first. - let _ = root_pkg; - self.get_io().write_error("No dependencies installed. Try running composer install or update."); + let joined: Vec = l + .iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect(); + self.get_io().write(&joined.join(", ")); } } + let include_paths = package.get_include_paths(); + if !include_paths.is_empty() { + self.get_io().write("include-path"); + self.get_io().write(&include_paths.join(", ")); + } } - if let Some(ref composer) = composer { - let composer = crate::composer::composer_full(composer); - let mut command_event = CommandEvent::new6( - PluginEvents::COMMAND, - "show", - input.clone(), - output, - vec![], - IndexMap::new(), - ); - let command_event_name = command_event.get_name().to_string(); - composer - .get_event_dispatcher() - .borrow_mut() - .dispatch(Some(&command_event_name), Some(&mut command_event))?; - } + Ok(()) + } - if input.borrow().get_option("latest")?.as_bool() == Some(true) && composer.is_none() { - self.get_io().write_error( - "No composer.json found in the current directory, disabling \"latest\" option", - ); - input - .borrow_mut() - .set_option("latest", PhpMixed::Bool(false)); + /// Prints all available versions of this package and highlights the installed one if any. + pub(crate) fn print_versions( + &self, + package: CompletePackageInterfaceHandle, + versions: &IndexMap, + installed_repo: &mut dyn RepositoryInterface, + ) -> anyhow::Result<()> { + let mut versions_keys: Vec = versions.keys().cloned().collect(); + versions_keys = Semver::rsort(versions_keys)?; + + // highlight installed version + let installed_packages = installed_repo.find_packages(&package.get_name(), None)?; + if !installed_packages.is_empty() { + for installed_package in installed_packages.iter() { + let installed_version = installed_package.get_pretty_version(); + let key_map: IndexMap = versions_keys + .iter() + .map(|v| (v.clone(), v.clone())) + .collect(); + if let Some(found) = array_search(&installed_version, &key_map) + && let Some(idx) = versions_keys.iter().position(|v| v == &found) + { + versions_keys[idx] = format!("* {}", installed_version); + } + } } - let package_filter: Option = input - .borrow() - .get_argument("package")? - .as_string() - .map(|s| s.to_string()); + let versions_str = versions_keys.join(", "); - // show single package or single version - if let Some(ref pkg) = single_package { - versions_map.insert(pkg.get_pretty_version(), pkg.get_version()); - } else if let Some(ref pf) = package_filter - && !pf.contains('*') - { - let (matched_package, vers) = self.get_package( - &installed_repo, - &repos, - pf, - input.borrow().get_argument("version")?, - )?; + self.get_io() + .write(&format!("versions : {}", versions_str)); - if let Some(ref pkg) = matched_package - && input.borrow().get_option("direct")?.as_bool() == Some(true) - && !in_array_strict( - pkg.get_name(), - &self - .get_root_requires() - .into_iter() - .map(PhpMixed::String) - .collect::>(), - ) - { - return Err(InvalidArgumentException { - message: format!( - "Package \"{}\" is installed but not a direct dependent of the root package.", - pkg.get_name() - ), - code: 0, - } - .into()); - } + Ok(()) + } - if matched_package.is_none() { - let options = input.borrow().get_options(); - let mut hint = String::new(); - if input.borrow().get_option("locked")?.as_bool() == Some(true) { - hint.push_str(" in lock file"); - } - if let Some(working_dir) = options.get("working-dir").filter(|v| !v.is_null()) { - hint.push_str(&format!( - " in {}/composer.json", - working_dir.as_string().unwrap_or("") - )); - } - if PlatformRepository::is_platform_package(pf) - && input.borrow().get_option("platform")?.as_bool() != Some(true) - { - hint.push_str(", try using --platform (-p) to show platform packages"); - } - if input.borrow().get_option("all")?.as_bool() != Some(true) - && input.borrow().get_option("available")?.as_bool() != Some(true) - { - hint.push_str(", try using --available (-a) to show all available packages"); - } + /// print link objects + pub(crate) fn print_links( + &self, + package: CompletePackageInterfaceHandle, + link_type: &str, + title: Option<&str>, + ) { + let title = title.unwrap_or(link_type); + let io = self.get_io(); + let links = package.get_links_for_type(link_type); + if !links.is_empty() { + io.write(&format!("\n{}", title)); - return Err(InvalidArgumentException { - message: format!("Package \"{}\" not found{}.", pf, hint), - code: 0, - } - .into()); + for link in links.iter() { + io.write(&format!( + "{} {}", + link.1.get_target(), + link.1.get_pretty_constraint(), + )); } - single_package = matched_package; - versions_map = vers; } + } - if let Some(ref package) = single_package { - // assert(isset($versions)); + /// Prints the licenses of a package with metadata + pub(crate) fn print_licenses(&self, package: CompletePackageInterfaceHandle) { + let spdx_licenses = SpdxLicenses::new(); - let mut exit_code: i64 = 0; - if input.borrow().get_option("tree")?.as_bool() == Some(true) { - let array_tree = - self.generate_package_tree(package.clone().into(), &installed_repo, &repos); + let licenses = package.get_license(); + let io = self.get_io(); - if format == "json" { - let mut wrapper: IndexMap = IndexMap::new(); - wrapper.insert( - "installed".to_string(), - PhpMixed::List(vec![PhpMixed::Array(array_tree.into_iter().collect())]), - ); - self.get_io().write(&JsonFile::encode(&PhpMixed::Array( - wrapper.into_iter().collect(), - ))?); - } else { - self.display_package_tree(vec![array_tree]); + for license_id in licenses.iter() { + let license = spdx_licenses.get_license_by_identifier(license_id); + + let out = match license { + None => license_id.clone(), + Some(license) => { + if license.is_osi_approved { + format!( + "{} ({}) (OSI approved) {}", + license.name, license_id, license.url + ) + } else { + format!("{} ({}) {}", license.name, license_id, license.url) + } } + }; - return Ok(exit_code); - } + io.write(&format!("license : {}", out)); + } + } - let mut latest_package: Option = None; - if input.borrow().get_option("latest")?.as_bool() == Some(true) { - latest_package = self.find_latest_package( - package.clone().into(), - composer.as_ref().unwrap(), - &platform_repo, - input - .borrow() - .get_option("major-only")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("minor-only")? - .as_bool() - .unwrap_or(false), - input - .borrow() - .get_option("patch-only")? - .as_bool() - .unwrap_or(false), - platform_req_filter.clone(), - )?; - } - if input.borrow().get_option("outdated")?.as_bool() == Some(true) - && input.borrow().get_option("strict")?.as_bool() == Some(true) - && latest_package.is_some() - && latest_package - .as_ref() - .unwrap() - .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) - != package - .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) - && (latest_package - .as_ref() - .unwrap() - .as_complete() - .is_none_or(|c| !c.is_abandoned())) - { - exit_code = 1; - } - if input.borrow().get_option("path")?.as_bool() == Some(true) { - self.get_io().write_no_newline(&package.get_name()); - let path = { - let composer_ref = composer.as_ref().unwrap(); - composer_ref - .borrow_partial() - .get_installation_manager() - .borrow_mut() - .get_install_path(package.clone().into()) - }; - if let Some(path) = path { - let real = realpath(&path).unwrap_or_default(); - let trimmed = real.split(['\r', '\n']).next().unwrap_or(""); - self.get_io().write(&format!(" {}", trimmed)); - } else { - self.get_io().write(" null"); - } + /// Prints package info in JSON format. + pub(crate) fn print_package_info_as_json( + &self, + package: CompletePackageInterfaceHandle, + versions: &IndexMap, + installed_repo: &mut dyn RepositoryInterface, + latest_package: Option, + ) -> anyhow::Result<()> { + let mut json: IndexMap = IndexMap::new(); + json.insert( + "name".to_string(), + PhpMixed::String(package.get_pretty_name()), + ); + json.insert( + "description".to_string(), + PhpMixed::String(package.get_description().unwrap_or_default()), + ); + let keywords: Vec = package + .get_keywords() + .into_iter() + .map(PhpMixed::String) + .collect(); + json.insert("keywords".to_string(), PhpMixed::List(keywords)); + json.insert("type".to_string(), PhpMixed::String(package.get_type())); + json.insert( + "homepage".to_string(), + match package.get_homepage() { + Some(h) => PhpMixed::String(h), + None => PhpMixed::Null, + }, + ); + json.insert( + "names".to_string(), + PhpMixed::List( + package + .get_names(true) + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + ); - return Ok(exit_code); - } + json = Self::append_versions(json, versions); + json = Self::append_licenses(json, package.clone()); - if format == "json" { - self.print_package_info_as_json( - package.clone(), - &versions_map, - &mut *installed_repo.borrow_mut(), - latest_package, - )?; - } else { - self.print_package_info( - package.clone(), - &versions_map, - &mut *installed_repo.borrow_mut(), - latest_package, - )?; - } + let latest: PackageInterfaceHandle = if let Some(latest) = latest_package { + json.insert( + "latest".to_string(), + PhpMixed::String(latest.get_pretty_version()), + ); + latest + } else { + package.clone().into() + }; - return Ok(exit_code); + if package.get_source_type().is_some() { + let mut src: IndexMap = IndexMap::new(); + src.insert( + "type".to_string(), + PhpMixed::String(package.get_source_type().unwrap_or_default()), + ); + src.insert( + "url".to_string(), + PhpMixed::String(package.get_source_url().unwrap_or_default()), + ); + src.insert( + "reference".to_string(), + PhpMixed::String(package.get_source_reference().unwrap_or_default()), + ); + json.insert( + "source".to_string(), + PhpMixed::Array(src.into_iter().collect()), + ); } - // show tree view if requested - if input.borrow().get_option("tree")?.as_bool() == Some(true) { - let root_requires = self.get_root_requires(); - let mut packages = installed_repo.get_packages()?; - packages.sort_by(|a, b| { - let sa: String = a.to_string(); - let sb: String = b.to_string(); - sa.cmp(&sb) - }); - let mut array_tree: Vec> = Vec::new(); - for package in packages.iter() { - if in_array_strict( - package.get_name(), - &root_requires - .iter() - .map(|s| PhpMixed::String(s.clone())) - .collect::>(), - ) { - array_tree.push(self.generate_package_tree( - package.clone(), - &installed_repo, - &repos, - )); + if package.get_dist_type().is_some() { + let mut dst: IndexMap = IndexMap::new(); + dst.insert( + "type".to_string(), + PhpMixed::String(package.get_dist_type().unwrap_or_default()), + ); + dst.insert( + "url".to_string(), + PhpMixed::String(package.get_dist_url().unwrap_or_default()), + ); + dst.insert( + "reference".to_string(), + PhpMixed::String(package.get_dist_reference().unwrap_or_default()), + ); + json.insert( + "dist".to_string(), + PhpMixed::Array(dst.into_iter().collect()), + ); + } + + if !PlatformRepository::is_platform_package(&package.get_name()) + && installed_repo.has_package(package.clone().into())? + { + let composer = self.require_composer(None, None)?; + let installation_manager = composer.borrow_partial().get_installation_manager(); + let path: Option = installation_manager + .borrow_mut() + .get_install_path(package.clone().into()); + match path { + Some(p) => { + if let Some(r) = realpath(&p) { + json.insert("path".to_string(), PhpMixed::String(r)); + } + } + None => { + json.insert("path".to_string(), PhpMixed::Null); } } - if format == "json" { - let mut wrapper: IndexMap = IndexMap::new(); - wrapper.insert( - "installed".to_string(), - PhpMixed::List( - array_tree - .into_iter() - .map(|m| PhpMixed::Array(m.into_iter().collect())) - .collect(), - ), + if let Some(rd) = package.get_release_date() { + json.insert( + "released".to_string(), + PhpMixed::String(rd.format(DATE_ATOM).to_string()), ); - self.get_io().write(&JsonFile::encode(&PhpMixed::Array( - wrapper.into_iter().collect(), - ))?); - } else { - self.display_package_tree(array_tree); } - - return Ok(0); - } - - // list packages - let mut packages: IndexMap> = IndexMap::new(); - let mut package_filter_regex: Option = None; - if let Some(ref pf) = package_filter { - let escaped = shirabe_php_shim::preg_quote(pf, None); - package_filter_regex = Some(format!("{{^{}$}}i", escaped.replace("\\*", ".*?"))); } - let mut package_list_filter: Option> = None; - if input.borrow().get_option("direct")?.as_bool() == Some(true) { - package_list_filter = Some(self.get_root_requires()); + if let Some(c) = latest.as_complete() + && c.is_abandoned() + { + json.insert( + "replacement".to_string(), + match c.get_replacement_package() { + Some(rp) => PhpMixed::String(rp), + None => PhpMixed::Null, + }, + ); } - if input.borrow().get_option("path")?.as_bool() == Some(true) && composer.is_none() { - self.get_io().write_error( - "No composer.json found in the current directory, disabling \"path\" option", + if !package.get_suggests().is_empty() { + let mut s: IndexMap = IndexMap::new(); + for (k, v) in package.get_suggests().iter() { + s.insert(k.clone(), PhpMixed::String(v.clone())); + } + json.insert( + "suggests".to_string(), + PhpMixed::Array(s.into_iter().collect()), ); - input.borrow_mut().set_option("path", PhpMixed::Bool(false)); } - for repo in RepositoryUtils::flatten_repositories(repos, true) { - let r#type = if Self::same_repository(&repo, &platform_repo) { - "platform" - } else if locked_repo - .as_ref() - .is_some_and(|lr| Self::same_repository(&repo, lr)) - { - "locked" - } else if Self::same_repository(&repo, &installed_repo) - || installed_repo - .borrow() - .as_any() - .downcast_ref::() - .is_some_and(|ir| ir.get_repositories().iter().any(|r| r.ptr_eq(&repo))) - { - "installed" - } else { - "available" - }; - let type_owned = r#type.to_string(); - if let Some(cr_rc) = repo.downcast_rc::() { - let names = cr_rc - .borrow_mut() - .get_package_names(package_filter.as_deref())?; - for name in names { - packages - .entry(type_owned.clone()) - .or_default() - .insert(name.clone(), PackageOrName::Name(name)); - } - } else { - for package in repo.get_packages()? { - let existing = packages - .get(&type_owned) - .and_then(|m| m.get(&package.get_name())); - let need_replace = match existing { - None => true, - Some(PackageOrName::Name(_)) => true, - Some(PackageOrName::Pkg(existing)) => { - version_compare(&existing.get_version(), &package.get_version(), "<") - } - }; - if need_replace { - let mut p: crate::package::PackageInterfaceHandle = package.clone(); - while let Some(alias) = p.as_alias() { - p = alias.get_alias_of().into(); - } - let matches_filter = match &package_filter_regex { - None => true, - Some(r) => Preg::is_match(r, &p.get_name()), - }; - if matches_filter { - let matches_list = match &package_list_filter { - None => true, - Some(list) => in_array_strict( - p.get_name(), - &list - .iter() - .map(|s| PhpMixed::String(s.clone())) - .collect::>(), - ), - }; - if matches_list { - packages - .entry(type_owned.clone()) - .or_default() - .insert(p.get_name(), PackageOrName::Pkg(p)); - } - } - } - } - if Self::same_repository(&repo, &platform_repo) { - for (name, p) in platform_repo.borrow().get_disabled_packages() { - packages - .entry(type_owned.clone()) - .or_default() - .insert(name.clone(), PackageOrName::Pkg(p.clone().into())); - } - } + if !package.get_support().is_empty() { + let mut s: IndexMap = IndexMap::new(); + for (k, v) in package.get_support().iter() { + s.insert(k.clone(), PhpMixed::String(v.clone())); } + json.insert( + "support".to_string(), + PhpMixed::Array(s.into_iter().collect()), + ); } - let show_all_types = input.borrow().get_option("all")?.as_bool() == Some(true); - let show_latest = input.borrow().get_option("latest")?.as_bool() == Some(true); - let show_major_only = input.borrow().get_option("major-only")?.as_bool() == Some(true); - let show_minor_only = input.borrow().get_option("minor-only")?.as_bool() == Some(true); - let show_patch_only = input.borrow().get_option("patch-only")?.as_bool() == Some(true); - let ignored_packages_regex = base_package::package_names_to_regexp( - &input - .borrow() - .get_option("ignore")? - .as_list() - .map(|l| { - l.iter() - .filter_map(|v| v.as_string().map(strtolower)) - .collect::>() - }) - .unwrap_or_default(), - "{^(?:%s)$}iD", - ); - let indent = if show_all_types { " " } else { "" }; - let mut latest_packages: IndexMap = - IndexMap::new(); - let mut exit_code: i64 = 0; - let mut view_data: IndexMap>> = IndexMap::new(); - let mut view_meta_data: IndexMap = IndexMap::new(); - - let mut write_version = false; - let mut write_description = false; - - let type_order: Vec<(&str, bool)> = vec![ - ("platform", true), - ("locked", true), - ("available", false), - ("installed", true), - ]; - for (r#type, show_version) in type_order.iter() { - if let Some(type_packages) = packages.get_mut(*r#type) { - type_packages.sort_keys(); - - let mut name_length: usize = 0; - let mut version_length: usize = 0; - let mut latest_length: usize = 0; - let mut release_date_length: usize = 0; + json = Self::append_autoload(json, package.clone()); - if show_latest && *show_version { - for package_or_name in type_packages.values() { - if let PackageOrName::Pkg(package) = package_or_name - && !Preg::is_match(&ignored_packages_regex, &package.get_pretty_name()) - { - let latest = self.find_latest_package( - package.clone(), - composer.as_ref().unwrap(), - &platform_repo, - show_major_only, - show_minor_only, - show_patch_only, - platform_req_filter.clone(), - )?; - if latest.is_none() { - continue; - } + if !package.get_include_paths().is_empty() { + json.insert( + "include_path".to_string(), + PhpMixed::List( + package + .get_include_paths() + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + ); + } - latest_packages.insert(package.get_pretty_name(), latest.unwrap()); - } - } - } + json = Self::append_links(json, package); - let write_path = input.borrow().get_option("name-only")?.as_bool() != Some(true) - && input.borrow().get_option("path")?.as_bool() == Some(true); - write_version = input.borrow().get_option("name-only")?.as_bool() != Some(true) - && input.borrow().get_option("path")?.as_bool() != Some(true) - && *show_version; - let write_latest = write_version && show_latest; - write_description = input.borrow().get_option("name-only")?.as_bool() != Some(true) - && input.borrow().get_option("path")?.as_bool() != Some(true); - let write_release_date = write_latest - && (input.borrow().get_option("sort-by-age")?.as_bool() == Some(true) - || format == "json"); + self.get_io().write(&JsonFile::encode(&PhpMixed::Array( + json.into_iter().collect(), + ))?); + Ok(()) + } - let mut has_outdated_packages = false; + fn append_versions( + mut json: IndexMap, + versions: &IndexMap, + ) -> IndexMap { + let mut versions_pairs: Vec<(String, String)> = versions + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + // uasort($versions, 'version_compare'); + versions_pairs.sort_by(|a, b| { + if version_compare(&a.1, &b.1, "<") { + std::cmp::Ordering::Less + } else if version_compare(&a.1, &b.1, ">") { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }); + versions_pairs.reverse(); + let keys: Vec = versions_pairs + .into_iter() + .map(|(k, _)| PhpMixed::String(k)) + .collect(); + json.insert("versions".to_string(), PhpMixed::List(keys)); - if input.borrow().get_option("sort-by-age")?.as_bool() == Some(true) { - type_packages.sort_by(|_ka, a, _kb, b| match (a, b) { - (PackageOrName::Pkg(a), PackageOrName::Pkg(b)) => { - a.get_release_date().cmp(&b.get_release_date()) - } - _ => std::cmp::Ordering::Equal, - }); - } + json + } - let mut view_type: Vec> = Vec::new(); - for package_or_name in type_packages.values() { - let mut package_view_data: IndexMap = IndexMap::new(); - if let PackageOrName::Pkg(package) = package_or_name { - let latest_package = if show_latest - && latest_packages.contains_key(&package.get_pretty_name()) - { - latest_packages.get(&package.get_pretty_name()) - } else { - None - }; + fn append_licenses( + mut json: IndexMap, + package: CompletePackageInterfaceHandle, + ) -> IndexMap { + let licenses = package.get_license(); + if !licenses.is_empty() { + let spdx_licenses = SpdxLicenses::new(); - // Determine if Composer is checking outdated dependencies and if current package should trigger non-default exit code - let mut package_is_up_to_date = if let Some(latest) = latest_package { - latest.get_full_pretty_version( - true, - crate::package::DisplayMode::SourceRefIfDev, - ) == package.get_full_pretty_version( - true, - crate::package::DisplayMode::SourceRefIfDev, - ) && latest.as_complete().is_none_or(|c| !c.is_abandoned()) - } else { - false - }; - // When using --major-only, and no bigger version than current major is found then it is considered up to date - package_is_up_to_date = - package_is_up_to_date || (latest_package.is_none() && show_major_only); - let package_is_ignored = - Preg::is_match(&ignored_packages_regex, &package.get_pretty_name()); - if input.borrow().get_option("outdated")?.as_bool() == Some(true) - && (package_is_up_to_date || package_is_ignored) - { - continue; + let mapped: Vec = licenses + .into_iter() + .map(|license_id| { + let license = spdx_licenses.get_license_by_identifier(&license_id); + match license { + None => PhpMixed::String(license_id), + Some(l) => { + // The 'osi' key holds the license id string, not the OSI-approved flag. + let mut m: IndexMap = IndexMap::new(); + m.insert("name".to_string(), PhpMixed::String(l.name)); + m.insert("osi".to_string(), PhpMixed::String(license_id)); + m.insert("url".to_string(), PhpMixed::String(l.url)); + PhpMixed::Array(m.into_iter().collect()) } + } + }) + .collect(); + json.insert("licenses".to_string(), PhpMixed::List(mapped)); + } - if input.borrow().get_option("outdated")?.as_bool() == Some(true) - || input.borrow().get_option("strict")?.as_bool() == Some(true) - { - has_outdated_packages = true; - } + json + } - package_view_data.insert( - "name".to_string(), - PhpMixed::String(package.get_pretty_name()), - ); - package_view_data.insert( - "direct-dependency".to_string(), - PhpMixed::Bool(in_array_strict( - package.get_name(), - &self - .get_root_requires() - .into_iter() - .map(PhpMixed::String) - .collect::>(), - )), - ); - if format != "json" - || input.borrow().get_option("name-only")?.as_bool() != Some(true) - { - package_view_data.insert( - "homepage".to_string(), - match package.as_complete() { - Some(c) => match c.get_homepage() { - Some(h) => PhpMixed::String(h), - None => PhpMixed::Null, - }, - None => PhpMixed::Null, - }, - ); - package_view_data.insert( - "source".to_string(), - match PackageInfo::get_view_source_url(package.clone()) { - Some(s) => PhpMixed::String(s), - None => PhpMixed::Null, - }, - ); - } - name_length = name_length.max(package.get_pretty_name().len()); - if write_version { - let mut version_str = package.get_full_pretty_version( - true, - crate::package::DisplayMode::SourceRefIfDev, - ); - if format == "text" { - version_str = version_str.trim_start_matches('v').to_string(); - } - version_length = version_length.max(version_str.len()); - package_view_data - .insert("version".to_string(), PhpMixed::String(version_str)); - } - if write_release_date { - if let Some(release_date) = package.get_release_date() { - let mut age = self - .get_relative_time(&release_date) - .replace(" ago", " old"); - if !age.contains(" old") { - age = format!("from {}", age); - } - release_date_length = release_date_length.max(age.len()); - package_view_data - .insert("release-age".to_string(), PhpMixed::String(age)); - package_view_data.insert( - "release-date".to_string(), - PhpMixed::String(release_date.format(DATE_ATOM).to_string()), - ); - } else { - package_view_data.insert( - "release-age".to_string(), - PhpMixed::String(String::new()), - ); - package_view_data.insert( - "release-date".to_string(), - PhpMixed::String(String::new()), - ); - } - } - if write_latest && let Some(latest) = latest_package { - let mut latest_version_str = latest.get_full_pretty_version( - true, - crate::package::DisplayMode::SourceRefIfDev, - ); - if format == "text" { - latest_version_str = - latest_version_str.trim_start_matches('v').to_string(); - } - let update_status = - Self::get_update_status(latest.clone(), package.clone())?; - latest_length = latest_length.max(latest_version_str.len()); - package_view_data - .insert("latest".to_string(), PhpMixed::String(latest_version_str)); - package_view_data.insert( - "latest-status".to_string(), - PhpMixed::String(update_status), - ); + fn append_autoload( + mut json: IndexMap, + package: CompletePackageInterfaceHandle, + ) -> IndexMap { + let autoload_config = package.get_autoload(); + if !autoload_config.is_empty() { + let mut autoload: IndexMap = IndexMap::new(); - if let Some(rd) = latest.get_release_date() { - package_view_data.insert( - "latest-release-date".to_string(), - PhpMixed::String(rd.format(DATE_ATOM).to_string()), - ); - } else { - package_view_data.insert( - "latest-release-date".to_string(), - PhpMixed::String(String::new()), - ); - } - } else if write_latest { - package_view_data.insert( - "latest".to_string(), - PhpMixed::String("[none matched]".to_string()), - ); - package_view_data.insert( - "latest-status".to_string(), - PhpMixed::String("up-to-date".to_string()), - ); - latest_length = latest_length.max("[none matched]".len()); - } - if write_description && let Some(c) = package.as_complete() { - package_view_data.insert( - "description".to_string(), - match c.get_description() { - Some(d) => PhpMixed::String(d), - None => PhpMixed::Null, - }, - ); - } - if write_path { - let installation_manager = composer - .as_ref() - .unwrap() - .borrow_partial() - .get_installation_manager(); - let path: Option = installation_manager - .borrow_mut() - .get_install_path(package.clone()); - if let Some(p) = path { - let r = realpath(&p).unwrap_or_default(); - let trimmed = r.split(['\r', '\n']).next().unwrap_or(""); - package_view_data.insert( - "path".to_string(), - PhpMixed::String(trimmed.to_string()), - ); - } else { - package_view_data.insert("path".to_string(), PhpMixed::Null); - } - } + for (r#type, autoloads) in autoload_config.iter() { + if r#type == "psr-0" || r#type == "psr-4" { + let mut psr: IndexMap = IndexMap::new(); - let mut package_is_abandoned: PhpMixed = PhpMixed::Bool(false); - if let Some(latest) = latest_package - && let Some(c) = latest.as_complete() - && c.is_abandoned() - { - let replacement_package_name = c.get_replacement_package(); - let replacement = if let Some(ref rp) = replacement_package_name { - format!("Use {} instead", rp) - } else { - "No replacement was suggested".to_string() - }; - let package_warning = format!( - "Package {} is abandoned, you should avoid using it. {}.", - package.get_pretty_name(), - replacement - ); - package_view_data - .insert("warning".to_string(), PhpMixed::String(package_warning)); - package_is_abandoned = match replacement_package_name { - Some(rp) => PhpMixed::String(rp), - None => PhpMixed::Bool(true), + if let PhpMixed::Array(map) = autoloads { + for (name, path) in map.iter() { + let mut path_val = path.clone(); + let is_empty_path = match &path_val { + PhpMixed::String(s) => s.is_empty(), + PhpMixed::Null => true, + _ => false, }; - } + if is_empty_path { + path_val = PhpMixed::String(".".to_string()); + } - package_view_data.insert("abandoned".to_string(), package_is_abandoned); - } else if let PackageOrName::Name(name) = package_or_name { - package_view_data - .insert("name".to_string(), PhpMixed::String(name.clone())); - name_length = name_length.max(name.len()); + let key = if name.is_empty() { + "*".to_string() + } else { + name.clone() + }; + psr.insert(key, path_val); + } } - view_type.push(package_view_data); - } - view_data.insert(r#type.to_string(), view_type); - view_meta_data.insert( - r#type.to_string(), - ViewMetaData { - name_length, - version_length, - latest_length, - release_date_length, - write_latest, - write_release_date, - }, - ); - if input.borrow().get_option("strict")?.as_bool() == Some(true) - && has_outdated_packages - { - exit_code = 1; - break; + + autoload.insert(r#type.clone(), PhpMixed::Array(psr.into_iter().collect())); + } else if r#type == "classmap" { + autoload.insert("classmap".to_string(), autoloads.clone()); } } + + json.insert( + "autoload".to_string(), + PhpMixed::Array(autoload.into_iter().collect()), + ); } - if format == "json" { - let mut json_map: IndexMap = IndexMap::new(); - for (k, v) in view_data.iter() { - json_map.insert( - k.clone(), - PhpMixed::List( - v.iter() - .map(|m| { - PhpMixed::Array( - m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - ) - }) - .collect(), - ), + json + } + + fn append_links( + mut json: IndexMap, + package: CompletePackageInterfaceHandle, + ) -> IndexMap { + for link_type in Link::types().iter() { + json = Self::append_link(json, package.clone(), link_type); + } + + json + } + + fn append_link( + mut json: IndexMap, + package: CompletePackageInterfaceHandle, + link_type: &str, + ) -> IndexMap { + let links = package.get_links_for_type(link_type); + + if !links.is_empty() { + let mut m: IndexMap = IndexMap::new(); + for link in links.iter() { + m.insert( + link.1.get_target().to_string(), + PhpMixed::String(link.1.get_pretty_constraint().to_string()), ); } - let io = self.get_io(); - io.write(&JsonFile::encode(&PhpMixed::Array( - json_map.into_iter().collect(), - ))?); - } else { - if input.borrow().get_option("latest")?.as_bool() == Some(true) - && view_data.values().any(|v| !v.is_empty()) - { - let io = self.get_io(); - if !io.is_decorated() { - io.write_error("Legend:"); - io.write_error("! patch or minor release available - update recommended"); - io.write_error("~ major release available - update possible"); - if input.borrow().get_option("outdated")?.as_bool() != Some(true) { - io.write_error("= up to date version"); + json.insert( + link_type.to_string(), + PhpMixed::Array(m.into_iter().collect()), + ); + } + + json + } + + /// Init styles for tree + pub(crate) fn init_styles(&self, output: std::rc::Rc>) { + *self.colors.borrow_mut() = vec![ + "green".to_string(), + "yellow".to_string(), + "cyan".to_string(), + "magenta".to_string(), + "blue".to_string(), + ]; + + for color in self.colors.borrow().iter() { + let style = OutputFormatterStyle::new(Some(color.as_str()), None, vec![]); + output + .borrow() + .get_formatter() + .borrow_mut() + .set_style(color, Box::new(style)); + } + } + + /// Display the tree + pub(crate) fn display_package_tree(&self, array_tree: Vec>) { + for package in array_tree.iter() { + let name = package + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + self.get_io() + .write_no_newline(&format!("{}", name)); + let version = package + .get("version") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + self.get_io().write_no_newline(&format!(" {}", version)); + if let Some(description) = package.get("description").and_then(|v| v.as_string()) { + let trimmed = description.split(['\r', '\n']).next().unwrap_or(""); + self.get_io().write(&format!(" {}", trimmed)); + } else { + // output newline + self.get_io().write(""); + } + + if let Some(requires) = package.get("requires").and_then(|v| v.as_list()).cloned() { + let mut tree_bar = "├".to_string(); + let mut j = 0_usize; + let total = requires.len(); + for require_mixed in requires.iter() { + let require = match require_mixed.as_array() { + Some(a) => a, + None => continue, + }; + let require_name = require + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + j += 1; + if j == total { + tree_bar = "└".to_string(); } - } else { - io.write_error("Color legend:"); - io.write_error("- patch or minor release available - update recommended"); - io.write_error( - "- major release available - update possible", + let level: usize = 1; + let color = self.colors.borrow().get(level).cloned().unwrap_or_default(); + let info = format!( + "{}──<{}>{} {}", + tree_bar, + color, + require_name, + color, + require + .get("version") + .and_then(|v| v.as_string()) + .unwrap_or("") + ); + self.write_tree_line(&info); + + tree_bar = tree_bar.replace('└', " "); + let packages_in_tree: Vec = vec![ + PhpMixed::String(name.clone()), + PhpMixed::String(require_name.clone()), + ]; + + self.display_tree( + &PhpMixed::Array( + require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + &packages_in_tree, + &tree_bar, + level + 1, ); - if input.borrow().get_option("outdated")?.as_bool() != Some(true) { - io.write_error("- up to date version"); - } } } + } + } + + /// Generate the package tree + pub(crate) fn generate_package_tree( + &self, + package: PackageInterfaceHandle, + installed_repo: &RepositoryInterfaceHandle, + remote_repos: &RepositoryInterfaceHandle, + ) -> IndexMap { + let requires = { + let mut r: IndexMap = package.get_requires(); + r.sort_keys(); + r + }; + let mut children: Vec = Vec::new(); + for (require_name, require) in requires.iter() { + let packages_in_tree: Vec = vec![ + PhpMixed::String(package.get_name().to_string()), + PhpMixed::String(require_name.clone()), + ]; + + let mut tree_child_desc: IndexMap = IndexMap::new(); + tree_child_desc.insert("name".to_string(), PhpMixed::String(require_name.clone())); + tree_child_desc.insert( + "version".to_string(), + PhpMixed::String(require.get_pretty_constraint().to_string()), + ); + + let deep_children = self + .add_tree( + require_name, + require, + installed_repo, + remote_repos, + &packages_in_tree, + ) + .unwrap_or_default(); + + if !deep_children.is_empty() { + tree_child_desc.insert( + "requires".to_string(), + PhpMixed::List( + deep_children + .into_iter() + .map(|m| PhpMixed::Array(m.into_iter().collect())) + .collect(), + ), + ); + } - let width = self.get_terminal_width(); + children.push(PhpMixed::Array(tree_child_desc.into_iter().collect())); + } + let mut tree: IndexMap = IndexMap::new(); + tree.insert( + "name".to_string(), + PhpMixed::String(package.get_pretty_name()), + ); + tree.insert( + "version".to_string(), + PhpMixed::String(package.get_pretty_version()), + ); + tree.insert( + "description".to_string(), + match package.as_complete() { + Some(c) => match c.get_description() { + Some(d) => PhpMixed::String(d), + None => PhpMixed::Null, + }, + None => PhpMixed::String(String::new()), + }, + ); - for (r#type, packages) in view_data.iter() { - let meta = match view_meta_data.get(r#type) { - Some(m) => m.clone(), - None => continue, - }; - let name_length = meta.name_length; - let version_length = meta.version_length; - let mut latest_length = meta.latest_length; - let release_date_length = meta.release_date_length; - let write_latest = meta.write_latest; - let write_release_date = meta.write_release_date; + if !children.is_empty() { + tree.insert("requires".to_string(), PhpMixed::List(children)); + } - let width_usize = width as usize; - let version_fits = name_length + version_length + 3 <= width_usize; - let latest_fits = name_length + version_length + latest_length + 3 <= width_usize; - let release_date_fits = - name_length + version_length + latest_length + release_date_length + 3 - <= width_usize; - let description_fits = - name_length + version_length + latest_length + release_date_length + 24 - <= width_usize; + tree + } - if latest_fits && !self.get_io().is_decorated() { - latest_length += 2; - } + /// Display a package tree + pub(crate) fn display_tree( + &self, + package: &PhpMixed, + packages_in_tree: &[PhpMixed], + previous_tree_bar: &str, + level: usize, + ) { + let previous_tree_bar = previous_tree_bar.replace('├', "│"); + let arr = match package.as_array() { + Some(a) => a, + None => return, + }; + let requires = match arr.get("requires").and_then(|v| v.as_list()).cloned() { + Some(l) => l, + None => return, + }; + let mut tree_bar = format!("{} ├", previous_tree_bar); + let mut i = 0_usize; + let total = requires.len(); + for require_mixed in requires.iter() { + let mut current_tree = packages_in_tree.to_vec(); + i += 1; + if i == total { + tree_bar = format!("{} └", previous_tree_bar); + } + let color_ident = level % self.colors.borrow().len(); + let color = self + .colors + .borrow() + .get(color_ident) + .cloned() + .unwrap_or_default(); - if show_all_types { - if r#type == "available" { - self.get_io() - .write(&format!("{}:", r#type)); - } else { - self.get_io().write(&format!("{}:", r#type)); - } - } + let require = match require_mixed.as_array() { + Some(a) => a, + None => continue, + }; + let require_name = require + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + let require_version = require + .get("version") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); - if write_latest && input.borrow().get_option("direct")?.as_bool() != Some(true) { - let mut direct_deps: Vec> = Vec::new(); - let mut transitive_deps: Vec> = Vec::new(); - for pkg in packages.iter() { - let is_direct = pkg - .get("direct-dependency") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if is_direct { - direct_deps.push(pkg.clone()); - } else { - transitive_deps.push(pkg.clone()); - } - } + let circular_warn = if in_array_strict(require_name.clone(), ¤t_tree) { + "(circular dependency aborted here)" + } else { + "" + }; + let info = format!( + "{}──<{}>{} {} {}", + tree_bar, color, require_name, color, require_version, circular_warn + ) + .trim_end() + .to_string(); + self.write_tree_line(&info); - self.get_io().write_error(""); - self.get_io() - .write_error("Direct dependencies required in composer.json:"); - if !direct_deps.is_empty() { - self.print_packages( - &direct_deps, - indent, - write_version && version_fits, - latest_fits, - write_description && description_fits, - width_usize, - version_length, - name_length, - latest_length, - write_release_date && release_date_fits, - release_date_length, - ); - } else { - self.get_io().write_error("Everything up to date"); - } - self.get_io().write_error(""); - self.get_io().write_error( - "Transitive dependencies not required in composer.json:", - ); - if !transitive_deps.is_empty() { - self.print_packages( - &transitive_deps, - indent, - write_version && version_fits, - latest_fits, - write_description && description_fits, - width_usize, - version_length, - name_length, - latest_length, - write_release_date && release_date_fits, - release_date_length, - ); - } else { - self.get_io().write_error("Everything up to date"); - } - } else { - if write_latest && packages.is_empty() { - self.get_io() - .write_error("All your direct dependencies are up to date"); - } else { - self.print_packages( - packages, - indent, - write_version && version_fits, - write_latest && latest_fits, - write_description && description_fits, - width_usize, - version_length, - name_length, - latest_length, - write_release_date && release_date_fits, - release_date_length, + tree_bar = tree_bar.replace('└', " "); + + current_tree.push(PhpMixed::String(require_name.clone())); + self.display_tree(require_mixed, ¤t_tree, &tree_bar, level + 1); + } + } + + /// Display a package tree + pub(crate) fn add_tree( + &self, + name: &str, + link: &Link, + installed_repo: &RepositoryInterfaceHandle, + remote_repos: &RepositoryInterfaceHandle, + packages_in_tree: &[PhpMixed], + ) -> anyhow::Result>> { + let mut children: Vec> = Vec::new(); + let version_arg: PhpMixed = if link.get_pretty_constraint() == "self.version" { + // pass the ConstraintInterface object — signal via Null in this scalar shape + PhpMixed::Null + } else { + PhpMixed::String(link.get_pretty_constraint().to_string()) + }; + let (package, _) = self.get_package(installed_repo, remote_repos, name, version_arg)?; + if let Some(package) = package { + let mut requires = package.get_requires(); + requires.sort_keys(); + for (require_name, require) in requires.iter() { + let mut current_tree = packages_in_tree.to_vec(); + + let mut tree_child_desc: IndexMap = IndexMap::new(); + tree_child_desc.insert("name".to_string(), PhpMixed::String(require_name.clone())); + tree_child_desc.insert( + "version".to_string(), + PhpMixed::String(require.get_pretty_constraint().to_string()), + ); + + if !in_array_strict(require_name.clone(), ¤t_tree) { + current_tree.push(PhpMixed::String(require_name.clone())); + let deep_children = self.add_tree( + require_name, + require, + installed_repo, + remote_repos, + ¤t_tree, + )?; + if !deep_children.is_empty() { + tree_child_desc.insert( + "requires".to_string(), + PhpMixed::List( + deep_children + .into_iter() + .map(|m| PhpMixed::Array(m.into_iter().collect())) + .collect(), + ), ); } } - if show_all_types { - self.get_io().write(""); - } + children.push(tree_child_desc); } } - Ok(exit_code) + Ok(children) } - fn initialize( - &self, - input: std::rc::Rc>, - output: std::rc::Rc>, - ) -> anyhow::Result<()> { - base_command_initialize(self, input, output) + fn update_status_to_version_style(update_status: &str) -> &'static str { + // 'up-to-date' is printed green + // 'semver-safe-update' is printed red + // 'update-possible' is printed yellow + match update_status { + "up-to-date" => "info", + "semver-safe-update" => "highlight", + "update-possible" => "comment", + _ => "comment", + } } - fn complete( - &self, - input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, - suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, - ) -> anyhow::Result<()> { - crate::command::base_command::base_command_complete(self, input, suggestions) - } + fn get_update_status( + latest_package: PackageInterfaceHandle, + package: PackageInterfaceHandle, + ) -> anyhow::Result { + if latest_package.get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) + == package.get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) + { + return Ok("up-to-date".to_string()); + } - shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); -} + let mut constraint = package.get_version(); + if !constraint.starts_with("dev-") { + constraint = format!("^{}", constraint); + } + if !latest_package.get_version().is_empty() + && Semver::satisfies(latest_package.get_version(), constraint)? + { + // it needs an immediate semver-compliant upgrade + return Ok("semver-safe-update".to_string()); + } -impl BaseCommand for ShowCommand { - fn base_command_data(&self) -> &crate::command::BaseCommandData { - &self.base_command_data + // it needs an upgrade but has potential BC breaks so is not urgent + Ok("update-possible".to_string()) } - crate::delegate_base_command_trait_impls_to_inner!(base_command_data); -} - -impl ShowCommand { - /// PHP: protected function suggestPackageBasedOnMode(): \Closure - pub(crate) fn suggest_package_based_on_mode(&self) -> crate::console::input::SuggestedValues { - crate::console::input::SuggestedValues::Closure(Box::new(|this, input, suggestions| { - if input.get_option("available")?.to_bool() || input.get_option("all")?.to_bool() { - return this.suggest_available_package_incl_platform().call( - this, - input, - suggestions, - ); - } - - if input.get_option("platform")?.to_bool() { - return this - .suggest_platform_package() - .call(this, input, suggestions); - } + fn write_tree_line(&self, line: &str) { + let io = self.get_io(); + let mut line = line.to_string(); + if !io.is_decorated() { + line = line + .replace('└', "`-") + .replace('├', "|-") + .replace("──", "-") + .replace('│', "|"); + } - this.suggest_installed_package(false, false) - .call(this, input, suggestions) - })) + io.write(&line); } + /// Given a package, this finds the latest package matching it #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] - fn print_packages( + fn find_latest_package( &self, - packages: &[IndexMap], - indent: &str, - write_version: bool, - write_latest: bool, - write_description: bool, - width: usize, - version_length: usize, - name_length: usize, - latest_length: usize, - write_release_date: bool, - release_date_length: usize, - ) { - let io = self.get_io(); - let pad_name = write_version || write_latest || write_release_date || write_description; - let pad_version = write_latest || write_release_date || write_description; - let pad_latest = write_description || write_release_date; - let pad_release_date = write_description; - for package in packages.iter() { - let link = package - .get("source") - .and_then(|v| v.as_string()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .or_else(|| { - package - .get("homepage") - .and_then(|v| v.as_string()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); - let name = package - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - if !link.is_empty() { - let pad = if pad_name && name_length > name.len() { - name_length - name.len() - } else { - 0 - }; - io.write_no_newline(&format!( - "{}{}{}", - indent, - OutputFormatter::escape(&link).expect("OutputFormatter::escape failed"), - name, - " ".repeat(pad) - )); - } else { - let width_pad = if pad_name { name_length } else { 0 }; - io.write_no_newline(&format!("{}{:{:", - style, - latest_version, - style, - width = width_pad - )); - if write_release_date - && let Some(age) = package.get("release-age").and_then(|v| v.as_string()) - { - let width_pad = if pad_release_date { - release_date_length - } else { - 0 - }; - io.write_no_newline(&format!(" {:, + ) -> anyhow::Result> { + // find the latest version allowed in this repo set + let name = package.get_name(); + let repo_set = self.get_repository_set(composer)?; + let composer_ref = crate::composer::composer_full(composer); + let mut version_selector = + VersionSelector::new(repo_set, Some(&mut *platform_repo.borrow_mut()))?; + let mut stability = composer_ref.get_package().get_minimum_stability(); + let flags = composer_ref.get_package().get_stability_flags(); + if let Some(flag_value) = flags.get(&name) { + let key_map: IndexMap = base_package::STABILITIES + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let needle = flag_value.to_string(); + if let Some(found_key) = array_search(&needle, &key_map) { + stability = found_key; } - if let Some(description) = package.get("description").and_then(|v| v.as_string()) - && write_description - { - let mut description = description - .split(['\r', '\n']) - .next() - .unwrap_or("") - .to_string(); + } - // Compute remaining width available for the description. - let mut remaining = (width as i64) - - (name_length as i64) - - (version_length as i64) - - (release_date_length as i64) - - 4; - if write_latest { - remaining -= latest_length as i64; - } + let mut best_stability = stability; + if composer_ref.get_package().get_prefer_stable() { + best_stability = package.get_stability(); + } - // If nothing fits, clear the description. - if remaining <= 0 { - description = String::new(); - } else if extension_loaded("mbstring") { - // Use mb_strwidth/mb_strimwidth to measure and trim by display width - // (CJK characters count as width 2). mb_strimwidth counts the trim - // marker ('...') in the width parameter, so pass $remaining directly. - if description.chars().count() > remaining as usize { - description = format!( - "{}...", - description - .chars() - .take((remaining as usize).saturating_sub(3)) - .collect::() - ); - } - } else { - // Fallback when mbstring is not available: do a conservative byte-based cut. - // Ensure cut length is non-negative and leave room for the ellipsis. - let cut = (remaining - 3).max(0) as usize; - if description.len() > cut { - description = format!("{}...", &description[..cut]); - } - } + let mut target_version: Option = None; + if package.get_version().starts_with("dev-") { + target_version = Some(package.get_version()); + + // dev-x branches are considered to be on the latest major version always, do not look up for a new commit as that is deemed a minor upgrade (albeit risky) + if major_only { + return Ok(None); + } + } - io.write_no_newline(&format!(" {}", description)); + if target_version.is_none() { + let mut groups: IndexMap = IndexMap::new(); + if major_only + && Preg::is_match3( + php_regex!(r"{^(?P(?:0\.)+)?(?P\d+)\.}"), + &package.get_version(), + Some(&mut groups), + ) + { + let zero_major = groups + .get(&CaptureKey::ByName("zero_major".to_string())) + .cloned() + .unwrap_or_default(); + let first_meaningful = groups + .get(&CaptureKey::ByName("first_meaningful".to_string())) + .cloned() + .unwrap_or_default() + .parse::() + .unwrap_or(0); + target_version = Some(format!( + ">={}{},<9999999-dev", + zero_major, + first_meaningful + 1 + )); } - if package.contains_key("path") { - let path_str = match package.get("path") { - Some(PhpMixed::String(s)) => s.clone(), - _ => "null".to_string(), + + if minor_only { + target_version = Some(format!("^{}", package.get_version())); + } + + if patch_only { + let trimmed_version = + Preg::replace(php_regex!(r"{(\.0)+$}D"), "", &package.get_version()); + let parts_needed = if trimmed_version.starts_with('0') { + 4 + } else { + 3 }; - io.write_no_newline(&format!(" {}", path_str)); + let mut trimmed_version = trimmed_version; + while trimmed_version.chars().filter(|&c| c == '.').count() + 1 < parts_needed { + trimmed_version.push_str(".0"); + } + target_version = Some(format!("~{}", trimmed_version)); } - io.write(""); - if let Some(warning) = package.get("warning").and_then(|v| v.as_string()) { - io.write(&format!("{}", warning)); + } + + let show_warnings = if self.get_io().is_verbose() { + ShowWarnings::Always + } else { + let package_version = package.get_version(); + ShowWarnings::Predicate(Box::new( + move |candidate: &PackageInterfaceHandle| -> bool { + if candidate.get_version().starts_with("dev-") + || package_version.starts_with("dev-") + { + return false; + } + + version_compare(&candidate.get_version(), &package_version, "<=") + }, + )) + }; + let mut candidate = version_selector.find_best_candidate( + &name, + target_version.as_deref(), + &best_stability, + Some(platform_req_filter), + 0, + Some(self.get_io().clone()), + show_warnings, + )?; + while let Some(ref c) = candidate { + if let Some(alias) = c.as_alias() { + candidate = Some(alias.get_alias_of().into()); + } else { + break; } } + + Ok(candidate) } - pub(crate) fn get_root_requires(&self) -> Vec { - let composer_rc = self.try_composer(None, None); - let composer_rc = match composer_rc { - None => return vec![], - Some(c) => c, - }; - let composer = crate::composer::composer_full(&composer_rc); + fn get_repository_set( + &self, + composer: &PartialComposerHandle, + ) -> anyhow::Result>> { + let composer = crate::composer::composer_full(composer); + if self.repository_set.borrow().is_none() { + let mut rs = RepositorySet::new( + &composer.get_package().get_minimum_stability(), + composer.get_package().get_stability_flags(), + Vec::new(), + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ); + rs.add_repository(RepositoryInterfaceHandle::new(CompositeRepository::new( + composer + .get_repository_manager() + .borrow() + .get_repositories() + .to_vec(), + )))?; + *self.repository_set.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new(rs))); + } - let root_package = composer.get_package(); + Ok(self.repository_set.borrow().as_ref().unwrap().clone()) + } - let mut combined: IndexMap = IndexMap::new(); - for (k, v) in root_package.get_requires().iter() { - combined.insert(k.clone(), v.clone()); + fn get_relative_time(&self, release_date: &chrono::DateTime) -> String { + if release_date + .format(date_format_to_strftime("Ymd")) + .to_string() + == date("Ymd", None) + { + return "today".to_string(); } - for (k, v) in root_package.get_dev_requires().iter() { - combined.insert(k.clone(), v.clone()); + + let diff = chrono::Utc::now().signed_duration_since(*release_date); + let days = diff.num_days(); + if days < 7 { + return "this week".to_string(); } - combined.keys().map(|k| strtolower(k)).collect() + + if days < 14 { + return "last week".to_string(); + } + + let months = days / 30; + if months < 1 && days < 31 { + return format!("{} weeks ago", days / 7); + } + + let years = days / 365; + if years < 1 { + return format!("{} month{} ago", months, if months > 1 { "s" } else { "" }); + } + + format!("{} year{} ago", years, if years > 1 { "s" } else { "" }) } - pub(crate) fn get_version_style( - &self, - latest_package: PackageInterfaceHandle, - package: PackageInterfaceHandle, - ) -> anyhow::Result { - Ok( - Self::update_status_to_version_style(&Self::get_update_status( - latest_package, - package, - )?) - .to_string(), - ) + fn same_repository(a: &T, b: &U) -> bool + where + T: Into + Clone, + U: Into + Clone, + { + let a = a.clone().into(); + let b = b.clone().into(); + Self::same_repository_handle(&a, &b) + } + + fn same_repository_handle( + a: &RepositoryInterfaceHandle, + b: &RepositoryInterfaceHandle, + ) -> bool { + a.ptr_eq(b) + } +} + +impl Command for ShowCommand { + fn configure(&self) -> anyhow::Result<()> { + self.set_name("show")?; + self.set_aliases(vec!["info".to_string()])?; + self.set_description("Shows information about packages"); + let opt_none = |name: &str, shortcut: Option<&str>, description: &str| { + InputOption::new( + name, + shortcut.map(|s| PhpMixed::String(s.to_string())), + Some(InputOption::VALUE_NONE), + description, + None, + ) + .unwrap() + .into() + }; + self.set_definition(&[ + InputArgument::new5( + "package", + Some(InputArgument::OPTIONAL), + "Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.", + None, + self.suggest_package_based_on_mode(), + ) + .unwrap() + .into(), + InputArgument::new( + "version", + Some(InputArgument::OPTIONAL), + "Version or version constraint to inspect", + None, + ) + .unwrap() + .into(), + opt_none("all", None, "List all packages"), + opt_none("locked", None, "List all locked packages"), + opt_none( + "installed", + Some("i"), + "List installed packages only (enabled by default, only present for BC).", + ), + opt_none("platform", Some("p"), "List platform packages only"), + opt_none("available", Some("a"), "List available packages only"), + opt_none("self", Some("s"), "Show the root package information"), + opt_none("name-only", Some("N"), "List package names only"), + opt_none("path", Some("P"), "Show package paths"), + opt_none("tree", Some("t"), "List the dependencies as a tree"), + opt_none("latest", Some("l"), "Show the latest version"), + opt_none( + "outdated", + Some("o"), + "Show the latest version but only for packages that are outdated", + ), + InputOption::new6( + "ignore", + None, + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "Ignore specified package(s). Can contain wildcards (*). Use it with the --outdated option if you don't want to be informed about new versions of some packages.", + None, + self.suggest_installed_package(false, false), + ) + .unwrap() + .into(), + opt_none( + "major-only", + Some("M"), + "Show only packages that have major SemVer-compatible updates. Use with the --latest or --outdated option.", + ), + opt_none( + "minor-only", + Some("m"), + "Show only packages that have minor SemVer-compatible updates. Use with the --latest or --outdated option.", + ), + opt_none( + "patch-only", + None, + "Show only packages that have patch SemVer-compatible updates. Use with the --latest or --outdated option.", + ), + opt_none( + "sort-by-age", + Some("A"), + "Displays the installed version's age, and sorts packages oldest first. Use with the --latest or --outdated option.", + ), + opt_none( + "direct", + Some("D"), + "Shows only packages that are directly required by the root package", + ), + opt_none( + "strict", + None, + "Return a non-zero exit code when there are outdated packages", + ), + InputOption::new6( + "format", + Some(PhpMixed::String("f".to_string())), + Some(InputOption::VALUE_REQUIRED), + "Format of the output: text or json", + Some(PhpMixed::String("text".to_string())), + SuggestedValues::List(vec!["json".to_string(), "text".to_string()]), + ) + .unwrap() + .into(), + opt_none("no-dev", None, "Disables search in require-dev packages."), + InputOption::new( + "ignore-platform-req", + None, + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "Ignore a specific platform requirement (php & ext- packages). Use with the --outdated option", + None, + ) + .unwrap() + .into(), + opt_none( + "ignore-platform-reqs", + None, + "Ignore all platform requirements (php & ext- packages). Use with the --outdated option", + ), + ]); + self.set_help( + "The show command displays detailed information about a package, or\n\ + lists all packages available.\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#show-info", + ); + Ok(()) } - /// finds a package by name and version if provided - pub(crate) fn get_package( + fn execute( &self, - installed_repo: &RepositoryInterfaceHandle, - repos: &RepositoryInterfaceHandle, - name: &str, - version: PhpMixed, - ) -> anyhow::Result<( - Option, - IndexMap, - )> { - let name = strtolower(name); - let constraint: Option = match &version { - PhpMixed::String(s) => Some(self.version_parser.borrow().parse_constraints(s)?), - PhpMixed::Null => None, - _ => None, // already a ConstraintInterface - }; - - let policy = DefaultPolicy::new(false, false, None); - let mut repository_set = RepositorySet::new( - "dev", - IndexMap::new(), - Vec::new(), - IndexMap::new(), - IndexMap::new(), - IndexMap::new(), - ); - repository_set.allow_installed_repositories(true); - repository_set.add_repository(repos.clone())?; - - let mut matched_package: Option = None; - let mut versions: IndexMap = IndexMap::new(); - let mut pool = if PlatformRepository::is_platform_package(&name) { - repository_set.create_pool_with_all_packages()? - } else { - repository_set.create_pool_for_package(&name, None)? - }; - let matches = pool.what_provides(&name, constraint.as_ref()); - let mut literals: Vec = Vec::new(); - for package in matches.iter() { - // avoid showing the 9999999-dev alias if the default branch has no branch-alias set - let mut p: crate::package::PackageInterfaceHandle = package.clone(); - if let Some(alias) = p.as_alias() - && p.get_version() == VersionParser::DEFAULT_BRANCH_ALIAS - { - p = alias.get_alias_of().into(); - } - - // select an exact match if it is in the installed repo and no specific version was required - if version.is_null() && installed_repo.has_package(p.clone())? { - matched_package = Some(p.clone()); - } - - versions.insert(p.get_pretty_version(), p.get_version()); - literals.push(p.get_id()); + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + *self.version_parser.borrow_mut() = VersionParser::new(); + if input.borrow().get_option("tree")?.as_bool() == Some(true) { + self.init_styles(output.clone()); } - // select preferred package according to policy rules - if matched_package.is_none() && !literals.is_empty() { - let preferred = policy.select_preferred_packages(&pool, literals.clone(), None); - matched_package = Some(pool.literal_to_package(preferred[0])); - } + let composer = self.try_composer(None, None); - if let Some(ref mp) = matched_package - && mp.as_complete().is_none() + if input.borrow().get_option("installed")?.as_bool() == Some(true) + && input.borrow().get_option("self")?.as_bool() != Some(true) { - return Err(LogicException { - message: format!( - "ShowCommand::getPackage can only work with CompletePackageInterface, but got {}", - shirabe_php_shim::get_class(&PhpMixed::Null) - ), - code: 0, - } - .into()); + self.get_io().write_error("You are using the deprecated option \"installed\". Only installed packages are shown by default now. The --all option can be used to show all packages."); } - let matched_package = matched_package.and_then(|mp| mp.as_complete()); - Ok((matched_package, versions)) - } - - /// Prints package info. - pub(crate) fn print_package_info( - &self, - package: CompletePackageInterfaceHandle, - versions: &IndexMap, - installed_repo: &mut dyn RepositoryInterface, - latest_package: Option, - ) -> anyhow::Result<()> { - self.print_meta(package.clone(), versions, installed_repo, latest_package)?; - self.print_links(package.clone(), Link::TYPE_REQUIRE, None); - self.print_links( - package.clone(), - Link::TYPE_DEV_REQUIRE, - Some("requires (dev)"), - ); - - if !package.get_suggests().is_empty() { - self.get_io().write("\nsuggests"); - for (suggested, reason) in package.get_suggests().iter() { - self.get_io() - .write(&format!("{} {}", suggested, reason)); - } + if input.borrow().get_option("outdated")?.as_bool() == Some(true) { + input + .borrow_mut() + .set_option("latest", PhpMixed::Bool(true)); + } else if input + .borrow() + .get_option("ignore")? + .as_list() + .map_or(0, |l| l.len()) + > 0 + { + self.get_io().write_error("You are using the option \"ignore\" for action other than \"outdated\", it will be ignored."); } - self.print_links(package.clone(), Link::TYPE_PROVIDE, None); - self.print_links(package.clone(), Link::TYPE_CONFLICT, None); - self.print_links(package, Link::TYPE_REPLACE, None); - Ok(()) - } - - /// Prints package metadata. - pub(crate) fn print_meta( - &self, - package: CompletePackageInterfaceHandle, - versions: &IndexMap, - installed_repo: &mut dyn RepositoryInterface, - latest_package: Option, - ) -> anyhow::Result<()> { - let is_installed_package = !PlatformRepository::is_platform_package(&package.get_name()) - && installed_repo.has_package(package.clone().into())?; + if input.borrow().get_option("direct")?.as_bool() == Some(true) + && (input.borrow().get_option("all")?.as_bool() == Some(true) + || input.borrow().get_option("available")?.as_bool() == Some(true) + || input.borrow().get_option("platform")?.as_bool() == Some(true)) + { + self.get_io().write_error("The --direct (-D) option is not usable in combination with --all, --platform (-p) or --available (-a)"); - self.get_io().write(&format!( - "name : {}", - package.get_pretty_name() - )); - self.get_io().write(&format!( - "descrip. : {}", - package.get_description().unwrap_or_default() - )); - let keywords = package.get_keywords(); - self.get_io() - .write(&format!("keywords : {}", keywords.join(", "))); - self.print_versions(package.clone(), versions, installed_repo)?; - if is_installed_package && let Some(rd) = package.get_release_date() { - let rel = self.get_relative_time(&rd); - self.get_io().write(&format!( - "released : {}, {}", - rd.format(date_format_to_strftime("Y-m-d")), - rel - )); + return Ok(1); } - let latest: PackageInterfaceHandle = if let Some(latest) = latest_package { - let style = self.get_version_style(latest.clone(), package.clone().into())?; - let released_time = match latest.get_release_date() { - None => String::new(), - Some(rd) => { - let rel = self.get_relative_time(&rd); - format!( - " released {}, {}", - rd.format(date_format_to_strftime("Y-m-d")), - rel - ) - } - }; - self.get_io().write(&format!( - "latest : <{}>{}{}", - style, - latest.get_pretty_version(), - style, - released_time - )); - latest - } else { - package.clone().into() - }; - self.get_io() - .write(&format!("type : {}", package.get_type())); - self.print_licenses(package.clone()); - self.get_io().write(&format!( - "homepage : {}", - package.get_homepage().unwrap_or_default() - )); - self.get_io().write(&format!( - "source : [{}] {} {}", - package.get_source_type().unwrap_or_default(), - package.get_source_url().unwrap_or_default(), - package.get_source_reference().unwrap_or_default() - )); - self.get_io().write(&format!( - "dist : [{}] {} {}", - package.get_dist_type().unwrap_or_default(), - package.get_dist_url().unwrap_or_default(), - package.get_dist_reference().unwrap_or_default() - )); - if is_installed_package { - let path: Option = self.require_composer(None, None).ok().and_then(|c| { - let installation_manager = c.borrow_partial().get_installation_manager(); - installation_manager - .borrow_mut() - .get_install_path(package.clone().into()) - }); - if let Some(p) = path { - self.get_io().write(&format!( - "path : {}", - realpath(&p).unwrap_or_default() - )); - } else { - self.get_io().write("path : null"); - } + if input.borrow().get_option("tree")?.as_bool() == Some(true) + && (input.borrow().get_option("all")?.as_bool() == Some(true) + || input.borrow().get_option("available")?.as_bool() == Some(true)) + { + self.get_io().write_error("The --tree (-t) option is not usable in combination with --all or --available (-a)"); + + return Ok(1); } - self.get_io().write(&format!( - "names : {}", - package.get_names(true).join(", ") - )); - if let Some(c) = latest.as_complete() - && c.is_abandoned() + let only_count: usize = [ + input.borrow().get_option("patch-only")?.as_bool() == Some(true), + input.borrow().get_option("minor-only")?.as_bool() == Some(true), + input.borrow().get_option("major-only")?.as_bool() == Some(true), + ] + .iter() + .filter(|b| **b) + .count(); + if only_count > 1 { + self.get_io().write_error( + "Only one of --major-only, --minor-only or --patch-only can be used at once", + ); + + return Ok(1); + } + + if input.borrow().get_option("tree")?.as_bool() == Some(true) + && input.borrow().get_option("latest")?.as_bool() == Some(true) { - let replacement = match c.get_replacement_package() { - Some(rp) => format!(" The author suggests using the {} package instead.", rp), - None => String::new(), - }; + self.get_io().write_error( + "The --tree (-t) option is not usable in combination with --latest (-l)", + ); + + return Ok(1); + } + + if input.borrow().get_option("tree")?.as_bool() == Some(true) + && input.borrow().get_option("path")?.as_bool() == Some(true) + { + self.get_io().write_error( + "The --tree (-t) option is not usable in combination with --path (-P)", + ); + return Ok(1); + } + + let format = input + .borrow() + .get_option("format")? + .as_string() + .unwrap_or("text") + .to_string(); + if !in_array_loose( + format.clone(), + &[ + PhpMixed::String("text".to_string()), + PhpMixed::String("json".to_string()), + ], + ) { self.get_io().write_error(&format!( - "Attention: This package is abandoned and no longer maintained.{}", - replacement - )); + "Unsupported format \"{}\". See help for supported formats.", + format + )); + + return Ok(1); } - let support = package.get_support(); - if !support.is_empty() { - self.get_io().write("\nsupport"); - for (r#type, value) in support.iter() { - self.get_io() - .write(&format!("{} : {}", r#type, value)); + let platform_req_filter = self.get_platform_requirement_filter(input.clone())?; + + // init repos + let mut platform_overrides: IndexMap = IndexMap::new(); + if let Some(ref composer) = composer { + let composer = crate::composer::composer_full(composer); + if let Some(p) = composer + .get_config() + .borrow() + .get("platform") + .as_array() + .cloned() + { + platform_overrides = p.into_iter().collect(); } } + let platform_repo = + PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides)?); + let mut locked_repo: Option = None; - let autoload_config = package.get_autoload(); - if !autoload_config.is_empty() { - self.get_io().write("\nautoload"); - for (r#type, autoloads) in autoload_config.iter() { - self.get_io() - .write(&format!("{}", r#type)); + // The single-package $package binding from PHP gets surfaced here. + let mut single_package: Option = None; + let mut versions_map: IndexMap = IndexMap::new(); + let installed_repo: RepositoryInterfaceHandle; + let repos: RepositoryInterfaceHandle; - if r#type == "psr-0" || r#type == "psr-4" { - if let PhpMixed::Array(map) = autoloads { - for (name, path) in map.iter() { - let path_str = match path { - PhpMixed::List(l) => l - .iter() - .filter_map(|p| p.as_string().map(|s| s.to_string())) - .collect::>() - .join(", "), - PhpMixed::String(s) if !s.is_empty() => s.clone(), - _ => ".".to_string(), - }; - let name_disp = if name.is_empty() { "*" } else { name }; - self.get_io() - .write(&format!("{} => {}", name_disp, path_str)); - } - } - } else if r#type == "classmap" - && let PhpMixed::List(l) = autoloads - { - let joined: Vec = l - .iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect(); - self.get_io().write(&joined.join(", ")); + if input.borrow().get_option("self")?.as_bool() == Some(true) + && input.borrow().get_option("installed")?.as_bool() != Some(true) + && input.borrow().get_option("locked")?.as_bool() != Some(true) + { + let composer = self.require_composer(None, None)?; + let package = crate::package::RootPackageInterfaceHandle::dup( + composer.borrow_partial().get_package(), + ); + if input.borrow().get_option("name-only")?.as_bool() == Some(true) { + self.get_io().write(&package.get_name()); + + return Ok(0); + } + if input + .borrow() + .get_argument("package")? + .as_string() + .is_some() + { + return Err(InvalidArgumentException { + message: "You cannot use --self together with a package name".to_string(), + code: 0, } + .into()); } - let include_paths = package.get_include_paths(); - if !include_paths.is_empty() { - self.get_io().write("include-path"); - self.get_io().write(&include_paths.join(", ")); + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + RepositoryInterfaceHandle::new(RootPackageRepository::new(package.clone())), + ])); + repos = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + RepositoryInterfaceHandle::new(RootPackageRepository::new(package.clone())), + ])); + single_package = Some(package.into()); + } else if input.borrow().get_option("platform")?.as_bool() == Some(true) { + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + platform_repo.clone().into(), + ])); + repos = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + platform_repo.clone().into(), + ])); + } else if input.borrow().get_option("available")?.as_bool() == Some(true) { + let mut ir = InstalledRepository::new(vec![platform_repo.clone().into()]); + if let Some(ref composer) = composer { + let composer = crate::composer::composer_full(composer); + repos = RepositoryInterfaceHandle::new(CompositeRepository::new( + composer + .get_repository_manager() + .borrow() + .get_repositories() + .to_vec(), + )); + ir.add_repository( + composer + .get_repository_manager() + .borrow() + .get_local_repository(), + ); + installed_repo = RepositoryInterfaceHandle::new(ir); + } else { + let default_repos = + RepositoryFactory::default_repos_with_default_manager(self.get_io())?; + let names: Vec = default_repos.keys().cloned().collect(); + repos = RepositoryInterfaceHandle::new(CompositeRepository::new( + default_repos.into_values().collect(), + )); + self.get_io().write_error(&format!( + "No composer.json found in the current directory, showing available packages from {}", + names.join(", ") + )); + installed_repo = RepositoryInterfaceHandle::new(ir); + } + } else if input.borrow().get_option("all")?.as_bool() == Some(true) && composer.is_some() { + let composer_ref = crate::composer::composer_full(composer.as_ref().unwrap()); + let local_repo = composer_ref + .get_repository_manager() + .borrow() + .get_local_repository(); + let locker_rc = composer_ref.get_locker().clone(); + let mut locker = locker_rc.borrow_mut(); + if locker.is_locked() { + let lr_handle: RepositoryInterfaceHandle = + locker.get_locked_repository(true)?.into(); + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + lr_handle.clone(), + local_repo, + platform_repo.clone().into(), + ])); + locked_repo = Some(lr_handle); + } else { + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + local_repo, + platform_repo.clone().into(), + ])); + } + let mut composite_input: Vec = + vec![RepositoryInterfaceHandle::new(FilterRepository::new( + installed_repo.clone(), + { + let mut m = IndexMap::new(); + m.insert("canonical".to_string(), PhpMixed::Bool(false)); + m + }, + )?)]; + for r in composer_ref + .get_repository_manager() + .borrow() + .get_repositories() + { + composite_input.push(r.clone()); + } + repos = RepositoryInterfaceHandle::new(CompositeRepository::new(composite_input)); + } else if input.borrow().get_option("all")?.as_bool() == Some(true) { + let default_repos = + RepositoryFactory::default_repos_with_default_manager(self.get_io())?; + let names: Vec = default_repos.keys().cloned().collect(); + self.get_io().write_error(&format!( + "No composer.json found in the current directory, showing available packages from {}", + names.join(", ") + )); + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + platform_repo.clone().into(), + ])); + let mut composite_input: Vec = vec![installed_repo.clone()]; + for (_k, v) in default_repos.into_iter() { + composite_input.push(v); } - } - - Ok(()) - } - - /// Prints all available versions of this package and highlights the installed one if any. - pub(crate) fn print_versions( - &self, - package: CompletePackageInterfaceHandle, - versions: &IndexMap, - installed_repo: &mut dyn RepositoryInterface, - ) -> anyhow::Result<()> { - let mut versions_keys: Vec = versions.keys().cloned().collect(); - versions_keys = Semver::rsort(versions_keys)?; - - // highlight installed version - let installed_packages = installed_repo.find_packages(&package.get_name(), None)?; - if !installed_packages.is_empty() { - for installed_package in installed_packages.iter() { - let installed_version = installed_package.get_pretty_version(); - let key_map: IndexMap = versions_keys - .iter() - .map(|v| (v.clone(), v.clone())) - .collect(); - if let Some(found) = array_search(&installed_version, &key_map) - && let Some(idx) = versions_keys.iter().position(|v| v == &found) - { - versions_keys[idx] = format!("* {}", installed_version); + repos = RepositoryInterfaceHandle::new(CompositeRepository::new(composite_input)); + } else if input.borrow().get_option("locked")?.as_bool() == Some(true) { + if composer.is_none() + || !crate::composer::composer_full(composer.as_ref().unwrap()) + .get_locker() + .borrow_mut() + .is_locked() + { + return Err(UnexpectedValueException { + message: "A valid composer.json and composer.lock files is required to run this command with --locked".to_string(), + code: 0, } + .into()); } - } - - let versions_str = versions_keys.join(", "); - - self.get_io() - .write(&format!("versions : {}", versions_str)); - - Ok(()) - } - - /// print link objects - pub(crate) fn print_links( - &self, - package: CompletePackageInterfaceHandle, - link_type: &str, - title: Option<&str>, - ) { - let title = title.unwrap_or(link_type); - let io = self.get_io(); - let links = package.get_links_for_type(link_type); - if !links.is_empty() { - io.write(&format!("\n{}", title)); - - for link in links.iter() { - io.write(&format!( - "{} {}", - link.1.get_target(), - link.1.get_pretty_constraint(), - )); + let composer_ref = crate::composer::composer_full(composer.as_ref().unwrap()); + let locker_rc = composer_ref.get_locker().clone(); + let mut locker = locker_rc.borrow_mut(); + let lr = locker.get_locked_repository( + input.borrow().get_option("no-dev")?.as_bool() != Some(true), + )?; + if input.borrow().get_option("self")?.as_bool() == Some(true) { + lr.add_package( + crate::package::RootPackageInterfaceHandle::dup(composer_ref.get_package()) + .into(), + )?; } - } - } - - /// Prints the licenses of a package with metadata - pub(crate) fn print_licenses(&self, package: CompletePackageInterfaceHandle) { - let spdx_licenses = SpdxLicenses::new(); - - let licenses = package.get_license(); - let io = self.get_io(); - - for license_id in licenses.iter() { - let license = spdx_licenses.get_license_by_identifier(license_id); - - let out = match license { - None => license_id.clone(), - Some(license) => { - if license.is_osi_approved { - format!( - "{} ({}) (OSI approved) {}", - license.name, license_id, license.url - ) - } else { - format!("{} ({}) {}", license.name, license_id, license.url) - } + let lr_handle: RepositoryInterfaceHandle = lr.into(); + locked_repo = Some(lr_handle.clone()); + let new_repo = + RepositoryInterfaceHandle::new(InstalledRepository::new(vec![lr_handle])); + installed_repo = new_repo.clone(); + repos = new_repo; + } else { + // --installed / default case + let composer_local_owned; + let _guard_from_existing; + let composer_local = match composer.as_ref() { + Some(c) => { + _guard_from_existing = crate::composer::composer_full(c); + &*_guard_from_existing + } + None => { + composer_local_owned = self.require_composer(None, None)?; + _guard_from_existing = crate::composer::composer_full(&composer_local_owned); + &*_guard_from_existing } }; + let root_pkg = composer_local.get_package(); - io.write(&format!("license : {}", out)); - } - } - - /// Prints package info in JSON format. - pub(crate) fn print_package_info_as_json( - &self, - package: CompletePackageInterfaceHandle, - versions: &IndexMap, - installed_repo: &mut dyn RepositoryInterface, - latest_package: Option, - ) -> anyhow::Result<()> { - let mut json: IndexMap = IndexMap::new(); - json.insert( - "name".to_string(), - PhpMixed::String(package.get_pretty_name()), - ); - json.insert( - "description".to_string(), - PhpMixed::String(package.get_description().unwrap_or_default()), - ); - let keywords: Vec = package - .get_keywords() - .into_iter() - .map(PhpMixed::String) - .collect(); - json.insert("keywords".to_string(), PhpMixed::List(keywords)); - json.insert("type".to_string(), PhpMixed::String(package.get_type())); - json.insert( - "homepage".to_string(), - match package.get_homepage() { - Some(h) => PhpMixed::String(h), - None => PhpMixed::Null, - }, - ); - json.insert( - "names".to_string(), - PhpMixed::List( - package - .get_names(true) - .into_iter() - .map(PhpMixed::String) - .collect(), - ), - ); - - json = Self::append_versions(json, versions); - json = Self::append_licenses(json, package.clone()); - - let latest: PackageInterfaceHandle = if let Some(latest) = latest_package { - json.insert( - "latest".to_string(), - PhpMixed::String(latest.get_pretty_version()), - ); - latest - } else { - package.clone().into() - }; - - if package.get_source_type().is_some() { - let mut src: IndexMap = IndexMap::new(); - src.insert( - "type".to_string(), - PhpMixed::String(package.get_source_type().unwrap_or_default()), - ); - src.insert( - "url".to_string(), - PhpMixed::String(package.get_source_url().unwrap_or_default()), - ); - src.insert( - "reference".to_string(), - PhpMixed::String(package.get_source_reference().unwrap_or_default()), - ); - json.insert( - "source".to_string(), - PhpMixed::Array(src.into_iter().collect()), - ); - } + let root_repo: RepositoryInterfaceHandle = + if input.borrow().get_option("self")?.as_bool() == Some(true) { + RepositoryInterfaceHandle::new(RootPackageRepository::new( + crate::package::RootPackageInterfaceHandle::dup( + composer_local.get_package(), + ), + )) + } else { + RepositoryInterfaceHandle::new(InstalledArrayRepository::new()?) + }; + if input.borrow().get_option("no-dev")?.as_bool() == Some(true) { + let local_packages = composer_local + .get_repository_manager() + .borrow() + .get_local_repository() + .get_packages()?; + let packages = RepositoryUtils::filter_required_packages( + &local_packages, + root_pkg.clone().into(), + false, + Vec::new(), + ); + let cloned: Vec = packages + .iter() + .map(crate::package::PackageInterfaceHandle::dup) + .collect(); + let new_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + root_repo, + RepositoryInterfaceHandle::new(InstalledArrayRepository::new_with_packages( + cloned, + )?), + ])); + installed_repo = new_repo.clone(); + repos = new_repo; + } else { + let repository_manager = composer_local.get_repository_manager().clone(); + let repository_manager = repository_manager.borrow(); + let lr = repository_manager.get_local_repository(); + installed_repo = RepositoryInterfaceHandle::new(InstalledRepository::new(vec![ + root_repo.clone(), + lr.clone(), + ])); + repos = + RepositoryInterfaceHandle::new(InstalledRepository::new(vec![root_repo, lr])); + } - if package.get_dist_type().is_some() { - let mut dst: IndexMap = IndexMap::new(); - dst.insert( - "type".to_string(), - PhpMixed::String(package.get_dist_type().unwrap_or_default()), - ); - dst.insert( - "url".to_string(), - PhpMixed::String(package.get_dist_url().unwrap_or_default()), - ); - dst.insert( - "reference".to_string(), - PhpMixed::String(package.get_dist_reference().unwrap_or_default()), - ); - json.insert( - "dist".to_string(), - PhpMixed::Array(dst.into_iter().collect()), - ); - } + if installed_repo.get_packages()?.is_empty() { + let has_non_platform_reqs = |reqs: &IndexMap| -> bool { + reqs.keys() + .any(|name| !PlatformRepository::is_platform_package(name)) + }; - if !PlatformRepository::is_platform_package(&package.get_name()) - && installed_repo.has_package(package.clone().into())? - { - let composer = self.require_composer(None, None)?; - let installation_manager = composer.borrow_partial().get_installation_manager(); - let path: Option = installation_manager - .borrow_mut() - .get_install_path(package.clone().into()); - match path { - Some(p) => { - if let Some(r) = realpath(&p) { - json.insert("path".to_string(), PhpMixed::String(r)); - } - } - None => { - json.insert("path".to_string(), PhpMixed::Null); + if has_non_platform_reqs(&root_pkg.get_requires()) + || has_non_platform_reqs(&root_pkg.get_dev_requires()) + { + // Borrow is local; release composer_local borrow first. + let _ = root_pkg; + self.get_io().write_error("No dependencies installed. Try running composer install or update."); } } - - if let Some(rd) = package.get_release_date() { - json.insert( - "released".to_string(), - PhpMixed::String(rd.format(DATE_ATOM).to_string()), - ); - } } - if let Some(c) = latest.as_complete() - && c.is_abandoned() - { - json.insert( - "replacement".to_string(), - match c.get_replacement_package() { - Some(rp) => PhpMixed::String(rp), - None => PhpMixed::Null, - }, + if let Some(ref composer) = composer { + let composer = crate::composer::composer_full(composer); + let mut command_event = CommandEvent::new6( + PluginEvents::COMMAND, + "show", + input.clone(), + output, + vec![], + IndexMap::new(), ); + let command_event_name = command_event.get_name().to_string(); + composer + .get_event_dispatcher() + .borrow_mut() + .dispatch(Some(&command_event_name), Some(&mut command_event))?; } - if !package.get_suggests().is_empty() { - let mut s: IndexMap = IndexMap::new(); - for (k, v) in package.get_suggests().iter() { - s.insert(k.clone(), PhpMixed::String(v.clone())); - } - json.insert( - "suggests".to_string(), - PhpMixed::Array(s.into_iter().collect()), + if input.borrow().get_option("latest")?.as_bool() == Some(true) && composer.is_none() { + self.get_io().write_error( + "No composer.json found in the current directory, disabling \"latest\" option", ); + input + .borrow_mut() + .set_option("latest", PhpMixed::Bool(false)); } - if !package.get_support().is_empty() { - let mut s: IndexMap = IndexMap::new(); - for (k, v) in package.get_support().iter() { - s.insert(k.clone(), PhpMixed::String(v.clone())); - } - json.insert( - "support".to_string(), - PhpMixed::Array(s.into_iter().collect()), - ); - } + let package_filter: Option = input + .borrow() + .get_argument("package")? + .as_string() + .map(|s| s.to_string()); - json = Self::append_autoload(json, package.clone()); + // show single package or single version + if let Some(ref pkg) = single_package { + versions_map.insert(pkg.get_pretty_version(), pkg.get_version()); + } else if let Some(ref pf) = package_filter + && !pf.contains('*') + { + let (matched_package, vers) = self.get_package( + &installed_repo, + &repos, + pf, + input.borrow().get_argument("version")?, + )?; - if !package.get_include_paths().is_empty() { - json.insert( - "include_path".to_string(), - PhpMixed::List( - package - .get_include_paths() + if let Some(ref pkg) = matched_package + && input.borrow().get_option("direct")?.as_bool() == Some(true) + && !in_array_strict( + pkg.get_name(), + &self + .get_root_requires() .into_iter() .map(PhpMixed::String) - .collect(), - ), - ); - } - - json = Self::append_links(json, package); - - self.get_io().write(&JsonFile::encode(&PhpMixed::Array( - json.into_iter().collect(), - ))?); - Ok(()) - } - - fn append_versions( - mut json: IndexMap, - versions: &IndexMap, - ) -> IndexMap { - let mut versions_pairs: Vec<(String, String)> = versions - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - // uasort($versions, 'version_compare'); - versions_pairs.sort_by(|a, b| { - if version_compare(&a.1, &b.1, "<") { - std::cmp::Ordering::Less - } else if version_compare(&a.1, &b.1, ">") { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Equal - } - }); - versions_pairs.reverse(); - let keys: Vec = versions_pairs - .into_iter() - .map(|(k, _)| PhpMixed::String(k)) - .collect(); - json.insert("versions".to_string(), PhpMixed::List(keys)); - - json - } - - fn append_licenses( - mut json: IndexMap, - package: CompletePackageInterfaceHandle, - ) -> IndexMap { - let licenses = package.get_license(); - if !licenses.is_empty() { - let spdx_licenses = SpdxLicenses::new(); - - let mapped: Vec = licenses - .into_iter() - .map(|license_id| { - let license = spdx_licenses.get_license_by_identifier(&license_id); - match license { - None => PhpMixed::String(license_id), - Some(l) => { - // The 'osi' key holds the license id string, not the OSI-approved flag. - let mut m: IndexMap = IndexMap::new(); - m.insert("name".to_string(), PhpMixed::String(l.name)); - m.insert("osi".to_string(), PhpMixed::String(license_id)); - m.insert("url".to_string(), PhpMixed::String(l.url)); - PhpMixed::Array(m.into_iter().collect()) - } - } - }) - .collect(); - json.insert("licenses".to_string(), PhpMixed::List(mapped)); - } - - json - } - - fn append_autoload( - mut json: IndexMap, - package: CompletePackageInterfaceHandle, - ) -> IndexMap { - let autoload_config = package.get_autoload(); - if !autoload_config.is_empty() { - let mut autoload: IndexMap = IndexMap::new(); - - for (r#type, autoloads) in autoload_config.iter() { - if r#type == "psr-0" || r#type == "psr-4" { - let mut psr: IndexMap = IndexMap::new(); - - if let PhpMixed::Array(map) = autoloads { - for (name, path) in map.iter() { - let mut path_val = path.clone(); - let is_empty_path = match &path_val { - PhpMixed::String(s) => s.is_empty(), - PhpMixed::Null => true, - _ => false, - }; - if is_empty_path { - path_val = PhpMixed::String(".".to_string()); + .collect::>(), + ) + { + return Err(InvalidArgumentException { + message: format!( + "Package \"{}\" is installed but not a direct dependent of the root package.", + pkg.get_name() + ), + code: 0, } + .into()); + } - let key = if name.is_empty() { - "*".to_string() - } else { - name.clone() - }; - psr.insert(key, path_val); - } - } + if matched_package.is_none() { + let options = input.borrow().get_options(); + let mut hint = String::new(); + if input.borrow().get_option("locked")?.as_bool() == Some(true) { + hint.push_str(" in lock file"); + } + if let Some(working_dir) = options.get("working-dir").filter(|v| !v.is_null()) { + hint.push_str(&format!( + " in {}/composer.json", + working_dir.as_string().unwrap_or("") + )); + } + if PlatformRepository::is_platform_package(pf) + && input.borrow().get_option("platform")?.as_bool() != Some(true) + { + hint.push_str(", try using --platform (-p) to show platform packages"); + } + if input.borrow().get_option("all")?.as_bool() != Some(true) + && input.borrow().get_option("available")?.as_bool() != Some(true) + { + hint.push_str(", try using --available (-a) to show all available packages"); + } - autoload.insert(r#type.clone(), PhpMixed::Array(psr.into_iter().collect())); - } else if r#type == "classmap" { - autoload.insert("classmap".to_string(), autoloads.clone()); + return Err(InvalidArgumentException { + message: format!("Package \"{}\" not found{}.", pf, hint), + code: 0, } + .into()); } - - json.insert( - "autoload".to_string(), - PhpMixed::Array(autoload.into_iter().collect()), - ); + single_package = matched_package; + versions_map = vers; } - json - } - - fn append_links( - mut json: IndexMap, - package: CompletePackageInterfaceHandle, - ) -> IndexMap { - for link_type in Link::types().iter() { - json = Self::append_link(json, package.clone(), link_type); - } + if let Some(ref package) = single_package { + // assert(isset($versions)); - json - } + let mut exit_code: i64 = 0; + if input.borrow().get_option("tree")?.as_bool() == Some(true) { + let array_tree = + self.generate_package_tree(package.clone().into(), &installed_repo, &repos); - fn append_link( - mut json: IndexMap, - package: CompletePackageInterfaceHandle, - link_type: &str, - ) -> IndexMap { - let links = package.get_links_for_type(link_type); + if format == "json" { + let mut wrapper: IndexMap = IndexMap::new(); + wrapper.insert( + "installed".to_string(), + PhpMixed::List(vec![PhpMixed::Array(array_tree.into_iter().collect())]), + ); + self.get_io().write(&JsonFile::encode(&PhpMixed::Array( + wrapper.into_iter().collect(), + ))?); + } else { + self.display_package_tree(vec![array_tree]); + } - if !links.is_empty() { - let mut m: IndexMap = IndexMap::new(); - for link in links.iter() { - m.insert( - link.1.get_target().to_string(), - PhpMixed::String(link.1.get_pretty_constraint().to_string()), - ); + return Ok(exit_code); } - json.insert( - link_type.to_string(), - PhpMixed::Array(m.into_iter().collect()), - ); - } - - json - } - - /// Init styles for tree - pub(crate) fn init_styles(&self, output: std::rc::Rc>) { - *self.colors.borrow_mut() = vec![ - "green".to_string(), - "yellow".to_string(), - "cyan".to_string(), - "magenta".to_string(), - "blue".to_string(), - ]; - - for color in self.colors.borrow().iter() { - let style = OutputFormatterStyle::new(Some(color.as_str()), None, vec![]); - output - .borrow() - .get_formatter() - .borrow_mut() - .set_style(color, Box::new(style)); - } - } - /// Display the tree - pub(crate) fn display_package_tree(&self, array_tree: Vec>) { - for package in array_tree.iter() { - let name = package - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - self.get_io() - .write_no_newline(&format!("{}", name)); - let version = package - .get("version") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - self.get_io().write_no_newline(&format!(" {}", version)); - if let Some(description) = package.get("description").and_then(|v| v.as_string()) { - let trimmed = description.split(['\r', '\n']).next().unwrap_or(""); - self.get_io().write(&format!(" {}", trimmed)); - } else { - // output newline - self.get_io().write(""); + let mut latest_package: Option = None; + if input.borrow().get_option("latest")?.as_bool() == Some(true) { + latest_package = self.find_latest_package( + package.clone().into(), + composer.as_ref().unwrap(), + &platform_repo, + input + .borrow() + .get_option("major-only")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("minor-only")? + .as_bool() + .unwrap_or(false), + input + .borrow() + .get_option("patch-only")? + .as_bool() + .unwrap_or(false), + platform_req_filter.clone(), + )?; } - - if let Some(requires) = package.get("requires").and_then(|v| v.as_list()).cloned() { - let mut tree_bar = "├".to_string(); - let mut j = 0_usize; - let total = requires.len(); - for require_mixed in requires.iter() { - let require = match require_mixed.as_array() { - Some(a) => a, - None => continue, - }; - let require_name = require - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - j += 1; - if j == total { - tree_bar = "└".to_string(); - } - let level: usize = 1; - let color = self.colors.borrow().get(level).cloned().unwrap_or_default(); - let info = format!( - "{}──<{}>{} {}", - tree_bar, - color, - require_name, - color, - require - .get("version") - .and_then(|v| v.as_string()) - .unwrap_or("") - ); - self.write_tree_line(&info); - - tree_bar = tree_bar.replace('└', " "); - let packages_in_tree: Vec = vec![ - PhpMixed::String(name.clone()), - PhpMixed::String(require_name.clone()), - ]; - - self.display_tree( - &PhpMixed::Array( - require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ), - &packages_in_tree, - &tree_bar, - level + 1, - ); + if input.borrow().get_option("outdated")?.as_bool() == Some(true) + && input.borrow().get_option("strict")?.as_bool() == Some(true) + && latest_package.is_some() + && latest_package + .as_ref() + .unwrap() + .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) + != package + .get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) + && (latest_package + .as_ref() + .unwrap() + .as_complete() + .is_none_or(|c| !c.is_abandoned())) + { + exit_code = 1; + } + if input.borrow().get_option("path")?.as_bool() == Some(true) { + self.get_io().write_no_newline(&package.get_name()); + let path = { + let composer_ref = composer.as_ref().unwrap(); + composer_ref + .borrow_partial() + .get_installation_manager() + .borrow_mut() + .get_install_path(package.clone().into()) + }; + if let Some(path) = path { + let real = realpath(&path).unwrap_or_default(); + let trimmed = real.split(['\r', '\n']).next().unwrap_or(""); + self.get_io().write(&format!(" {}", trimmed)); + } else { + self.get_io().write(" null"); } + + return Ok(exit_code); } - } - } - /// Generate the package tree - pub(crate) fn generate_package_tree( - &self, - package: PackageInterfaceHandle, - installed_repo: &RepositoryInterfaceHandle, - remote_repos: &RepositoryInterfaceHandle, - ) -> IndexMap { - let requires = { - let mut r: IndexMap = package.get_requires(); - r.sort_keys(); - r - }; - let mut children: Vec = Vec::new(); - for (require_name, require) in requires.iter() { - let packages_in_tree: Vec = vec![ - PhpMixed::String(package.get_name().to_string()), - PhpMixed::String(require_name.clone()), - ]; + if format == "json" { + self.print_package_info_as_json( + package.clone(), + &versions_map, + &mut *installed_repo.borrow_mut(), + latest_package, + )?; + } else { + self.print_package_info( + package.clone(), + &versions_map, + &mut *installed_repo.borrow_mut(), + latest_package, + )?; + } - let mut tree_child_desc: IndexMap = IndexMap::new(); - tree_child_desc.insert("name".to_string(), PhpMixed::String(require_name.clone())); - tree_child_desc.insert( - "version".to_string(), - PhpMixed::String(require.get_pretty_constraint().to_string()), - ); + return Ok(exit_code); + } - let deep_children = self - .add_tree( - require_name, - require, - installed_repo, - remote_repos, - &packages_in_tree, - ) - .unwrap_or_default(); + // show tree view if requested + if input.borrow().get_option("tree")?.as_bool() == Some(true) { + let root_requires = self.get_root_requires(); + let mut packages = installed_repo.get_packages()?; + packages.sort_by(|a, b| { + let sa: String = a.to_string(); + let sb: String = b.to_string(); + sa.cmp(&sb) + }); + let mut array_tree: Vec> = Vec::new(); + for package in packages.iter() { + if in_array_strict( + package.get_name(), + &root_requires + .iter() + .map(|s| PhpMixed::String(s.clone())) + .collect::>(), + ) { + array_tree.push(self.generate_package_tree( + package.clone(), + &installed_repo, + &repos, + )); + } + } - if !deep_children.is_empty() { - tree_child_desc.insert( - "requires".to_string(), + if format == "json" { + let mut wrapper: IndexMap = IndexMap::new(); + wrapper.insert( + "installed".to_string(), PhpMixed::List( - deep_children + array_tree .into_iter() .map(|m| PhpMixed::Array(m.into_iter().collect())) .collect(), ), ); + self.get_io().write(&JsonFile::encode(&PhpMixed::Array( + wrapper.into_iter().collect(), + ))?); + } else { + self.display_package_tree(array_tree); } - children.push(PhpMixed::Array(tree_child_desc.into_iter().collect())); + return Ok(0); } - let mut tree: IndexMap = IndexMap::new(); - tree.insert( - "name".to_string(), - PhpMixed::String(package.get_pretty_name()), - ); - tree.insert( - "version".to_string(), - PhpMixed::String(package.get_pretty_version()), - ); - tree.insert( - "description".to_string(), - match package.as_complete() { - Some(c) => match c.get_description() { - Some(d) => PhpMixed::String(d), - None => PhpMixed::Null, - }, - None => PhpMixed::String(String::new()), - }, - ); - if !children.is_empty() { - tree.insert("requires".to_string(), PhpMixed::List(children)); + // list packages + let mut packages: IndexMap> = IndexMap::new(); + let mut package_filter_regex: Option = None; + if let Some(ref pf) = package_filter { + let escaped = shirabe_php_shim::preg_quote(pf, None); + package_filter_regex = Some(format!("{{^{}$}}i", escaped.replace("\\*", ".*?"))); + } + + let mut package_list_filter: Option> = None; + if input.borrow().get_option("direct")?.as_bool() == Some(true) { + package_list_filter = Some(self.get_root_requires()); + } + + if input.borrow().get_option("path")?.as_bool() == Some(true) && composer.is_none() { + self.get_io().write_error( + "No composer.json found in the current directory, disabling \"path\" option", + ); + input.borrow_mut().set_option("path", PhpMixed::Bool(false)); + } + + for repo in RepositoryUtils::flatten_repositories(repos, true) { + let r#type = if Self::same_repository(&repo, &platform_repo) { + "platform" + } else if locked_repo + .as_ref() + .is_some_and(|lr| Self::same_repository(&repo, lr)) + { + "locked" + } else if Self::same_repository(&repo, &installed_repo) + || installed_repo + .borrow() + .as_any() + .downcast_ref::() + .is_some_and(|ir| ir.get_repositories().iter().any(|r| r.ptr_eq(&repo))) + { + "installed" + } else { + "available" + }; + let type_owned = r#type.to_string(); + if let Some(cr_rc) = repo.downcast_rc::() { + let names = cr_rc + .borrow_mut() + .get_package_names(package_filter.as_deref())?; + for name in names { + packages + .entry(type_owned.clone()) + .or_default() + .insert(name.clone(), PackageOrName::Name(name)); + } + } else { + for package in repo.get_packages()? { + let existing = packages + .get(&type_owned) + .and_then(|m| m.get(&package.get_name())); + let need_replace = match existing { + None => true, + Some(PackageOrName::Name(_)) => true, + Some(PackageOrName::Pkg(existing)) => { + version_compare(&existing.get_version(), &package.get_version(), "<") + } + }; + if need_replace { + let mut p: crate::package::PackageInterfaceHandle = package.clone(); + while let Some(alias) = p.as_alias() { + p = alias.get_alias_of().into(); + } + let matches_filter = match &package_filter_regex { + None => true, + Some(r) => Preg::is_match(r, &p.get_name()), + }; + if matches_filter { + let matches_list = match &package_list_filter { + None => true, + Some(list) => in_array_strict( + p.get_name(), + &list + .iter() + .map(|s| PhpMixed::String(s.clone())) + .collect::>(), + ), + }; + if matches_list { + packages + .entry(type_owned.clone()) + .or_default() + .insert(p.get_name(), PackageOrName::Pkg(p)); + } + } + } + } + if Self::same_repository(&repo, &platform_repo) { + for (name, p) in platform_repo.borrow().get_disabled_packages() { + packages + .entry(type_owned.clone()) + .or_default() + .insert(name.clone(), PackageOrName::Pkg(p.clone().into())); + } + } + } } - tree - } + let show_all_types = input.borrow().get_option("all")?.as_bool() == Some(true); + let show_latest = input.borrow().get_option("latest")?.as_bool() == Some(true); + let show_major_only = input.borrow().get_option("major-only")?.as_bool() == Some(true); + let show_minor_only = input.borrow().get_option("minor-only")?.as_bool() == Some(true); + let show_patch_only = input.borrow().get_option("patch-only")?.as_bool() == Some(true); + let ignored_packages_regex = base_package::package_names_to_regexp( + &input + .borrow() + .get_option("ignore")? + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(strtolower)) + .collect::>() + }) + .unwrap_or_default(), + "{^(?:%s)$}iD", + ); + let indent = if show_all_types { " " } else { "" }; + let mut latest_packages: IndexMap = + IndexMap::new(); + let mut exit_code: i64 = 0; + let mut view_data: IndexMap>> = IndexMap::new(); + let mut view_meta_data: IndexMap = IndexMap::new(); + + let mut write_version = false; + let mut write_description = false; + + let type_order: Vec<(&str, bool)> = vec![ + ("platform", true), + ("locked", true), + ("available", false), + ("installed", true), + ]; + for (r#type, show_version) in type_order.iter() { + if let Some(type_packages) = packages.get_mut(*r#type) { + type_packages.sort_keys(); + + let mut name_length: usize = 0; + let mut version_length: usize = 0; + let mut latest_length: usize = 0; + let mut release_date_length: usize = 0; + + if show_latest && *show_version { + for package_or_name in type_packages.values() { + if let PackageOrName::Pkg(package) = package_or_name + && !Preg::is_match(&ignored_packages_regex, &package.get_pretty_name()) + { + let latest = self.find_latest_package( + package.clone(), + composer.as_ref().unwrap(), + &platform_repo, + show_major_only, + show_minor_only, + show_patch_only, + platform_req_filter.clone(), + )?; + if latest.is_none() { + continue; + } + + latest_packages.insert(package.get_pretty_name(), latest.unwrap()); + } + } + } + + let write_path = input.borrow().get_option("name-only")?.as_bool() != Some(true) + && input.borrow().get_option("path")?.as_bool() == Some(true); + write_version = input.borrow().get_option("name-only")?.as_bool() != Some(true) + && input.borrow().get_option("path")?.as_bool() != Some(true) + && *show_version; + let write_latest = write_version && show_latest; + write_description = input.borrow().get_option("name-only")?.as_bool() != Some(true) + && input.borrow().get_option("path")?.as_bool() != Some(true); + let write_release_date = write_latest + && (input.borrow().get_option("sort-by-age")?.as_bool() == Some(true) + || format == "json"); + + let mut has_outdated_packages = false; - /// Display a package tree - pub(crate) fn display_tree( - &self, - package: &PhpMixed, - packages_in_tree: &[PhpMixed], - previous_tree_bar: &str, - level: usize, - ) { - let previous_tree_bar = previous_tree_bar.replace('├', "│"); - let arr = match package.as_array() { - Some(a) => a, - None => return, - }; - let requires = match arr.get("requires").and_then(|v| v.as_list()).cloned() { - Some(l) => l, - None => return, - }; - let mut tree_bar = format!("{} ├", previous_tree_bar); - let mut i = 0_usize; - let total = requires.len(); - for require_mixed in requires.iter() { - let mut current_tree = packages_in_tree.to_vec(); - i += 1; - if i == total { - tree_bar = format!("{} └", previous_tree_bar); - } - let color_ident = level % self.colors.borrow().len(); - let color = self - .colors - .borrow() - .get(color_ident) - .cloned() - .unwrap_or_default(); + if input.borrow().get_option("sort-by-age")?.as_bool() == Some(true) { + type_packages.sort_by(|_ka, a, _kb, b| match (a, b) { + (PackageOrName::Pkg(a), PackageOrName::Pkg(b)) => { + a.get_release_date().cmp(&b.get_release_date()) + } + _ => std::cmp::Ordering::Equal, + }); + } - let require = match require_mixed.as_array() { - Some(a) => a, - None => continue, - }; - let require_name = require - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - let require_version = require - .get("version") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); + let mut view_type: Vec> = Vec::new(); + for package_or_name in type_packages.values() { + let mut package_view_data: IndexMap = IndexMap::new(); + if let PackageOrName::Pkg(package) = package_or_name { + let latest_package = if show_latest + && latest_packages.contains_key(&package.get_pretty_name()) + { + latest_packages.get(&package.get_pretty_name()) + } else { + None + }; - let circular_warn = if in_array_strict(require_name.clone(), ¤t_tree) { - "(circular dependency aborted here)" - } else { - "" - }; - let info = format!( - "{}──<{}>{} {} {}", - tree_bar, color, require_name, color, require_version, circular_warn - ) - .trim_end() - .to_string(); - self.write_tree_line(&info); + // Determine if Composer is checking outdated dependencies and if current package should trigger non-default exit code + let mut package_is_up_to_date = if let Some(latest) = latest_package { + latest.get_full_pretty_version( + true, + crate::package::DisplayMode::SourceRefIfDev, + ) == package.get_full_pretty_version( + true, + crate::package::DisplayMode::SourceRefIfDev, + ) && latest.as_complete().is_none_or(|c| !c.is_abandoned()) + } else { + false + }; + // When using --major-only, and no bigger version than current major is found then it is considered up to date + package_is_up_to_date = + package_is_up_to_date || (latest_package.is_none() && show_major_only); + let package_is_ignored = + Preg::is_match(&ignored_packages_regex, &package.get_pretty_name()); + if input.borrow().get_option("outdated")?.as_bool() == Some(true) + && (package_is_up_to_date || package_is_ignored) + { + continue; + } - tree_bar = tree_bar.replace('└', " "); + if input.borrow().get_option("outdated")?.as_bool() == Some(true) + || input.borrow().get_option("strict")?.as_bool() == Some(true) + { + has_outdated_packages = true; + } - current_tree.push(PhpMixed::String(require_name.clone())); - self.display_tree(require_mixed, ¤t_tree, &tree_bar, level + 1); - } - } + package_view_data.insert( + "name".to_string(), + PhpMixed::String(package.get_pretty_name()), + ); + package_view_data.insert( + "direct-dependency".to_string(), + PhpMixed::Bool(in_array_strict( + package.get_name(), + &self + .get_root_requires() + .into_iter() + .map(PhpMixed::String) + .collect::>(), + )), + ); + if format != "json" + || input.borrow().get_option("name-only")?.as_bool() != Some(true) + { + package_view_data.insert( + "homepage".to_string(), + match package.as_complete() { + Some(c) => match c.get_homepage() { + Some(h) => PhpMixed::String(h), + None => PhpMixed::Null, + }, + None => PhpMixed::Null, + }, + ); + package_view_data.insert( + "source".to_string(), + match PackageInfo::get_view_source_url(package.clone()) { + Some(s) => PhpMixed::String(s), + None => PhpMixed::Null, + }, + ); + } + name_length = name_length.max(package.get_pretty_name().len()); + if write_version { + let mut version_str = package.get_full_pretty_version( + true, + crate::package::DisplayMode::SourceRefIfDev, + ); + if format == "text" { + version_str = version_str.trim_start_matches('v').to_string(); + } + version_length = version_length.max(version_str.len()); + package_view_data + .insert("version".to_string(), PhpMixed::String(version_str)); + } + if write_release_date { + if let Some(release_date) = package.get_release_date() { + let mut age = self + .get_relative_time(&release_date) + .replace(" ago", " old"); + if !age.contains(" old") { + age = format!("from {}", age); + } + release_date_length = release_date_length.max(age.len()); + package_view_data + .insert("release-age".to_string(), PhpMixed::String(age)); + package_view_data.insert( + "release-date".to_string(), + PhpMixed::String(release_date.format(DATE_ATOM).to_string()), + ); + } else { + package_view_data.insert( + "release-age".to_string(), + PhpMixed::String(String::new()), + ); + package_view_data.insert( + "release-date".to_string(), + PhpMixed::String(String::new()), + ); + } + } + if write_latest && let Some(latest) = latest_package { + let mut latest_version_str = latest.get_full_pretty_version( + true, + crate::package::DisplayMode::SourceRefIfDev, + ); + if format == "text" { + latest_version_str = + latest_version_str.trim_start_matches('v').to_string(); + } + let update_status = + Self::get_update_status(latest.clone(), package.clone())?; + latest_length = latest_length.max(latest_version_str.len()); + package_view_data + .insert("latest".to_string(), PhpMixed::String(latest_version_str)); + package_view_data.insert( + "latest-status".to_string(), + PhpMixed::String(update_status), + ); - /// Display a package tree - pub(crate) fn add_tree( - &self, - name: &str, - link: &Link, - installed_repo: &RepositoryInterfaceHandle, - remote_repos: &RepositoryInterfaceHandle, - packages_in_tree: &[PhpMixed], - ) -> anyhow::Result>> { - let mut children: Vec> = Vec::new(); - let version_arg: PhpMixed = if link.get_pretty_constraint() == "self.version" { - // pass the ConstraintInterface object — signal via Null in this scalar shape - PhpMixed::Null - } else { - PhpMixed::String(link.get_pretty_constraint().to_string()) - }; - let (package, _) = self.get_package(installed_repo, remote_repos, name, version_arg)?; - if let Some(package) = package { - let mut requires = package.get_requires(); - requires.sort_keys(); - for (require_name, require) in requires.iter() { - let mut current_tree = packages_in_tree.to_vec(); + if let Some(rd) = latest.get_release_date() { + package_view_data.insert( + "latest-release-date".to_string(), + PhpMixed::String(rd.format(DATE_ATOM).to_string()), + ); + } else { + package_view_data.insert( + "latest-release-date".to_string(), + PhpMixed::String(String::new()), + ); + } + } else if write_latest { + package_view_data.insert( + "latest".to_string(), + PhpMixed::String("[none matched]".to_string()), + ); + package_view_data.insert( + "latest-status".to_string(), + PhpMixed::String("up-to-date".to_string()), + ); + latest_length = latest_length.max("[none matched]".len()); + } + if write_description && let Some(c) = package.as_complete() { + package_view_data.insert( + "description".to_string(), + match c.get_description() { + Some(d) => PhpMixed::String(d), + None => PhpMixed::Null, + }, + ); + } + if write_path { + let installation_manager = composer + .as_ref() + .unwrap() + .borrow_partial() + .get_installation_manager(); + let path: Option = installation_manager + .borrow_mut() + .get_install_path(package.clone()); + if let Some(p) = path { + let r = realpath(&p).unwrap_or_default(); + let trimmed = r.split(['\r', '\n']).next().unwrap_or(""); + package_view_data.insert( + "path".to_string(), + PhpMixed::String(trimmed.to_string()), + ); + } else { + package_view_data.insert("path".to_string(), PhpMixed::Null); + } + } - let mut tree_child_desc: IndexMap = IndexMap::new(); - tree_child_desc.insert("name".to_string(), PhpMixed::String(require_name.clone())); - tree_child_desc.insert( - "version".to_string(), - PhpMixed::String(require.get_pretty_constraint().to_string()), - ); + let mut package_is_abandoned: PhpMixed = PhpMixed::Bool(false); + if let Some(latest) = latest_package + && let Some(c) = latest.as_complete() + && c.is_abandoned() + { + let replacement_package_name = c.get_replacement_package(); + let replacement = if let Some(ref rp) = replacement_package_name { + format!("Use {} instead", rp) + } else { + "No replacement was suggested".to_string() + }; + let package_warning = format!( + "Package {} is abandoned, you should avoid using it. {}.", + package.get_pretty_name(), + replacement + ); + package_view_data + .insert("warning".to_string(), PhpMixed::String(package_warning)); + package_is_abandoned = match replacement_package_name { + Some(rp) => PhpMixed::String(rp), + None => PhpMixed::Bool(true), + }; + } - if !in_array_strict(require_name.clone(), ¤t_tree) { - current_tree.push(PhpMixed::String(require_name.clone())); - let deep_children = self.add_tree( - require_name, - require, - installed_repo, - remote_repos, - ¤t_tree, - )?; - if !deep_children.is_empty() { - tree_child_desc.insert( - "requires".to_string(), - PhpMixed::List( - deep_children - .into_iter() - .map(|m| PhpMixed::Array(m.into_iter().collect())) - .collect(), - ), - ); + package_view_data.insert("abandoned".to_string(), package_is_abandoned); + } else if let PackageOrName::Name(name) = package_or_name { + package_view_data + .insert("name".to_string(), PhpMixed::String(name.clone())); + name_length = name_length.max(name.len()); } + view_type.push(package_view_data); + } + view_data.insert(r#type.to_string(), view_type); + view_meta_data.insert( + r#type.to_string(), + ViewMetaData { + name_length, + version_length, + latest_length, + release_date_length, + write_latest, + write_release_date, + }, + ); + if input.borrow().get_option("strict")?.as_bool() == Some(true) + && has_outdated_packages + { + exit_code = 1; + break; } - - children.push(tree_child_desc); - } - } - - Ok(children) - } - - fn update_status_to_version_style(update_status: &str) -> &'static str { - // 'up-to-date' is printed green - // 'semver-safe-update' is printed red - // 'update-possible' is printed yellow - match update_status { - "up-to-date" => "info", - "semver-safe-update" => "highlight", - "update-possible" => "comment", - _ => "comment", - } - } - - fn get_update_status( - latest_package: PackageInterfaceHandle, - package: PackageInterfaceHandle, - ) -> anyhow::Result { - if latest_package.get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) - == package.get_full_pretty_version(true, crate::package::DisplayMode::SourceRefIfDev) - { - return Ok("up-to-date".to_string()); - } - - let mut constraint = package.get_version(); - if !constraint.starts_with("dev-") { - constraint = format!("^{}", constraint); - } - if !latest_package.get_version().is_empty() - && Semver::satisfies(latest_package.get_version(), constraint)? - { - // it needs an immediate semver-compliant upgrade - return Ok("semver-safe-update".to_string()); - } - - // it needs an upgrade but has potential BC breaks so is not urgent - Ok("update-possible".to_string()) - } - - fn write_tree_line(&self, line: &str) { - let io = self.get_io(); - let mut line = line.to_string(); - if !io.is_decorated() { - line = line - .replace('└', "`-") - .replace('├', "|-") - .replace("──", "-") - .replace('│', "|"); - } - - io.write(&line); - } - - /// Given a package, this finds the latest package matching it - #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] - fn find_latest_package( - &self, - package: PackageInterfaceHandle, - composer: &PartialComposerHandle, - platform_repo: &PlatformRepositoryHandle, - major_only: bool, - minor_only: bool, - patch_only: bool, - platform_req_filter: std::rc::Rc, - ) -> anyhow::Result> { - // find the latest version allowed in this repo set - let name = package.get_name(); - let repo_set = self.get_repository_set(composer)?; - let composer_ref = crate::composer::composer_full(composer); - let mut version_selector = - VersionSelector::new(repo_set, Some(&mut *platform_repo.borrow_mut()))?; - let mut stability = composer_ref.get_package().get_minimum_stability(); - let flags = composer_ref.get_package().get_stability_flags(); - if let Some(flag_value) = flags.get(&name) { - let key_map: IndexMap = base_package::STABILITIES - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let needle = flag_value.to_string(); - if let Some(found_key) = array_search(&needle, &key_map) { - stability = found_key; } } - let mut best_stability = stability; - if composer_ref.get_package().get_prefer_stable() { - best_stability = package.get_stability(); - } - - let mut target_version: Option = None; - if package.get_version().starts_with("dev-") { - target_version = Some(package.get_version()); - - // dev-x branches are considered to be on the latest major version always, do not look up for a new commit as that is deemed a minor upgrade (albeit risky) - if major_only { - return Ok(None); + if format == "json" { + let mut json_map: IndexMap = IndexMap::new(); + for (k, v) in view_data.iter() { + json_map.insert( + k.clone(), + PhpMixed::List( + v.iter() + .map(|m| { + PhpMixed::Array( + m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + ) + }) + .collect(), + ), + ); } - } - - if target_version.is_none() { - let mut groups: IndexMap = IndexMap::new(); - if major_only - && Preg::is_match3( - php_regex!(r"{^(?P(?:0\.)+)?(?P\d+)\.}"), - &package.get_version(), - Some(&mut groups), - ) + let io = self.get_io(); + io.write(&JsonFile::encode(&PhpMixed::Array( + json_map.into_iter().collect(), + ))?); + } else { + if input.borrow().get_option("latest")?.as_bool() == Some(true) + && view_data.values().any(|v| !v.is_empty()) { - let zero_major = groups - .get(&CaptureKey::ByName("zero_major".to_string())) - .cloned() - .unwrap_or_default(); - let first_meaningful = groups - .get(&CaptureKey::ByName("first_meaningful".to_string())) - .cloned() - .unwrap_or_default() - .parse::() - .unwrap_or(0); - target_version = Some(format!( - ">={}{},<9999999-dev", - zero_major, - first_meaningful + 1 - )); + let io = self.get_io(); + if !io.is_decorated() { + io.write_error("Legend:"); + io.write_error("! patch or minor release available - update recommended"); + io.write_error("~ major release available - update possible"); + if input.borrow().get_option("outdated")?.as_bool() != Some(true) { + io.write_error("= up to date version"); + } + } else { + io.write_error("Color legend:"); + io.write_error("- patch or minor release available - update recommended"); + io.write_error( + "- major release available - update possible", + ); + if input.borrow().get_option("outdated")?.as_bool() != Some(true) { + io.write_error("- up to date version"); + } + } } - if minor_only { - target_version = Some(format!("^{}", package.get_version())); - } + let width = self.get_terminal_width(); - if patch_only { - let trimmed_version = - Preg::replace(php_regex!(r"{(\.0)+$}D"), "", &package.get_version()); - let parts_needed = if trimmed_version.starts_with('0') { - 4 - } else { - 3 + for (r#type, packages) in view_data.iter() { + let meta = match view_meta_data.get(r#type) { + Some(m) => m.clone(), + None => continue, }; - let mut trimmed_version = trimmed_version; - while trimmed_version.chars().filter(|&c| c == '.').count() + 1 < parts_needed { - trimmed_version.push_str(".0"); + let name_length = meta.name_length; + let version_length = meta.version_length; + let mut latest_length = meta.latest_length; + let release_date_length = meta.release_date_length; + let write_latest = meta.write_latest; + let write_release_date = meta.write_release_date; + + let width_usize = width as usize; + let version_fits = name_length + version_length + 3 <= width_usize; + let latest_fits = name_length + version_length + latest_length + 3 <= width_usize; + let release_date_fits = + name_length + version_length + latest_length + release_date_length + 3 + <= width_usize; + let description_fits = + name_length + version_length + latest_length + release_date_length + 24 + <= width_usize; + + if latest_fits && !self.get_io().is_decorated() { + latest_length += 2; } - target_version = Some(format!("~{}", trimmed_version)); - } - } - let show_warnings = if self.get_io().is_verbose() { - ShowWarnings::Always - } else { - let package_version = package.get_version(); - ShowWarnings::Predicate(Box::new( - move |candidate: &PackageInterfaceHandle| -> bool { - if candidate.get_version().starts_with("dev-") - || package_version.starts_with("dev-") - { - return false; + if show_all_types { + if r#type == "available" { + self.get_io() + .write(&format!("{}:", r#type)); + } else { + self.get_io().write(&format!("{}:", r#type)); } + } - version_compare(&candidate.get_version(), &package_version, "<=") - }, - )) - }; - let mut candidate = version_selector.find_best_candidate( - &name, - target_version.as_deref(), - &best_stability, - Some(platform_req_filter), - 0, - Some(self.get_io().clone()), - show_warnings, - )?; - while let Some(ref c) = candidate { - if let Some(alias) = c.as_alias() { - candidate = Some(alias.get_alias_of().into()); - } else { - break; + if write_latest && input.borrow().get_option("direct")?.as_bool() != Some(true) { + let mut direct_deps: Vec> = Vec::new(); + let mut transitive_deps: Vec> = Vec::new(); + for pkg in packages.iter() { + let is_direct = pkg + .get("direct-dependency") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if is_direct { + direct_deps.push(pkg.clone()); + } else { + transitive_deps.push(pkg.clone()); + } + } + + self.get_io().write_error(""); + self.get_io() + .write_error("Direct dependencies required in composer.json:"); + if !direct_deps.is_empty() { + self.print_packages( + &direct_deps, + indent, + write_version && version_fits, + latest_fits, + write_description && description_fits, + width_usize, + version_length, + name_length, + latest_length, + write_release_date && release_date_fits, + release_date_length, + ); + } else { + self.get_io().write_error("Everything up to date"); + } + self.get_io().write_error(""); + self.get_io().write_error( + "Transitive dependencies not required in composer.json:", + ); + if !transitive_deps.is_empty() { + self.print_packages( + &transitive_deps, + indent, + write_version && version_fits, + latest_fits, + write_description && description_fits, + width_usize, + version_length, + name_length, + latest_length, + write_release_date && release_date_fits, + release_date_length, + ); + } else { + self.get_io().write_error("Everything up to date"); + } + } else { + if write_latest && packages.is_empty() { + self.get_io() + .write_error("All your direct dependencies are up to date"); + } else { + self.print_packages( + packages, + indent, + write_version && version_fits, + write_latest && latest_fits, + write_description && description_fits, + width_usize, + version_length, + name_length, + latest_length, + write_release_date && release_date_fits, + release_date_length, + ); + } + } + + if show_all_types { + self.get_io().write(""); + } } } - Ok(candidate) + Ok(exit_code) } - fn get_repository_set( + fn initialize( &self, - composer: &PartialComposerHandle, - ) -> anyhow::Result>> { - let composer = crate::composer::composer_full(composer); - if self.repository_set.borrow().is_none() { - let mut rs = RepositorySet::new( - &composer.get_package().get_minimum_stability(), - composer.get_package().get_stability_flags(), - Vec::new(), - IndexMap::new(), - IndexMap::new(), - IndexMap::new(), - ); - rs.add_repository(RepositoryInterfaceHandle::new(CompositeRepository::new( - composer - .get_repository_manager() - .borrow() - .get_repositories() - .to_vec(), - )))?; - *self.repository_set.borrow_mut() = Some(std::rc::Rc::new(std::cell::RefCell::new(rs))); - } - - Ok(self.repository_set.borrow().as_ref().unwrap().clone()) + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + base_command_initialize(self, input, output) } - fn get_relative_time(&self, release_date: &chrono::DateTime) -> String { - if release_date - .format(date_format_to_strftime("Ymd")) - .to_string() - == date("Ymd", None) - { - return "today".to_string(); - } - - let diff = chrono::Utc::now().signed_duration_since(*release_date); - let days = diff.num_days(); - if days < 7 { - return "this week".to_string(); - } - - if days < 14 { - return "last week".to_string(); - } - - let months = days / 30; - if months < 1 && days < 31 { - return format!("{} weeks ago", days / 7); - } - - let years = days / 365; - if years < 1 { - return format!("{} month{} ago", months, if months > 1 { "s" } else { "" }); - } - - format!("{} year{} ago", years, if years > 1 { "s" } else { "" }) + fn complete( + &self, + input: &shirabe_external_packages::symfony::console::completion::completion_input::CompletionInput, + suggestions: &mut shirabe_external_packages::symfony::console::completion::completion_suggestions::CompletionSuggestions, + ) -> anyhow::Result<()> { + crate::command::base_command::base_command_complete(self, input, suggestions) } - fn same_repository(a: &T, b: &U) -> bool - where - T: Into + Clone, - U: Into + Clone, - { - let a = a.clone().into(); - let b = b.clone().into(); - Self::same_repository_handle(&a, &b) - } + shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); +} - fn same_repository_handle( - a: &RepositoryInterfaceHandle, - b: &RepositoryInterfaceHandle, - ) -> bool { - a.ptr_eq(b) +impl BaseCommand for ShowCommand { + fn base_command_data(&self) -> &crate::command::BaseCommandData { + &self.base_command_data } + + crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } #[derive(Debug)] diff --git a/crates/shirabe/src/command/update_command.rs b/crates/shirabe/src/command/update_command.rs index 1885e8eb..c928552a 100644 --- a/crates/shirabe/src/command/update_command.rs +++ b/crates/shirabe/src/command/update_command.rs @@ -62,6 +62,202 @@ impl UpdateCommand { .expect("UpdateCommand::configure uses static, valid metadata"); command } + + fn get_packages_interactively( + &self, + io: std::rc::Rc>, + input: std::rc::Rc>, + output: std::rc::Rc>, + composer: &PartialComposerHandle, + packages: Vec, + ) -> anyhow::Result> { + if !input.borrow().is_interactive() { + return Err(InvalidArgumentException { + message: "--interactive cannot be used in non-interactive terminals.".to_string(), + code: 0, + } + .into()); + } + + let composer_ref = crate::composer::composer_full(composer); + let platform_req_filter = self.get_platform_requirement_filter(input); + let stability_flags = composer_ref.get_package().get_stability_flags(); + let requires = array_merge_map( + composer_ref.get_package().get_requires(), + composer_ref.get_package().get_dev_requires(), + ); + + let filter: Option = if !packages.is_empty() { + Some(base_package::package_names_to_regexp(&packages, "%s")) + } else { + None + }; + + io.write_error3( + "Loading packages that can be updated...", + true, + io_interface::NORMAL, + ); + let mut autocompleter_values: IndexMap = IndexMap::new(); + let installed_packages: Vec = + if composer_ref.get_locker().borrow_mut().is_locked() { + let locked_repo = composer_ref + .get_locker() + .borrow_mut() + .get_locked_repository(true)?; + locked_repo.borrow_mut().get_canonical_packages()? + } else { + composer_ref + .get_repository_manager() + .borrow() + .get_local_repository() + .get_packages()? + }; + let mut version_selector = self.create_version_selector(composer)?; + for package in &installed_packages { + if let Some(filter) = &filter + && !Preg::is_match(filter, &package.get_name()) + { + continue; + } + let current_version = package.get_pretty_version(); + let constraint = requires + .get(&package.get_name()) + .map(|link| link.get_pretty_constraint()); + let stability = match stability_flags.get(&package.get_name()) { + Some(flag) => base_package::STABILITIES + .iter() + .find(|&(_, v)| v == flag) + .map(|(k, _)| k.to_string()) + .unwrap_or_default(), + None => composer_ref.get_package().get_minimum_stability(), + }; + let latest_version = version_selector.find_best_candidate( + &package.get_name(), + constraint, + &stability, + None, + 0, + None, + ShowWarnings::Always, + )?; + let _ = &platform_req_filter; + if let Some(latest) = latest_version + && (package.get_version() != latest.get_version() || latest.is_dev()) + { + autocompleter_values.insert( + package.get_name(), + format!( + "{} => {}", + current_version, + latest.get_pretty_version(), + ), + ); + } + } + if installed_packages.is_empty() { + for (req, _constraint) in &requires { + if PlatformRepository::is_platform_package(req) { + continue; + } + autocompleter_values.insert(req.to_string(), String::new()); + } + } + + if autocompleter_values.is_empty() { + return Err(RuntimeException { + message: "Could not find any package with new versions available".to_string(), + code: 0, + } + .into()); + } + + let select_result = io.select( + "Select packages: (Select more than one value separated by comma) ".to_string(), + PhpMixed::Array( + autocompleter_values + .iter() + .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) + .collect(), + ), + PhpMixed::Bool(false), + PhpMixed::Int(1), + "No package named \"%s\" is installed.".to_string(), + true, + )?; + let packages: Vec = match select_result { + PhpMixed::List(l) => l + .into_iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect(), + _ => Vec::new(), + }; + + let mut table = Table::new(output); + table.set_headers(vec!["Selected packages".into()]); + for package in &packages { + table.add_row(PhpMixed::List(vec![PhpMixed::String(package.clone())]).into()); + } + table.render(); + + if io.ask_confirmation( + format!( + "Would you like to continue and update the above package{} [yes]? ", + if 1 == packages.len() { "" } else { "s" }, + ), + true, + ) { + return Ok(packages); + } + + Err(RuntimeException { + message: "Installation aborted.".to_string(), + code: 0, + } + .into()) + } + + fn create_version_selector( + &self, + composer: &PartialComposerHandle, + ) -> anyhow::Result { + let composer = crate::composer::composer_full(composer); + let root_aliases: Vec = composer + .get_package() + .get_aliases() + .into_iter() + .map(|alias| crate::repository::RootAliasInput { + package: alias.get("package").cloned().unwrap_or_default(), + version: alias.get("version").cloned().unwrap_or_default(), + alias: alias.get("alias").cloned().unwrap_or_default(), + alias_normalized: alias.get("alias_normalized").cloned().unwrap_or_default(), + }) + .collect(); + let mut repository_set = RepositorySet::new( + &composer.get_package().get_minimum_stability(), + composer.get_package().get_stability_flags(), + root_aliases, + composer.get_package().get_references(), + IndexMap::new(), + IndexMap::new(), + ); + let repositories: Vec = composer + .get_repository_manager() + .borrow() + .get_repositories() + .iter() + .filter(|repository| !repository.is::()) + .cloned() + .collect(); + repository_set.add_repository(crate::repository::RepositoryInterfaceHandle::new( + CompositeRepository::new(repositories), + ))?; + + VersionSelector::new( + std::rc::Rc::new(std::cell::RefCell::new(repository_set)), + None, + ) + } } impl Command for UpdateCommand { @@ -591,201 +787,3 @@ impl BaseCommand for UpdateCommand { crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } - -impl UpdateCommand { - fn get_packages_interactively( - &self, - io: std::rc::Rc>, - input: std::rc::Rc>, - output: std::rc::Rc>, - composer: &PartialComposerHandle, - packages: Vec, - ) -> anyhow::Result> { - if !input.borrow().is_interactive() { - return Err(InvalidArgumentException { - message: "--interactive cannot be used in non-interactive terminals.".to_string(), - code: 0, - } - .into()); - } - - let composer_ref = crate::composer::composer_full(composer); - let platform_req_filter = self.get_platform_requirement_filter(input); - let stability_flags = composer_ref.get_package().get_stability_flags(); - let requires = array_merge_map( - composer_ref.get_package().get_requires(), - composer_ref.get_package().get_dev_requires(), - ); - - let filter: Option = if !packages.is_empty() { - Some(base_package::package_names_to_regexp(&packages, "%s")) - } else { - None - }; - - io.write_error3( - "Loading packages that can be updated...", - true, - io_interface::NORMAL, - ); - let mut autocompleter_values: IndexMap = IndexMap::new(); - let installed_packages: Vec = - if composer_ref.get_locker().borrow_mut().is_locked() { - let locked_repo = composer_ref - .get_locker() - .borrow_mut() - .get_locked_repository(true)?; - locked_repo.borrow_mut().get_canonical_packages()? - } else { - composer_ref - .get_repository_manager() - .borrow() - .get_local_repository() - .get_packages()? - }; - let mut version_selector = self.create_version_selector(composer)?; - for package in &installed_packages { - if let Some(filter) = &filter - && !Preg::is_match(filter, &package.get_name()) - { - continue; - } - let current_version = package.get_pretty_version(); - let constraint = requires - .get(&package.get_name()) - .map(|link| link.get_pretty_constraint()); - let stability = match stability_flags.get(&package.get_name()) { - Some(flag) => base_package::STABILITIES - .iter() - .find(|&(_, v)| v == flag) - .map(|(k, _)| k.to_string()) - .unwrap_or_default(), - None => composer_ref.get_package().get_minimum_stability(), - }; - let latest_version = version_selector.find_best_candidate( - &package.get_name(), - constraint, - &stability, - None, - 0, - None, - ShowWarnings::Always, - )?; - let _ = &platform_req_filter; - if let Some(latest) = latest_version - && (package.get_version() != latest.get_version() || latest.is_dev()) - { - autocompleter_values.insert( - package.get_name(), - format!( - "{} => {}", - current_version, - latest.get_pretty_version(), - ), - ); - } - } - if installed_packages.is_empty() { - for (req, _constraint) in &requires { - if PlatformRepository::is_platform_package(req) { - continue; - } - autocompleter_values.insert(req.to_string(), String::new()); - } - } - - if autocompleter_values.is_empty() { - return Err(RuntimeException { - message: "Could not find any package with new versions available".to_string(), - code: 0, - } - .into()); - } - - let select_result = io.select( - "Select packages: (Select more than one value separated by comma) ".to_string(), - PhpMixed::Array( - autocompleter_values - .iter() - .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) - .collect(), - ), - PhpMixed::Bool(false), - PhpMixed::Int(1), - "No package named \"%s\" is installed.".to_string(), - true, - )?; - let packages: Vec = match select_result { - PhpMixed::List(l) => l - .into_iter() - .filter_map(|v| v.as_string().map(|s| s.to_string())) - .collect(), - _ => Vec::new(), - }; - - let mut table = Table::new(output); - table.set_headers(vec!["Selected packages".into()]); - for package in &packages { - table.add_row(PhpMixed::List(vec![PhpMixed::String(package.clone())]).into()); - } - table.render(); - - if io.ask_confirmation( - format!( - "Would you like to continue and update the above package{} [yes]? ", - if 1 == packages.len() { "" } else { "s" }, - ), - true, - ) { - return Ok(packages); - } - - Err(RuntimeException { - message: "Installation aborted.".to_string(), - code: 0, - } - .into()) - } - - fn create_version_selector( - &self, - composer: &PartialComposerHandle, - ) -> anyhow::Result { - let composer = crate::composer::composer_full(composer); - let root_aliases: Vec = composer - .get_package() - .get_aliases() - .into_iter() - .map(|alias| crate::repository::RootAliasInput { - package: alias.get("package").cloned().unwrap_or_default(), - version: alias.get("version").cloned().unwrap_or_default(), - alias: alias.get("alias").cloned().unwrap_or_default(), - alias_normalized: alias.get("alias_normalized").cloned().unwrap_or_default(), - }) - .collect(); - let mut repository_set = RepositorySet::new( - &composer.get_package().get_minimum_stability(), - composer.get_package().get_stability_flags(), - root_aliases, - composer.get_package().get_references(), - IndexMap::new(), - IndexMap::new(), - ); - let repositories: Vec = composer - .get_repository_manager() - .borrow() - .get_repositories() - .iter() - .filter(|repository| !repository.is::()) - .cloned() - .collect(); - repository_set.add_repository(crate::repository::RepositoryInterfaceHandle::new( - CompositeRepository::new(repositories), - ))?; - - VersionSelector::new( - std::rc::Rc::new(std::cell::RefCell::new(repository_set)), - None, - ) - } -} diff --git a/crates/shirabe/src/command/validate_command.rs b/crates/shirabe/src/command/validate_command.rs index 0a89b71d..c663c868 100644 --- a/crates/shirabe/src/command/validate_command.rs +++ b/crates/shirabe/src/command/validate_command.rs @@ -41,6 +41,100 @@ impl ValidateCommand { .expect("ValidateCommand::configure uses static, valid metadata"); command } + + #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] + fn output_result( + &self, + io: std::rc::Rc>, + name: &str, + errors: &mut Vec, + warnings: &mut Vec, + check_publish: bool, + publish_errors: &mut Vec, + check_lock: bool, + lock_errors: &mut Vec, + print_schema_url: bool, + ) { + let mut do_print_schema_url = false; + + if !errors.is_empty() { + io.write_error(&format!( + "{} is invalid, the following errors/warnings were found:", + name + )); + } else if !publish_errors.is_empty() && check_publish { + io.write_error(&format!( + "{} is valid for simple usage with Composer but has", + name + )); + io.write_error( + "strict errors that make it unable to be published as a package", + ); + do_print_schema_url = print_schema_url; + } else if !warnings.is_empty() { + io.write_error(&format!( + "{} is valid, but with a few warnings", + name + )); + do_print_schema_url = print_schema_url; + } else if !lock_errors.is_empty() { + io.write(&format!( + "{} is valid but your composer.lock has some {}", + name, + if check_lock { "errors" } else { "warnings" } + )); + } else { + io.write(&format!("{} is valid", name)); + } + + if do_print_schema_url { + io.write_error("See https://getcomposer.org/doc/04-schema.md for details on the schema"); + } + + if !errors.is_empty() { + *errors = errors.iter().map(|e| format!("- {}", e)).collect(); + errors.insert(0, "# General errors".to_string()); + } + if !warnings.is_empty() { + *warnings = warnings.iter().map(|w| format!("- {}", w)).collect(); + warnings.insert(0, "# General warnings".to_string()); + } + + let mut extra_warnings: Vec = vec![]; + + if !publish_errors.is_empty() && check_publish { + *publish_errors = publish_errors.iter().map(|e| format!("- {}", e)).collect(); + publish_errors.insert(0, "# Publish errors".to_string()); + errors.append(publish_errors); + } + + if !lock_errors.is_empty() { + if check_lock { + lock_errors.insert(0, "# Lock file errors".to_string()); + errors.append(lock_errors); + } else { + lock_errors.insert(0, "# Lock file warnings".to_string()); + extra_warnings.append(lock_errors); + } + } + + let all_warnings: Vec = warnings.iter().cloned().chain(extra_warnings).collect(); + + for msg in errors.iter() { + if msg.starts_with('#') { + io.write_error(&format!("{}", msg)); + } else { + io.write_error(msg); + } + } + for msg in &all_warnings { + if msg.starts_with('#') { + io.write_error(&format!("{}", msg)); + } else { + io.write_error(msg); + } + } + } } impl Command for ValidateCommand { @@ -328,99 +422,3 @@ impl BaseCommand for ValidateCommand { crate::delegate_base_command_trait_impls_to_inner!(base_command_data); } - -impl ValidateCommand { - #[allow(clippy::too_many_arguments, reason = "to keep PHP signature")] - fn output_result( - &self, - io: std::rc::Rc>, - name: &str, - errors: &mut Vec, - warnings: &mut Vec, - check_publish: bool, - publish_errors: &mut Vec, - check_lock: bool, - lock_errors: &mut Vec, - print_schema_url: bool, - ) { - let mut do_print_schema_url = false; - - if !errors.is_empty() { - io.write_error(&format!( - "{} is invalid, the following errors/warnings were found:", - name - )); - } else if !publish_errors.is_empty() && check_publish { - io.write_error(&format!( - "{} is valid for simple usage with Composer but has", - name - )); - io.write_error( - "strict errors that make it unable to be published as a package", - ); - do_print_schema_url = print_schema_url; - } else if !warnings.is_empty() { - io.write_error(&format!( - "{} is valid, but with a few warnings", - name - )); - do_print_schema_url = print_schema_url; - } else if !lock_errors.is_empty() { - io.write(&format!( - "{} is valid but your composer.lock has some {}", - name, - if check_lock { "errors" } else { "warnings" } - )); - } else { - io.write(&format!("{} is valid", name)); - } - - if do_print_schema_url { - io.write_error("See https://getcomposer.org/doc/04-schema.md for details on the schema"); - } - - if !errors.is_empty() { - *errors = errors.iter().map(|e| format!("- {}", e)).collect(); - errors.insert(0, "# General errors".to_string()); - } - if !warnings.is_empty() { - *warnings = warnings.iter().map(|w| format!("- {}", w)).collect(); - warnings.insert(0, "# General warnings".to_string()); - } - - let mut extra_warnings: Vec = vec![]; - - if !publish_errors.is_empty() && check_publish { - *publish_errors = publish_errors.iter().map(|e| format!("- {}", e)).collect(); - publish_errors.insert(0, "# Publish errors".to_string()); - errors.append(publish_errors); - } - - if !lock_errors.is_empty() { - if check_lock { - lock_errors.insert(0, "# Lock file errors".to_string()); - errors.append(lock_errors); - } else { - lock_errors.insert(0, "# Lock file warnings".to_string()); - extra_warnings.append(lock_errors); - } - } - - let all_warnings: Vec = warnings.iter().cloned().chain(extra_warnings).collect(); - - for msg in errors.iter() { - if msg.starts_with('#') { - io.write_error(&format!("{}", msg)); - } else { - io.write_error(msg); - } - } - for msg in &all_warnings { - if msg.starts_with('#') { - io.write_error(&format!("{}", msg)); - } else { - io.write_error(msg); - } - } - } -} diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 4136a830..3ba6a324 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -2616,9 +2616,7 @@ impl ApplicationHandle { .borrow_mut() .get_composer(required, disable_plugins, disable_scripts) } -} -impl ApplicationHandle { /// Runs the current application (Symfony base; `parent::run`). pub fn base_run( &self, diff --git a/crates/shirabe/src/dependency_resolver/request.rs b/crates/shirabe/src/dependency_resolver/request.rs index 0a05f3e4..d78900c0 100644 --- a/crates/shirabe/src/dependency_resolver/request.rs +++ b/crates/shirabe/src/dependency_resolver/request.rs @@ -25,33 +25,7 @@ impl Request { pub const UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE: i64 = UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE; pub const UPDATE_LISTED_WITH_TRANSITIVE_DEPS: i64 = UPDATE_LISTED_WITH_TRANSITIVE_DEPS; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAllowTransitiveDeps { - /// Corresponds to PHP false. - False, - /// \Composer\DependencyResolver\Request::UPDATE_ONLY_LISTED - UpdateOnlyListed, - /// \Composer\DependencyResolver\Request::UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE - UpdateListedWithTransitiveDepsNoRootRequire, - /// \Composer\DependencyResolver\Request::UPDATE_LISTED_WITH_TRANSITIVE_DEPS - UpdateListedWithTransitiveDeps, -} - -#[derive(Debug)] -pub struct Request { - pub(crate) locked_repository: Option, - pub(crate) requires: IndexMap, - pub(crate) fixed_packages: IndexMap, - pub(crate) locked_packages: IndexMap, - pub(crate) fixed_locked_packages: IndexMap, - pub(crate) update_allow_list: Vec, - pub(crate) update_allow_transitive_dependencies: UpdateAllowTransitiveDeps, - restrict_packages: Option>, -} -impl Request { pub fn new(locked_repository: Option) -> Self { Self { locked_repository, @@ -245,3 +219,27 @@ impl Request { self.restrict_packages.as_ref() } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAllowTransitiveDeps { + /// Corresponds to PHP false. + False, + /// \Composer\DependencyResolver\Request::UPDATE_ONLY_LISTED + UpdateOnlyListed, + /// \Composer\DependencyResolver\Request::UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE + UpdateListedWithTransitiveDepsNoRootRequire, + /// \Composer\DependencyResolver\Request::UPDATE_LISTED_WITH_TRANSITIVE_DEPS + UpdateListedWithTransitiveDeps, +} + +#[derive(Debug)] +pub struct Request { + pub(crate) locked_repository: Option, + pub(crate) requires: IndexMap, + pub(crate) fixed_packages: IndexMap, + pub(crate) locked_packages: IndexMap, + pub(crate) fixed_locked_packages: IndexMap, + pub(crate) update_allow_list: Vec, + pub(crate) update_allow_transitive_dependencies: UpdateAllowTransitiveDeps, + restrict_packages: Option>, +} diff --git a/crates/shirabe/src/downloader/file_downloader.rs b/crates/shirabe/src/downloader/file_downloader.rs index d3b08b36..cdcf750e 100644 --- a/crates/shirabe/src/downloader/file_downloader.rs +++ b/crates/shirabe/src/downloader/file_downloader.rs @@ -150,6 +150,234 @@ impl FileDownloader { this } + + /// Shared body of `ChangeReportInterface::get_local_changes`. + /// + /// PHP's `getLocalChanges` calls `$this->download()` / `$this->install()`, which late-bind + /// to the concrete downloader class (e.g. `ArchiveDownloader::install` extracts the archive + /// instead of copying the dist file). The Rust port embeds the parent class as `inner`, so + /// delegating downloaders must pass themselves as `this` to preserve that dispatch. + pub(crate) fn base_get_local_changes( + &self, + this: &dyn DownloaderInterface, + package: PackageInterfaceHandle, + path: &str, + ) -> anyhow::Result> { + let prev_io = std::mem::replace( + &mut *self.io.borrow_mut(), + std::rc::Rc::new(std::cell::RefCell::new(NullIO::new())), + ); + self.io + .borrow() + .borrow_mut() + .load_configuration(&mut self.config.borrow_mut())?; + + let target_dir = Filesystem::trim_trailing_slash(path); + // PHP attaches an onRejected handler to capture the error and drives the promise via + // httpDownloader->wait() / process->wait(); the single-threaded sync bridge block_on's the + // download/install futures, so a rejection surfaces directly as the Err captured below. + let result: anyhow::Result = (|| -> anyhow::Result { + if is_dir(format!("{}_compare", target_dir)) { + self.filesystem + .borrow_mut() + .remove_directory(format!("{}_compare", target_dir))?; + } + + sync_executor::block_on(this.download( + package.clone(), + &format!("{}_compare", target_dir), + None, + false, + ))?; + sync_executor::block_on(this.install( + package.clone(), + &format!("{}_compare", target_dir), + false, + ))?; + + let mut comparer = Comparer::new(); + comparer.set_source(format!("{}_compare", target_dir)); + comparer.set_update(target_dir.clone()); + comparer.do_compare(); + let output = comparer.get_changed_as_string(true, false); + self.filesystem + .borrow_mut() + .remove_directory(format!("{}_compare", target_dir))?; + Ok(output) + })(); + + *self.io.borrow_mut() = prev_io; + + let (e, output) = match result { + Ok(output) => (None, output), + Err(err) => (Some(err), String::new()), + }; + + if let Some(err) = e { + if self.io.borrow().is_debug() { + return Err(err); + } + + return Ok(Some(format!( + "Failed to detect changes: [{}] {}", + get_class(&PhpMixed::Null), + err + ))); + } + + let output = trim(&output, None); + + Ok(if strlen(&output) > 0 { + Some(output) + } else { + None + }) + } + + /// Shared body of `DownloaderInterface::update`; see `base_get_local_changes` for why the + /// concrete downloader is threaded in as `this`. The appendix is computed by the caller + /// because `getInstallOperationAppendix` is protected and not part of `DownloaderInterface`. + pub(crate) async fn base_update( + &self, + this: &dyn DownloaderInterface, + install_operation_appendix: &str, + initial: PackageInterfaceHandle, + target: PackageInterfaceHandle, + path: &str, + ) -> anyhow::Result> { + self.io.borrow().write_error(&format!( + " - {}{}", + UpdateOperation::format(initial.clone(), target.clone(), false), + install_operation_appendix + )); + + // PHP: return $this->remove($initial, $path, false)->then(fn () => $this->install($target, $path, false)); + let _ = this.remove(initial, path, false).await?; + this.install(target, path, false).await + } + + fn get_dist_path(&self, package: PackageInterfaceHandle, component: i64) -> String { + pathinfo( + parse_url( + &strtr(&package.get_dist_url().unwrap_or_default(), "\\", "/"), + PHP_URL_PATH, + ) + .as_string() + .unwrap_or(""), + component, + ) + } + + pub(crate) fn clear_last_cache_write(&self, package: PackageInterfaceHandle) { + let mut last_cache_writes = self.last_cache_writes.lock().unwrap(); + if let Some(cache) = &self.cache + && last_cache_writes.contains_key(&package.get_name()) + { + let key = last_cache_writes.get(&package.get_name()).unwrap().clone(); + cache.borrow_mut().remove(&key); + last_cache_writes.shift_remove(&package.get_name()); + } + } + + pub(crate) fn add_cleanup_path(&self, package: PackageInterfaceHandle, path: &str) { + self.additional_cleanup_paths + .borrow_mut() + .entry(package.get_name()) + .or_default() + .push(path.to_string()); + } + + pub(crate) fn remove_cleanup_path(&self, package: PackageInterfaceHandle, path: &str) { + if let Some(paths) = self + .additional_cleanup_paths + .borrow_mut() + .get_mut(&package.get_name()) + { + // PHP: array_search($path, ..., true) + let idx = paths.iter().position(|p| p == path); + if let Some(i) = idx { + paths.remove(i); + } + let _ = array_search; + } + } + + /// Gets file name for specific package + pub(crate) fn get_file_name(&self, package: PackageInterfaceHandle, _path: &str) -> String { + let extension = self.get_dist_path(package.clone(), PATHINFO_EXTENSION); + let extension = if extension.is_empty() { + package.get_dist_type().unwrap_or_default() + } else { + extension + }; + + rtrim( + &format!( + "{}/composer/tmp-{}.{}", + self.config + .borrow_mut() + .get("vendor-dir") + .as_string() + .unwrap_or(""), + hash( + "md5", + &format!("{}{}", package, spl_object_hash(&PhpMixed::Null)) + ), + extension + ), + Some("."), + ) + } + + /// Gets appendix message to add to the "- Upgrading x" string being output on update + fn get_install_operation_appendix( + &self, + _package: PackageInterfaceHandle, + _path: &str, + ) -> String { + String::new() + } + + /// For testing only: invoke the crate-private `get_file_name`. + pub fn __get_file_name(&self, package: PackageInterfaceHandle, path: &str) -> String { + self.get_file_name(package, path) + } + + /// For testing only: invoke the crate-private `process_url`. + pub fn __process_url( + &self, + package: PackageInterfaceHandle, + url: &str, + ) -> anyhow::Result { + self.process_url(package, url) + } + + /// Process the download url + pub(crate) fn process_url( + &self, + package: PackageInterfaceHandle, + url: &str, + ) -> anyhow::Result { + if !shirabe_php_shim::extension_loaded("openssl") && Some(0) == strpos(url, "https:") { + return Err(RuntimeException { + message: "You must enable the openssl extension to download files via https" + .to_string(), + code: 0, + } + .into()); + } + + let mut url = url.to_string(); + if package.get_dist_reference().is_some() { + url = UrlUtil::update_dist_reference( + &self.config.borrow(), + url, + &package.get_dist_reference().unwrap(), + ); + } + + Ok(url) + } } #[async_trait::async_trait(?Send)] @@ -604,238 +832,6 @@ impl ChangeReportInterface for FileDownloader { } } -impl FileDownloader { - /// Shared body of `ChangeReportInterface::get_local_changes`. - /// - /// PHP's `getLocalChanges` calls `$this->download()` / `$this->install()`, which late-bind - /// to the concrete downloader class (e.g. `ArchiveDownloader::install` extracts the archive - /// instead of copying the dist file). The Rust port embeds the parent class as `inner`, so - /// delegating downloaders must pass themselves as `this` to preserve that dispatch. - pub(crate) fn base_get_local_changes( - &self, - this: &dyn DownloaderInterface, - package: PackageInterfaceHandle, - path: &str, - ) -> anyhow::Result> { - let prev_io = std::mem::replace( - &mut *self.io.borrow_mut(), - std::rc::Rc::new(std::cell::RefCell::new(NullIO::new())), - ); - self.io - .borrow() - .borrow_mut() - .load_configuration(&mut self.config.borrow_mut())?; - - let target_dir = Filesystem::trim_trailing_slash(path); - // PHP attaches an onRejected handler to capture the error and drives the promise via - // httpDownloader->wait() / process->wait(); the single-threaded sync bridge block_on's the - // download/install futures, so a rejection surfaces directly as the Err captured below. - let result: anyhow::Result = (|| -> anyhow::Result { - if is_dir(format!("{}_compare", target_dir)) { - self.filesystem - .borrow_mut() - .remove_directory(format!("{}_compare", target_dir))?; - } - - sync_executor::block_on(this.download( - package.clone(), - &format!("{}_compare", target_dir), - None, - false, - ))?; - sync_executor::block_on(this.install( - package.clone(), - &format!("{}_compare", target_dir), - false, - ))?; - - let mut comparer = Comparer::new(); - comparer.set_source(format!("{}_compare", target_dir)); - comparer.set_update(target_dir.clone()); - comparer.do_compare(); - let output = comparer.get_changed_as_string(true, false); - self.filesystem - .borrow_mut() - .remove_directory(format!("{}_compare", target_dir))?; - Ok(output) - })(); - - *self.io.borrow_mut() = prev_io; - - let (e, output) = match result { - Ok(output) => (None, output), - Err(err) => (Some(err), String::new()), - }; - - if let Some(err) = e { - if self.io.borrow().is_debug() { - return Err(err); - } - - return Ok(Some(format!( - "Failed to detect changes: [{}] {}", - get_class(&PhpMixed::Null), - err - ))); - } - - let output = trim(&output, None); - - Ok(if strlen(&output) > 0 { - Some(output) - } else { - None - }) - } - - /// Shared body of `DownloaderInterface::update`; see `base_get_local_changes` for why the - /// concrete downloader is threaded in as `this`. The appendix is computed by the caller - /// because `getInstallOperationAppendix` is protected and not part of `DownloaderInterface`. - pub(crate) async fn base_update( - &self, - this: &dyn DownloaderInterface, - install_operation_appendix: &str, - initial: PackageInterfaceHandle, - target: PackageInterfaceHandle, - path: &str, - ) -> anyhow::Result> { - self.io.borrow().write_error(&format!( - " - {}{}", - UpdateOperation::format(initial.clone(), target.clone(), false), - install_operation_appendix - )); - - // PHP: return $this->remove($initial, $path, false)->then(fn () => $this->install($target, $path, false)); - let _ = this.remove(initial, path, false).await?; - this.install(target, path, false).await - } -} - -impl FileDownloader { - fn get_dist_path(&self, package: PackageInterfaceHandle, component: i64) -> String { - pathinfo( - parse_url( - &strtr(&package.get_dist_url().unwrap_or_default(), "\\", "/"), - PHP_URL_PATH, - ) - .as_string() - .unwrap_or(""), - component, - ) - } - - pub(crate) fn clear_last_cache_write(&self, package: PackageInterfaceHandle) { - let mut last_cache_writes = self.last_cache_writes.lock().unwrap(); - if let Some(cache) = &self.cache - && last_cache_writes.contains_key(&package.get_name()) - { - let key = last_cache_writes.get(&package.get_name()).unwrap().clone(); - cache.borrow_mut().remove(&key); - last_cache_writes.shift_remove(&package.get_name()); - } - } - - pub(crate) fn add_cleanup_path(&self, package: PackageInterfaceHandle, path: &str) { - self.additional_cleanup_paths - .borrow_mut() - .entry(package.get_name()) - .or_default() - .push(path.to_string()); - } - - pub(crate) fn remove_cleanup_path(&self, package: PackageInterfaceHandle, path: &str) { - if let Some(paths) = self - .additional_cleanup_paths - .borrow_mut() - .get_mut(&package.get_name()) - { - // PHP: array_search($path, ..., true) - let idx = paths.iter().position(|p| p == path); - if let Some(i) = idx { - paths.remove(i); - } - let _ = array_search; - } - } - - /// Gets file name for specific package - pub(crate) fn get_file_name(&self, package: PackageInterfaceHandle, _path: &str) -> String { - let extension = self.get_dist_path(package.clone(), PATHINFO_EXTENSION); - let extension = if extension.is_empty() { - package.get_dist_type().unwrap_or_default() - } else { - extension - }; - - rtrim( - &format!( - "{}/composer/tmp-{}.{}", - self.config - .borrow_mut() - .get("vendor-dir") - .as_string() - .unwrap_or(""), - hash( - "md5", - &format!("{}{}", package, spl_object_hash(&PhpMixed::Null)) - ), - extension - ), - Some("."), - ) - } - - /// Gets appendix message to add to the "- Upgrading x" string being output on update - fn get_install_operation_appendix( - &self, - _package: PackageInterfaceHandle, - _path: &str, - ) -> String { - String::new() - } - - /// For testing only: invoke the crate-private `get_file_name`. - pub fn __get_file_name(&self, package: PackageInterfaceHandle, path: &str) -> String { - self.get_file_name(package, path) - } - - /// For testing only: invoke the crate-private `process_url`. - pub fn __process_url( - &self, - package: PackageInterfaceHandle, - url: &str, - ) -> anyhow::Result { - self.process_url(package, url) - } - - /// Process the download url - pub(crate) fn process_url( - &self, - package: PackageInterfaceHandle, - url: &str, - ) -> anyhow::Result { - if !shirabe_php_shim::extension_loaded("openssl") && Some(0) == strpos(url, "https:") { - return Err(RuntimeException { - message: "You must enable the openssl extension to download files via https" - .to_string(), - code: 0, - } - .into()); - } - - let mut url = url.to_string(); - if package.get_dist_reference().is_some() { - url = UrlUtil::update_dist_reference( - &self.config.borrow(), - url, - &package.get_dist_reference().unwrap(), - ); - } - - Ok(url) - } -} - #[derive(Debug, Clone)] struct UrlEntry { base: String, diff --git a/crates/shirabe/src/package/loader/array_loader.rs b/crates/shirabe/src/package/loader/array_loader.rs index f0399e89..0940183c 100644 --- a/crates/shirabe/src/package/loader/array_loader.rs +++ b/crates/shirabe/src/package/loader/array_loader.rs @@ -39,156 +39,7 @@ impl ArrayLoader { load_options, } } -} - -enum CompleteOrRootPackage { - Complete(CompletePackage), - Root(RootPackage), -} - -impl CompleteOrRootPackage { - fn package(&self) -> &Package { - match self { - Self::Complete(p) => &p.inner, - Self::Root(p) => &p.inner.inner, - } - } - - fn package_mut(&mut self) -> &mut Package { - match self { - Self::Complete(p) => &mut p.inner, - Self::Root(p) => &mut p.inner.inner, - } - } - - fn complete_mut(&mut self) -> &mut dyn CompletePackageInterface { - match self { - Self::Complete(p) => p, - Self::Root(p) => p, - } - } - - fn is_root(&self) -> bool { - matches!(self, Self::Root(_)) - } - - fn get_name(&self) -> &str { - self.package().get_name() - } - - fn get_pretty_version(&self) -> &str { - self.package().get_pretty_version() - } - - fn into_handle(self) -> PackageInterfaceHandle { - match self { - Self::Complete(p) => CompletePackageHandle::from_complete_package(p).into(), - Self::Root(p) => RootPackageHandle::from_root_package(p).into(), - } - } -} - -fn php_to_map(value: &PhpMixed) -> IndexMap { - match value { - PhpMixed::Array(m) => m.clone(), - _ => IndexMap::new(), - } -} - -fn php_to_string_vec(value: &PhpMixed) -> Vec { - match value { - PhpMixed::List(l) => l.iter().map(strval).collect(), - PhpMixed::Array(m) => m.values().map(strval).collect(), - _ => Vec::new(), - } -} - -fn apply_link_setter(package: &mut Package, method: &str, links: IndexMap) { - if method == Link::TYPE_REQUIRE { - package.set_requires(links); - } else if method == Link::TYPE_DEV_REQUIRE { - package.set_dev_requires(links); - } else if method == Link::TYPE_CONFLICT { - package.set_conflicts(links); - } else if method == Link::TYPE_PROVIDE { - package.set_provides(links); - } else if method == Link::TYPE_REPLACE { - package.set_replaces(links); - } -} - -fn php_to_mirrors(value: &PhpMixed) -> Vec { - let entries: Vec<&PhpMixed> = match value { - PhpMixed::List(l) => l.iter().collect(), - PhpMixed::Array(m) => m.values().collect(), - _ => Vec::new(), - }; - entries - .into_iter() - .filter_map(|entry| match entry { - PhpMixed::Array(m) => Some(Mirror { - url: m - .get("url") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(), - preferred: m.get("preferred").is_some_and(|v| v.to_bool()), - }), - _ => None, - }) - .collect() -} - -impl LoaderInterface for ArrayLoader { - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn load( - &self, - mut config: IndexMap, - class: Option, - ) -> anyhow::Result { - let class = class.unwrap_or_else(|| "Composer\\Package\\CompletePackage".to_string()); - - if class != "Composer\\Package\\CompletePackage" - && class != "Composer\\Package\\RootPackage" - { - trigger_error( - "The $class arg is deprecated, please reach out to Composer maintainers ASAP if you still need this.", - E_USER_DEPRECATED, - ); - } - - let mut package = self.create_object(&config, &class)?; - - for (r#type, opts) in SUPPORTED_LINK_TYPES.iter() { - let entry = config.get(*r#type); - let entry_is_array = entry - .map(|v| matches!(v, PhpMixed::Array(_))) - .unwrap_or(false); - if entry.is_none() || !entry_is_array { - continue; - } - let links = self.parse_links( - package.get_name(), - package.get_pretty_version(), - opts.method, - match entry.unwrap() { - PhpMixed::Array(arr) => arr.clone(), - _ => IndexMap::new(), - }, - )?; - apply_link_setter(package.package_mut(), opts.method, links); - } - - let package = self.configure_object(package, &mut config)?; - - Ok(package) - } -} -impl ArrayLoader { #[tracing::instrument(skip_all)] pub fn load_packages( &self, @@ -938,3 +789,150 @@ impl ArrayLoader { Ok(None) } } + +enum CompleteOrRootPackage { + Complete(CompletePackage), + Root(RootPackage), +} + +impl CompleteOrRootPackage { + fn package(&self) -> &Package { + match self { + Self::Complete(p) => &p.inner, + Self::Root(p) => &p.inner.inner, + } + } + + fn package_mut(&mut self) -> &mut Package { + match self { + Self::Complete(p) => &mut p.inner, + Self::Root(p) => &mut p.inner.inner, + } + } + + fn complete_mut(&mut self) -> &mut dyn CompletePackageInterface { + match self { + Self::Complete(p) => p, + Self::Root(p) => p, + } + } + + fn is_root(&self) -> bool { + matches!(self, Self::Root(_)) + } + + fn get_name(&self) -> &str { + self.package().get_name() + } + + fn get_pretty_version(&self) -> &str { + self.package().get_pretty_version() + } + + fn into_handle(self) -> PackageInterfaceHandle { + match self { + Self::Complete(p) => CompletePackageHandle::from_complete_package(p).into(), + Self::Root(p) => RootPackageHandle::from_root_package(p).into(), + } + } +} + +fn php_to_map(value: &PhpMixed) -> IndexMap { + match value { + PhpMixed::Array(m) => m.clone(), + _ => IndexMap::new(), + } +} + +fn php_to_string_vec(value: &PhpMixed) -> Vec { + match value { + PhpMixed::List(l) => l.iter().map(strval).collect(), + PhpMixed::Array(m) => m.values().map(strval).collect(), + _ => Vec::new(), + } +} + +fn apply_link_setter(package: &mut Package, method: &str, links: IndexMap) { + if method == Link::TYPE_REQUIRE { + package.set_requires(links); + } else if method == Link::TYPE_DEV_REQUIRE { + package.set_dev_requires(links); + } else if method == Link::TYPE_CONFLICT { + package.set_conflicts(links); + } else if method == Link::TYPE_PROVIDE { + package.set_provides(links); + } else if method == Link::TYPE_REPLACE { + package.set_replaces(links); + } +} + +fn php_to_mirrors(value: &PhpMixed) -> Vec { + let entries: Vec<&PhpMixed> = match value { + PhpMixed::List(l) => l.iter().collect(), + PhpMixed::Array(m) => m.values().collect(), + _ => Vec::new(), + }; + entries + .into_iter() + .filter_map(|entry| match entry { + PhpMixed::Array(m) => Some(Mirror { + url: m + .get("url") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + preferred: m.get("preferred").is_some_and(|v| v.to_bool()), + }), + _ => None, + }) + .collect() +} + +impl LoaderInterface for ArrayLoader { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn load( + &self, + mut config: IndexMap, + class: Option, + ) -> anyhow::Result { + let class = class.unwrap_or_else(|| "Composer\\Package\\CompletePackage".to_string()); + + if class != "Composer\\Package\\CompletePackage" + && class != "Composer\\Package\\RootPackage" + { + trigger_error( + "The $class arg is deprecated, please reach out to Composer maintainers ASAP if you still need this.", + E_USER_DEPRECATED, + ); + } + + let mut package = self.create_object(&config, &class)?; + + for (r#type, opts) in SUPPORTED_LINK_TYPES.iter() { + let entry = config.get(*r#type); + let entry_is_array = entry + .map(|v| matches!(v, PhpMixed::Array(_))) + .unwrap_or(false); + if entry.is_none() || !entry_is_array { + continue; + } + let links = self.parse_links( + package.get_name(), + package.get_pretty_version(), + opts.method, + match entry.unwrap() { + PhpMixed::Array(arr) => arr.clone(), + _ => IndexMap::new(), + }, + )?; + apply_link_setter(package.package_mut(), opts.method, links); + } + + let package = self.configure_object(package, &mut config)?; + + Ok(package) + } +} diff --git a/crates/shirabe/src/package/loader/validating_array_loader.rs b/crates/shirabe/src/package/loader/validating_array_loader.rs index 0be0ef38..a28df595 100644 --- a/crates/shirabe/src/package/loader/validating_array_loader.rs +++ b/crates/shirabe/src/package/loader/validating_array_loader.rs @@ -60,1548 +60,1546 @@ impl ValidatingArrayLoader { flags, } } -} -impl LoaderInterface for ValidatingArrayLoader { - fn as_any(&self) -> &dyn std::any::Any { - self + pub fn get_warnings(&self) -> Vec { + self.warnings.borrow().clone() } - fn load( - &self, - config: IndexMap, - class: Option, - ) -> anyhow::Result { - let class = class.unwrap_or_else(|| "Composer\\Package\\CompletePackage".to_string()); + pub fn get_errors(&self) -> Vec { + self.errors.borrow().clone() + } - *self.errors.borrow_mut() = Vec::new(); - *self.warnings.borrow_mut() = Vec::new(); - *self.config.borrow_mut() = config.clone(); + pub fn has_package_naming_error(name: &str, is_link: bool) -> Option { + if PlatformRepository::is_platform_package(name) { + return None; + } - self.validate_string("name", true); - if let Some(name_val) = config.get("name").and_then(|v| v.as_string()) - && let Some(err) = Self::has_package_naming_error(name_val, false) - { - self.errors.borrow_mut().push(format!("name : {}", err)); + if !Preg::is_match( + php_regex!( + "{^[a-z0-9](?:[_.-]?[a-z0-9]++)*+/[a-z0-9](?:(?:[_.]|-{1,2})?[a-z0-9]++)*+$}iD" + ), + name, + ) { + return Some(format!( + "{} is invalid, it should have a vendor name, a forward slash, and a package name. The vendor and package name can be words separated by -, . or _. The complete name should match \"^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]?|-{{0,2}})[a-z0-9]+)*$\".", + name + )); } - if self.config.borrow().contains_key("version") { - let version_val = self.config.borrow()["version"].clone(); - if !is_scalar(&version_val) { - self.validate_string("version", false); - } else { - if !is_string(&version_val) { - self.config.borrow_mut().insert( - "version".to_string(), - PhpMixed::String(php_to_string(&version_val)), - ); - } - let version_str = self - .config - .borrow() - .get("version") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - match self.version_parser.normalize(&version_str, None) { - Ok(_) => {} - Err(e) => { - self.errors - .borrow_mut() - .push(format!("version : invalid value ({}): {}", version_str, e)); - self.config.borrow_mut().shift_remove("version"); - } - } - } + let reserved_names = [ + "nul", "con", "prn", "aux", "com1", "com2", "com3", "com4", "com5", "com6", "com7", + "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", + ]; + let lower = strtolower(name); + let bits: Vec<&str> = lower.split('/').collect(); + if reserved_names.contains(&bits[0]) || reserved_names.contains(&bits[1]) { + return Some(format!( + "{} is reserved, package and vendor names can not match any of: {}.", + name, + reserved_names.join(", ") + )); } - if let Some(config_section) = self - .config - .borrow() - .get("config") - .and_then(|v| v.as_array()) - .cloned() - && let Some(platform_val) = config_section.get("platform") - { - let platform_array: IndexMap = match platform_val { - PhpMixed::Array(m) => m.clone(), - other => { - let mut m = IndexMap::new(); - m.insert("0".to_string(), other.clone()); - m - } - }; - for (key, platform) in &platform_array { - if let PhpMixed::Bool(false) = platform { - continue; - } - if !is_string(platform) { - self.errors.borrow_mut().push(format!( - "config.platform.{} : invalid value ({} {}): expected string or false", - key, - get_debug_type(platform), - var_export(platform, true) - )); - continue; - } - let platform_str = platform.as_string().unwrap_or("").to_string(); - if let Err(e) = self.version_parser.normalize(&platform_str, None) { - self.errors.borrow_mut().push(format!( - "config.platform.{} : invalid value ({}): {}", - key, platform_str, e - )); - } + if Preg::is_match(php_regex!("{\\.json$}"), name) { + return Some(format!( + "{} is invalid, package names can not end in .json, consider renaming it or perhaps using a -json suffix instead.", + name + )); + } + + if Preg::is_match(php_regex!("{[A-Z]}"), name) { + if is_link { + return Some(format!( + "{} is invalid, it should not contain uppercase characters. Please use {} instead.", + name, + strtolower(name) + )); } + + let suggest_name = Preg::replace( + php_regex!("{(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))}"), + "\\1\\3-\\2\\4", + name, + ); + let suggest_name = strtolower(&suggest_name); + + return Some(format!( + "{} is invalid, it should not contain uppercase characters. We suggest using {} instead.", + name, suggest_name + )); } - self.validate_regex("type", "[A-Za-z0-9-]+", false); - self.validate_string("target-dir", false); - self.validate_array("extra", false); + None + } - if self.config.borrow().contains_key("bin") { - if is_string(&self.config.borrow()["bin"]) { - self.validate_string("bin", false); + fn validate_regex(&self, property: &str, regex: &str, mandatory: bool) -> bool { + if !self.validate_string(property, mandatory) { + return false; + } + + let value = self.config.borrow()[property] + .as_string() + .unwrap_or("") + .to_string(); + if !Preg::is_match(format!("{{^{}$}}u", regex), &value) { + let message = format!( + "{} : invalid value ({}), must match {}", + property, value, regex + ); + if mandatory { + self.errors.borrow_mut().push(message); } else { - self.validate_flat_array("bin", None, false); + self.warnings.borrow_mut().push(message); } + self.config.borrow_mut().shift_remove(property); + + return false; } - self.validate_array("scripts", false); // TODO validate event names & listener syntax - self.validate_string("description", false); - self.validate_url("homepage", false); - self.validate_flat_array("keywords", Some("[\\p{N}\\p{L} ._-]+"), false); + true + } - let mut release_date: Option> = None; - self.validate_string("time", false); - if self.config.borrow().contains_key("time") { - let time_str = self.config.borrow()["time"] - .as_string() - .unwrap_or("") - .to_string(); - match shirabe_php_shim::date_create::(&time_str) { - Ok(dt) => { - release_date = Some(dt); - } - Err(e) => { - self.errors - .borrow_mut() - .push(format!("time : invalid value ({}): {}", time_str, e)); - self.config.borrow_mut().shift_remove("time"); - } - } + fn validate_string(&self, property: &str, mandatory: bool) -> bool { + if self.config.borrow().contains_key(property) + && !is_string(&self.config.borrow()[property]) + { + self.errors.borrow_mut().push(format!( + "{} : should be a string, {} given", + property, + get_debug_type(&self.config.borrow()[property]) + )); + self.config.borrow_mut().shift_remove(property); + + return false; } - if self.config.borrow().contains_key("license") { - let license_val = self.config.borrow()["license"].clone(); - // validate main data types - if is_array(&license_val) || is_string(&license_val) { - // PHP: `(array) $this->config['license']` — an array (list-shaped included) - // stays as-is; only a scalar is wrapped. - let mut licenses: IndexMap = match &license_val { - PhpMixed::Array(m) => m.clone(), - PhpMixed::List(items) => items - .iter() - .enumerate() - .map(|(i, v)| (i.to_string(), v.clone())) - .collect(), - other => { - let mut m = IndexMap::new(); - m.insert("0".to_string(), other.clone()); - m - } - }; + let is_empty = !self.config.borrow().contains_key(property) + || trim( + self.config.borrow()[property].as_string().unwrap_or(""), + Some(" \t\n\r\0\u{0B}"), + ) + .is_empty(); + if is_empty { + if mandatory { + self.errors + .borrow_mut() + .push(format!("{} : must be present", property)); + } + self.config.borrow_mut().shift_remove(property); - let license_keys: Vec = licenses.keys().cloned().collect(); - for index in &license_keys { - let license = licenses[index].clone(); - if !is_string(&license) { - self.warnings.borrow_mut().push(format!( - "License {} should be a string.", - json_encode(&license).unwrap_or_default(), - )); - licenses.shift_remove(index); - } - } + return false; + } - // check for license validity on newly updated branches/tags - let cutoff = strtotime("-8days").unwrap_or(0); - if release_date.is_none() || release_date.unwrap().timestamp() >= cutoff { - let license_validator = SpdxLicenses::new(); - for license in licenses.values() { - let license_str = license.as_string().unwrap_or("").to_string(); - // replace proprietary by MIT for validation purposes since it's not a valid SPDX identifier, but is accepted by composer - if license_str == "proprietary" { - continue; - } - let license_to_validate = str_replace("proprietary", "MIT", &license_str); - if !license_validator.validate(&license_to_validate) { - if license_validator - .validate(&trim(&license_to_validate, Some(" \t\n\r\0\u{0B}"))) - { - self.warnings.borrow_mut().push(format!( - "License {} must not contain extra spaces, make sure to trim it.", - json_encode(&PhpMixed::String(license_str.clone())) - .unwrap_or_default(), - )); - } else { - self.warnings.borrow_mut().push(format!( - "License {} is not a valid SPDX license identifier, see https://spdx.org/licenses/ if you use an open license.{}If the software is closed-source, you may use \"proprietary\" as license.", + true + } - json_encode(&PhpMixed::String(license_str.clone())) - .unwrap_or_default(), - PHP_EOL - )); - } - } - } - } + fn validate_array(&self, property: &str, mandatory: bool) -> bool { + if self.config.borrow().contains_key(property) && !is_array(&self.config.borrow()[property]) + { + self.errors.borrow_mut().push(format!( + "{} : should be an array, {} given", + property, + get_debug_type(&self.config.borrow()[property]) + )); + self.config.borrow_mut().shift_remove(property); - let reindexed: Vec = array_values(&licenses); - let mut reindexed_map: IndexMap = IndexMap::new(); - for (i, v) in reindexed.into_iter().enumerate() { - reindexed_map.insert(i.to_string(), v); - } - self.config - .borrow_mut() - .insert("license".to_string(), PhpMixed::Array(reindexed_map)); - } else { - self.warnings.borrow_mut().push(format!( - "License must be a string or array of strings, got {}.", - json_encode(&license_val).unwrap_or_default(), + return false; + } + + let is_empty = !self.config.borrow().contains_key(property) + || match &self.config.borrow()[property] { + PhpMixed::Array(m) => m.is_empty(), + PhpMixed::List(l) => l.is_empty(), + // is_array() above guarantees the value is Array or List here. + _ => unreachable!("validate_array: non-array value survived the is_array check"), + }; + if is_empty { + if mandatory { + self.errors.borrow_mut().push(format!( + "{} : must be present and contain at least one element", + property )); - self.config.borrow_mut().shift_remove("license"); } + self.config.borrow_mut().shift_remove(property); + + return false; } - if self.validate_array("authors", false) { - let author_keys: Vec = self.config.borrow()["authors"] - .as_array() - .map(|a| a.keys().cloned().collect()) - .unwrap_or_default(); - for key in &author_keys { - let author = self.config.borrow()["authors"].as_array().unwrap()[key].clone(); - if !is_array(&author) { - self.errors.borrow_mut().push(format!( - "authors.{} : should be an array, {} given", - key, - get_debug_type(&author) - )); - if let Some(PhpMixed::Array(m)) = self.config.borrow_mut().get_mut("authors") { - m.shift_remove(key); - } - continue; - } - for author_data in ["homepage", "email", "name", "role"] { - let val_opt = author.as_array().and_then(|m| m.get(author_data)).cloned(); - if let Some(val) = val_opt - && !is_string(&val) - { - self.errors.borrow_mut().push(format!( - "authors.{}.{} : invalid value, must be a string", - key, author_data - )); - if let Some(PhpMixed::Array(authors)) = - self.config.borrow_mut().get_mut("authors") - && let Some(author_entry) = authors.get_mut(key) - && let PhpMixed::Array(am) = author_entry - { - am.shift_remove(author_data); - } - } - } - let homepage = author - .as_array() - .and_then(|m| m.get("homepage")) - .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(homepage_str) = homepage - && !self.filter_url(&homepage_str, &["http", "https"]) - { - self.warnings.borrow_mut().push(format!( - "authors.{}.homepage : invalid value ({}), must be an http/https URL", - key, homepage_str - )); - if let Some(PhpMixed::Array(authors)) = - self.config.borrow_mut().get_mut("authors") - && let Some(author_entry) = authors.get_mut(key) - && let PhpMixed::Array(am) = author_entry - { - am.shift_remove("homepage"); - } + true + } + + fn validate_flat_array(&self, property: &str, regex: Option<&str>, mandatory: bool) -> bool { + if !self.validate_array(property, mandatory) { + return false; + } + + let mut pass = true; + let entries: Vec<(String, PhpMixed)> = self.config.borrow()[property] + .as_array() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + for (key, value) in entries { + if !is_string(&value) && !is_numeric(&value) { + self.errors.borrow_mut().push(format!( + "{}.{} : must be a string or int, {} given", + property, + key, + get_debug_type(&value) + )); + if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut(property) { + arr.shift_remove(&key); } - let email = author - .as_array() - .and_then(|m| m.get("email")) - .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(email_str) = email - && !filter_var_email(&email_str) - { + pass = false; + + continue; + } + + if let Some(regex_str) = regex { + let value_str = php_to_string(&value); + if !Preg::is_match(format!("{{^{}$}}u", regex_str), &value_str) { self.warnings.borrow_mut().push(format!( - "authors.{}.email : invalid value ({}), must be a valid email address", - key, email_str + "{}.{} : invalid value ({}), must match {}", + property, key, value_str, regex_str )); - if let Some(PhpMixed::Array(authors)) = - self.config.borrow_mut().get_mut("authors") - && let Some(author_entry) = authors.get_mut(key) - && let PhpMixed::Array(am) = author_entry - { - am.shift_remove("email"); + if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut(property) { + arr.shift_remove(&key); } + pass = false; } - let current_author_len = self - .config - .borrow() - .get("authors") - .and_then(|v| v.as_array()) - .and_then(|m| m.get(key)) - .and_then(|v| v.as_array()) - .map(|m| m.len()) - .unwrap_or(0); - if current_author_len == 0 - && let Some(PhpMixed::Array(authors)) = - self.config.borrow_mut().get_mut("authors") - { - authors.shift_remove(key); - } - } - let authors_len = self - .config - .borrow() - .get("authors") - .and_then(|v| v.as_array()) - .map(|m| m.len()) - .unwrap_or(0); - if authors_len == 0 { - self.config.borrow_mut().shift_remove("authors"); } } - if self.validate_array("support", false) - && !Self::is_empty_array(self.config.borrow().get("support")) - { - for key in [ - "issues", "forum", "wiki", "source", "email", "irc", "docs", "rss", "chat", - "security", - ] { - let val_opt = self - .config - .borrow() - .get("support") - .and_then(|v| v.as_array()) - .and_then(|m| m.get(key)) - .cloned(); - if let Some(val) = val_opt - && !is_string(&val) - { - self.errors - .borrow_mut() - .push(format!("support.{} : invalid value, must be a string", key)); - if let Some(PhpMixed::Array(support)) = - self.config.borrow_mut().get_mut("support") - { - support.shift_remove(key); - } - } - } + pass + } - let support_email = self - .config - .borrow() - .get("support") - .and_then(|v| v.as_array()) - .and_then(|m| m.get("email")) - .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(email_str) = support_email - && !filter_var_email(&email_str) - { - self.warnings.borrow_mut().push(format!( - "support.email : invalid value ({}), must be a valid email address", - email_str - )); - if let Some(PhpMixed::Array(support)) = self.config.borrow_mut().get_mut("support") - { - support.shift_remove("email"); - } - } + fn validate_url(&self, property: &str, mandatory: bool) -> bool { + if !self.validate_string(property, mandatory) { + return false; + } - let support_irc = self - .config - .borrow() - .get("support") - .and_then(|v| v.as_array()) - .and_then(|m| m.get("irc")) - .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(irc_str) = support_irc - && !self.filter_url(&irc_str, &["irc", "ircs"]) - { - self.warnings.borrow_mut().push(format!( - "support.irc : invalid value ({}), must be a irc:/// or ircs:// URL", - irc_str - )); - if let Some(PhpMixed::Array(support)) = self.config.borrow_mut().get_mut("support") - { - support.shift_remove("irc"); - } - } + let value = self.config.borrow()[property] + .as_string() + .unwrap_or("") + .to_string(); + if !self.filter_url(&value, &["http", "https"]) { + self.warnings.borrow_mut().push(format!( + "{} : invalid value ({}), must be an http/https URL", + property, value + )); + self.config.borrow_mut().shift_remove(property); - for key in [ - "issues", "forum", "wiki", "source", "docs", "chat", "security", - ] { - let url_opt = self + return false; + } + + true + } + + fn filter_url(&self, value: &str, schemes: &[&str]) -> bool { + if value.is_empty() { + return true; + } + + let bits = parse_url_all(value); + let bits_map = match bits { + PhpMixed::Array(m) => m, + _ => return false, + }; + let scheme = bits_map + .get("scheme") + .and_then(|v| v.as_string()) + .unwrap_or(""); + let host = bits_map + .get("host") + .and_then(|v| v.as_string()) + .unwrap_or(""); + if scheme.is_empty() || host.is_empty() { + return false; + } + + if !schemes.contains(&scheme) { + return false; + } + + true + } + + fn is_empty_array(val: Option<&PhpMixed>) -> bool { + match val { + Some(v) => match v { + PhpMixed::Array(m) => m.is_empty(), + PhpMixed::Null => true, + PhpMixed::Bool(false) => true, + PhpMixed::String(s) => s.is_empty(), + PhpMixed::Int(0) => true, + _ => false, + }, + None => true, + } + } +} + +impl LoaderInterface for ValidatingArrayLoader { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn load( + &self, + config: IndexMap, + class: Option, + ) -> anyhow::Result { + let class = class.unwrap_or_else(|| "Composer\\Package\\CompletePackage".to_string()); + + *self.errors.borrow_mut() = Vec::new(); + *self.warnings.borrow_mut() = Vec::new(); + *self.config.borrow_mut() = config.clone(); + + self.validate_string("name", true); + if let Some(name_val) = config.get("name").and_then(|v| v.as_string()) + && let Some(err) = Self::has_package_naming_error(name_val, false) + { + self.errors.borrow_mut().push(format!("name : {}", err)); + } + + if self.config.borrow().contains_key("version") { + let version_val = self.config.borrow()["version"].clone(); + if !is_scalar(&version_val) { + self.validate_string("version", false); + } else { + if !is_string(&version_val) { + self.config.borrow_mut().insert( + "version".to_string(), + PhpMixed::String(php_to_string(&version_val)), + ); + } + let version_str = self .config .borrow() - .get("support") - .and_then(|v| v.as_array()) - .and_then(|m| m.get(key)) + .get("version") .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(url_str) = url_opt - && !self.filter_url(&url_str, &["http", "https"]) - { - self.warnings.borrow_mut().push(format!( - "support.{} : invalid value ({}), must be an http/https URL", - key, url_str - )); - if let Some(PhpMixed::Array(support)) = - self.config.borrow_mut().get_mut("support") - { - support.shift_remove(key); + .unwrap_or("") + .to_string(); + match self.version_parser.normalize(&version_str, None) { + Ok(_) => {} + Err(e) => { + self.errors + .borrow_mut() + .push(format!("version : invalid value ({}): {}", version_str, e)); + self.config.borrow_mut().shift_remove("version"); } } } - if Self::is_empty_array(self.config.borrow().get("support")) { - self.config.borrow_mut().shift_remove("support"); - } } - if self.validate_array("funding", false) - && !Self::is_empty_array(self.config.borrow().get("funding")) + if let Some(config_section) = self + .config + .borrow() + .get("config") + .and_then(|v| v.as_array()) + .cloned() + && let Some(platform_val) = config_section.get("platform") { - let funding_keys: Vec = self - .config - .borrow() - .get("funding") - .and_then(|v| v.as_array()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - for key in &funding_keys { - let funding_option = - self.config.borrow()["funding"].as_array().unwrap()[key].clone(); - if !is_array(&funding_option) { + let platform_array: IndexMap = match platform_val { + PhpMixed::Array(m) => m.clone(), + other => { + let mut m = IndexMap::new(); + m.insert("0".to_string(), other.clone()); + m + } + }; + for (key, platform) in &platform_array { + if let PhpMixed::Bool(false) = platform { + continue; + } + if !is_string(platform) { self.errors.borrow_mut().push(format!( - "funding.{} : should be an array, {} given", + "config.platform.{} : invalid value ({} {}): expected string or false", key, - get_debug_type(&funding_option) + get_debug_type(platform), + var_export(platform, true) )); - if let Some(PhpMixed::Array(funding)) = - self.config.borrow_mut().get_mut("funding") - { - funding.shift_remove(key); - } continue; } - for funding_data in ["type", "url"] { - let val_opt = funding_option - .as_array() - .and_then(|m| m.get(funding_data)) - .cloned(); - if let Some(val) = val_opt - && !is_string(&val) - { - self.errors.borrow_mut().push(format!( - "funding.{}.{} : invalid value, must be a string", - key, funding_data - )); - if let Some(PhpMixed::Array(funding)) = - self.config.borrow_mut().get_mut("funding") - && let Some(entry) = funding.get_mut(key) - && let PhpMixed::Array(em) = entry - { - em.shift_remove(funding_data); - } - } - } - let url = funding_option - .as_array() - .and_then(|m| m.get("url")) - .and_then(|v| v.as_string()) - .map(|s| s.to_string()); - if let Some(url_str) = url - && !self.filter_url(&url_str, &["http", "https"]) - { - self.warnings.borrow_mut().push(format!( - "funding.{}.url : invalid value ({}), must be an http/https URL", - key, url_str + let platform_str = platform.as_string().unwrap_or("").to_string(); + if let Err(e) = self.version_parser.normalize(&platform_str, None) { + self.errors.borrow_mut().push(format!( + "config.platform.{} : invalid value ({}): {}", + key, platform_str, e )); - if let Some(PhpMixed::Array(funding)) = - self.config.borrow_mut().get_mut("funding") - && let Some(entry) = funding.get_mut(key) - && let PhpMixed::Array(em) = entry - { - em.shift_remove("url"); - } - } - let entry_empty = self - .config - .borrow() - .get("funding") - .and_then(|v| v.as_array()) - .and_then(|m| m.get(key)) - .and_then(|v| v.as_array()) - .map(|m| m.is_empty()) - .unwrap_or(true); - if entry_empty - && let Some(PhpMixed::Array(funding)) = - self.config.borrow_mut().get_mut("funding") - { - funding.shift_remove(key); } } - if Self::is_empty_array(self.config.borrow().get("funding")) { - self.config.borrow_mut().shift_remove("funding"); - } } - if self.config.borrow().contains_key("php-ext") && self.validate_array("php-ext", false) { - let pkg_type = self - .config - .borrow() - .get("type") - .and_then(|v| v.as_string()) - .unwrap_or("") - .to_string(); - if !["php-ext", "php-ext-zend"].contains(&pkg_type.as_str()) { - self.errors.borrow_mut().push( - "php-ext can only be set by packages of type \"php-ext\" or \"php-ext-zend\" which must be C extensions".to_string() - ); - self.config.borrow_mut().shift_remove("php-ext"); + self.validate_regex("type", "[A-Za-z0-9-]+", false); + self.validate_string("target-dir", false); + self.validate_array("extra", false); + + if self.config.borrow().contains_key("bin") { + if is_string(&self.config.borrow()["bin"]) { + self.validate_string("bin", false); + } else { + self.validate_flat_array("bin", None, false); } + } - if self.config.borrow().contains_key("php-ext") { - let mut php_ext: IndexMap = - match self.config.borrow_mut().shift_remove("php-ext").unwrap() { - PhpMixed::Array(m) => m, - _ => IndexMap::new(), - }; + self.validate_array("scripts", false); // TODO validate event names & listener syntax + self.validate_string("description", false); + self.validate_url("homepage", false); + self.validate_flat_array("keywords", Some("[\\p{N}\\p{L} ._-]+"), false); - if let Some(v) = php_ext.get("extension-name").cloned() - && !is_string(&v) - { - self.errors.borrow_mut().push(format!( - "php-ext.extension-name : should be a string, {} given", - get_debug_type(&v) - )); - php_ext.shift_remove("extension-name"); + let mut release_date: Option> = None; + self.validate_string("time", false); + if self.config.borrow().contains_key("time") { + let time_str = self.config.borrow()["time"] + .as_string() + .unwrap_or("") + .to_string(); + match shirabe_php_shim::date_create::(&time_str) { + Ok(dt) => { + release_date = Some(dt); + } + Err(e) => { + self.errors + .borrow_mut() + .push(format!("time : invalid value ({}): {}", time_str, e)); + self.config.borrow_mut().shift_remove("time"); } + } + } - if let Some(v) = php_ext.get("priority").cloned() - && !is_int(&v) - { - self.errors.borrow_mut().push(format!( - "php-ext.priority : should be an integer, {} given", - get_debug_type(&v) - )); - php_ext.shift_remove("priority"); + if self.config.borrow().contains_key("license") { + let license_val = self.config.borrow()["license"].clone(); + // validate main data types + if is_array(&license_val) || is_string(&license_val) { + // PHP: `(array) $this->config['license']` — an array (list-shaped included) + // stays as-is; only a scalar is wrapped. + let mut licenses: IndexMap = match &license_val { + PhpMixed::Array(m) => m.clone(), + PhpMixed::List(items) => items + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect(), + other => { + let mut m = IndexMap::new(); + m.insert("0".to_string(), other.clone()); + m + } + }; + + let license_keys: Vec = licenses.keys().cloned().collect(); + for index in &license_keys { + let license = licenses[index].clone(); + if !is_string(&license) { + self.warnings.borrow_mut().push(format!( + "License {} should be a string.", + json_encode(&license).unwrap_or_default(), + )); + licenses.shift_remove(index); + } } - if let Some(v) = php_ext.get("support-zts").cloned() - && !is_bool(&v) - { - self.errors.borrow_mut().push(format!( - "php-ext.support-zts : should be a boolean, {} given", - get_debug_type(&v) - )); - php_ext.shift_remove("support-zts"); + // check for license validity on newly updated branches/tags + let cutoff = strtotime("-8days").unwrap_or(0); + if release_date.is_none() || release_date.unwrap().timestamp() >= cutoff { + let license_validator = SpdxLicenses::new(); + for license in licenses.values() { + let license_str = license.as_string().unwrap_or("").to_string(); + // replace proprietary by MIT for validation purposes since it's not a valid SPDX identifier, but is accepted by composer + if license_str == "proprietary" { + continue; + } + let license_to_validate = str_replace("proprietary", "MIT", &license_str); + if !license_validator.validate(&license_to_validate) { + if license_validator + .validate(&trim(&license_to_validate, Some(" \t\n\r\0\u{0B}"))) + { + self.warnings.borrow_mut().push(format!( + "License {} must not contain extra spaces, make sure to trim it.", + json_encode(&PhpMixed::String(license_str.clone())) + .unwrap_or_default(), + )); + } else { + self.warnings.borrow_mut().push(format!( + "License {} is not a valid SPDX license identifier, see https://spdx.org/licenses/ if you use an open license.{}If the software is closed-source, you may use \"proprietary\" as license.", + + json_encode(&PhpMixed::String(license_str.clone())) + .unwrap_or_default(), + PHP_EOL + )); + } + } + } } - if let Some(v) = php_ext.get("support-nts").cloned() - && !is_bool(&v) - { - self.errors.borrow_mut().push(format!( - "php-ext.support-nts : should be a boolean, {} given", - get_debug_type(&v) - )); - php_ext.shift_remove("support-nts"); + let reindexed: Vec = array_values(&licenses); + let mut reindexed_map: IndexMap = IndexMap::new(); + for (i, v) in reindexed.into_iter().enumerate() { + reindexed_map.insert(i.to_string(), v); } + self.config + .borrow_mut() + .insert("license".to_string(), PhpMixed::Array(reindexed_map)); + } else { + self.warnings.borrow_mut().push(format!( + "License must be a string or array of strings, got {}.", + json_encode(&license_val).unwrap_or_default(), + )); + self.config.borrow_mut().shift_remove("license"); + } + } - if let Some(v) = php_ext.get("build-path").cloned() - && !is_string(&v) - && !matches!(v, PhpMixed::Null) - { + if self.validate_array("authors", false) { + let author_keys: Vec = self.config.borrow()["authors"] + .as_array() + .map(|a| a.keys().cloned().collect()) + .unwrap_or_default(); + for key in &author_keys { + let author = self.config.borrow()["authors"].as_array().unwrap()[key].clone(); + if !is_array(&author) { self.errors.borrow_mut().push(format!( - "php-ext.build-path : should be a string or null, {} given", - get_debug_type(&v) + "authors.{} : should be an array, {} given", + key, + get_debug_type(&author) )); - php_ext.shift_remove("build-path"); + if let Some(PhpMixed::Array(m)) = self.config.borrow_mut().get_mut("authors") { + m.shift_remove(key); + } + continue; } - - if php_ext.contains_key("download-url-method") { - let v = php_ext["download-url-method"].clone(); - if !is_array(&v) && !is_string(&v) { + for author_data in ["homepage", "email", "name", "role"] { + let val_opt = author.as_array().and_then(|m| m.get(author_data)).cloned(); + if let Some(val) = val_opt + && !is_string(&val) + { self.errors.borrow_mut().push(format!( - "php-ext.download-url-method : should be an array or a string, {} given", - get_debug_type(&v) + "authors.{}.{} : invalid value, must be a string", + key, author_data )); - php_ext.shift_remove("download-url-method"); - } else { - let valid_download_url_methods = [ - "composer-default", - "pre-packaged-source", - "pre-packaged-binary", - ]; - let defined_download_url_methods: IndexMap = - if is_array(&v) { - v.as_array().unwrap().clone() - } else { - let mut m = IndexMap::new(); - m.insert("0".to_string(), v); - m - }; - - if defined_download_url_methods.is_empty() { - self.errors.borrow_mut().push( - "php-ext.download-url-method : must contain at least one element" - .to_string(), - ); - php_ext.shift_remove("download-url-method"); - } else { - for (key, download_url_method) in &defined_download_url_methods { - if !is_string(download_url_method) { - self.errors.borrow_mut().push(format!( - "php-ext.download-url-method.{} : should be a string, {} given", - key, - get_debug_type(download_url_method) - )); - php_ext.shift_remove("download-url-method"); - } else if !valid_download_url_methods - .contains(&download_url_method.as_string().unwrap_or("")) - { - self.errors.borrow_mut().push(format!( - "php-ext.download-url-method.{} : invalid value ({}), must be one of {}", - key, - download_url_method.as_string().unwrap_or(""), - valid_download_url_methods.join(", ") - )); - php_ext.shift_remove("download-url-method"); - } - } + if let Some(PhpMixed::Array(authors)) = + self.config.borrow_mut().get_mut("authors") + && let Some(author_entry) = authors.get_mut(key) + && let PhpMixed::Array(am) = author_entry + { + am.shift_remove(author_data); } } } - - if php_ext.contains_key("os-families") - && php_ext.contains_key("os-families-exclude") + let homepage = author + .as_array() + .and_then(|m| m.get("homepage")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(homepage_str) = homepage + && !self.filter_url(&homepage_str, &["http", "https"]) { - self.errors.borrow_mut().push( - "php-ext : os-families and os-families-exclude cannot both be specified" - .to_string(), - ); - php_ext.shift_remove("os-families"); - php_ext.shift_remove("os-families-exclude"); - } else { - let valid_os_families = - ["windows", "bsd", "darwin", "solaris", "linux", "unknown"]; - - for field_name in ["os-families", "os-families-exclude"] { - if let Some(field_val) = php_ext.get(field_name).cloned() { - if !is_array(&field_val) { - self.errors.borrow_mut().push(format!( - "php-ext.{} : should be an array, {} given", - field_name, - get_debug_type(&field_val) - )); - php_ext.shift_remove(field_name); - } else if field_val.as_array().unwrap().is_empty() { - self.errors.borrow_mut().push(format!( - "php-ext.{} : must contain at least one element", - field_name - )); - php_ext.shift_remove(field_name); - } else { - let field_keys: Vec = - field_val.as_array().unwrap().keys().cloned().collect(); - for key in &field_keys { - let os_family = field_val.as_array().unwrap()[key].clone(); - if !is_string(&os_family) { - self.errors.borrow_mut().push(format!( - "php-ext.{}.{} : should be a string, {} given", - field_name, - key, - get_debug_type(&os_family) - )); - if let Some(PhpMixed::Array(arr)) = - php_ext.get_mut(field_name) - { - arr.shift_remove(key); - } - } else if !valid_os_families - .contains(&os_family.as_string().unwrap_or("")) - { - self.errors.borrow_mut().push(format!( - "php-ext.{}.{} : invalid value ({}), must be one of {}", - field_name, - key, - os_family.as_string().unwrap_or(""), - valid_os_families.join(", ") - )); - if let Some(PhpMixed::Array(arr)) = - php_ext.get_mut(field_name) - { - arr.shift_remove(key); - } - } - } - let field_empty = php_ext - .get(field_name) - .and_then(|v| v.as_array()) - .map(|m| m.is_empty()) - .unwrap_or(true); - if field_empty { - php_ext.shift_remove(field_name); - } - } - } + self.warnings.borrow_mut().push(format!( + "authors.{}.homepage : invalid value ({}), must be an http/https URL", + key, homepage_str + )); + if let Some(PhpMixed::Array(authors)) = + self.config.borrow_mut().get_mut("authors") + && let Some(author_entry) = authors.get_mut(key) + && let PhpMixed::Array(am) = author_entry + { + am.shift_remove("homepage"); } } - - if php_ext.contains_key("configure-options") { - let configure_options = php_ext["configure-options"].clone(); - if !is_array(&configure_options) { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options : should be an array, {} given", - get_debug_type(&configure_options) - )); - php_ext.shift_remove("configure-options"); - } else { - let configure_keys: Vec = configure_options - .as_array() - .unwrap() - .keys() - .cloned() - .collect(); - for key in &configure_keys { - let option = configure_options.as_array().unwrap()[key].clone(); - if !is_array(&option) { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options.{} : should be an array, {} given", - key, - get_debug_type(&option) - )); - if let Some(PhpMixed::Array(arr)) = - php_ext.get_mut("configure-options") - { - arr.shift_remove(key); - } - continue; - } - - let option_map = option.as_array().unwrap(); - if !option_map.contains_key("name") { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options.{}.name : must be present", - key - )); - if let Some(PhpMixed::Array(arr)) = - php_ext.get_mut("configure-options") - { - arr.shift_remove(key); - } - continue; - } - - let name_val = option_map["name"].clone(); - if !is_string(&name_val) { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options.{}.name : should be a string, {} given", - key, - get_debug_type(&name_val) - )); - if let Some(PhpMixed::Array(arr)) = - php_ext.get_mut("configure-options") - { - arr.shift_remove(key); - } - continue; - } - - if let Some(needs_value) = option_map.get("needs-value").cloned() - && !is_bool(&needs_value) - { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options.{}.needs-value : should be a boolean, {} given", - key, - get_debug_type(&needs_value) - )); - if let Some(PhpMixed::Array(co)) = - php_ext.get_mut("configure-options") - && let Some(entry) = co.get_mut(key) - && let PhpMixed::Array(em) = entry - { - em.shift_remove("needs-value"); - } - } - - if let Some(description) = option_map.get("description").cloned() - && !is_string(&description) - { - self.errors.borrow_mut().push(format!( - "php-ext.configure-options.{}.description : should be a string, {} given", - key, - get_debug_type(&description) - )); - if let Some(PhpMixed::Array(co)) = - php_ext.get_mut("configure-options") - && let Some(entry) = co.get_mut(key) - && let PhpMixed::Array(em) = entry - { - em.shift_remove("description"); - } - } - } - - let configure_empty = php_ext - .get("configure-options") - .and_then(|v| v.as_array()) - .map(|m| m.is_empty()) - .unwrap_or(true); - if configure_empty { - php_ext.shift_remove("configure-options"); - } + let email = author + .as_array() + .and_then(|m| m.get("email")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(email_str) = email + && !filter_var_email(&email_str) + { + self.warnings.borrow_mut().push(format!( + "authors.{}.email : invalid value ({}), must be a valid email address", + key, email_str + )); + if let Some(PhpMixed::Array(authors)) = + self.config.borrow_mut().get_mut("authors") + && let Some(author_entry) = authors.get_mut(key) + && let PhpMixed::Array(am) = author_entry + { + am.shift_remove("email"); } } - - // If php-ext is now empty, unset it - if !php_ext.is_empty() { - self.config - .borrow_mut() - .insert("php-ext".to_string(), PhpMixed::Array(php_ext)); + let current_author_len = self + .config + .borrow() + .get("authors") + .and_then(|v| v.as_array()) + .and_then(|m| m.get(key)) + .and_then(|v| v.as_array()) + .map(|m| m.len()) + .unwrap_or(0); + if current_author_len == 0 + && let Some(PhpMixed::Array(authors)) = + self.config.borrow_mut().get_mut("authors") + { + authors.shift_remove(key); } } + let authors_len = self + .config + .borrow() + .get("authors") + .and_then(|v| v.as_array()) + .map(|m| m.len()) + .unwrap_or(0); + if authors_len == 0 { + self.config.borrow_mut().shift_remove("authors"); + } } - let unbound_constraint = - SimpleConstraint::new("=".to_string(), "10000000-dev".to_string(), None).into(); - - let link_types: Vec<&'static str> = SUPPORTED_LINK_TYPES.keys().copied().collect(); - for link_type in link_types { - if self.validate_array(link_type, false) && self.config.borrow().contains_key(link_type) - { - let link_section = self.config.borrow()[link_type] - .as_array() - .cloned() - .unwrap_or_default(); - for (package, constraint) in &link_section { - let package = package.to_string(); - let conflicts_with_own_name = self - .config - .borrow() - .get("name") - .and_then(|v| v.as_string()) - .is_some_and(|name_val| strcasecmp(&package, name_val) == 0); - if conflicts_with_own_name { - self.errors.borrow_mut().push(format!( - "{}.{} : a package cannot set a {} on itself", - link_type, package, link_type - )); - if let Some(PhpMixed::Array(arr)) = - self.config.borrow_mut().get_mut(link_type) - { - arr.shift_remove(&package); - } - continue; - } - if let Some(err) = Self::has_package_naming_error(&package, true) { - self.warnings - .borrow_mut() - .push(format!("{}.{}", link_type, err)); - } else if !Preg::is_match(php_regex!("{^[A-Za-z0-9_./-]+$}"), &package) { - self.errors.borrow_mut().push(format!( - "{}.{} : invalid key, package names must be strings containing only [A-Za-z0-9_./-]", - link_type, package - )); + if self.validate_array("support", false) + && !Self::is_empty_array(self.config.borrow().get("support")) + { + for key in [ + "issues", "forum", "wiki", "source", "email", "irc", "docs", "rss", "chat", + "security", + ] { + let val_opt = self + .config + .borrow() + .get("support") + .and_then(|v| v.as_array()) + .and_then(|m| m.get(key)) + .cloned(); + if let Some(val) = val_opt + && !is_string(&val) + { + self.errors + .borrow_mut() + .push(format!("support.{} : invalid value, must be a string", key)); + if let Some(PhpMixed::Array(support)) = + self.config.borrow_mut().get_mut("support") + { + support.shift_remove(key); } - if !is_string(constraint) { - self.errors.borrow_mut().push(format!( - "{}.{} : invalid value, must be a string containing a version constraint", - link_type, package - )); - if let Some(PhpMixed::Array(arr)) = - self.config.borrow_mut().get_mut(link_type) - { - arr.shift_remove(&package); - } - } else if constraint.as_string().unwrap_or("") != "self.version" { - let constraint_str = constraint.as_string().unwrap_or("").to_string(); - let link_constraint = - match self.version_parser.parse_constraints(&constraint_str) { - Ok(c) => c, - Err(e) => { - self.errors.borrow_mut().push(format!( - "{}.{} : invalid version constraint ({})", - link_type, package, e - )); - if let Some(PhpMixed::Array(arr)) = - self.config.borrow_mut().get_mut(link_type) - { - arr.shift_remove(&package); - } - continue; - } - }; - - // check requires for unbound constraints on non-platform packages - if (self.flags & Self::CHECK_UNBOUND_CONSTRAINTS) != 0 - && link_type == "require" - && link_constraint.matches(&unbound_constraint) - && !PlatformRepository::is_platform_package(&package) - { - self.warnings.borrow_mut().push(format!( - "{}.{} : unbound version constraints ({}) should be avoided", - link_type, package, constraint_str - )); - } else if (self.flags & Self::CHECK_STRICT_CONSTRAINTS) != 0 - && link_type == "require" - && link_constraint - .as_constraint() - .is_some_and(|c| ["==", "="].contains(&c.get_operator())) - && AnyConstraint::from(SimpleConstraint::new( - ">=".to_string(), - "1.0.0.0-dev".to_string(), - None, - )) - .matches(&link_constraint) - { - self.warnings.borrow_mut().push(format!( - "{}.{} : exact version constraints ({}) should be avoided if the package follows semantic versioning", - link_type, package, constraint_str - )); - } + } + } - let compacted = Intervals::compact_constraint(&link_constraint)?; - if compacted.is_match_none() { - self.warnings.borrow_mut().push(format!( - "{}.{} : this version constraint cannot possibly match anything ({})", - link_type, package, constraint_str - )); - } - } + let support_email = self + .config + .borrow() + .get("support") + .and_then(|v| v.as_array()) + .and_then(|m| m.get("email")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(email_str) = support_email + && !filter_var_email(&email_str) + { + self.warnings.borrow_mut().push(format!( + "support.email : invalid value ({}), must be a valid email address", + email_str + )); + if let Some(PhpMixed::Array(support)) = self.config.borrow_mut().get_mut("support") + { + support.shift_remove("email"); + } + } - if link_type == "conflict" && self.config.borrow().contains_key("replace") { - let replace_map = self - .config - .borrow() - .get("replace") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let conflict_map = self - .config - .borrow() - .get("conflict") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let replace_map_flat: IndexMap = replace_map; - let conflict_map_flat: IndexMap = conflict_map; - let keys = array_intersect_key(&replace_map_flat, &conflict_map_flat); - if !keys.is_empty() { - self.errors.borrow_mut().push(format!( - "{}.{} : you cannot conflict with a package that is also replaced, as replace already creates an implicit conflict rule", - link_type, package - )); - if let Some(PhpMixed::Array(arr)) = - self.config.borrow_mut().get_mut(link_type) - { - arr.shift_remove(&package); - } - } - } + let support_irc = self + .config + .borrow() + .get("support") + .and_then(|v| v.as_array()) + .and_then(|m| m.get("irc")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(irc_str) = support_irc + && !self.filter_url(&irc_str, &["irc", "ircs"]) + { + self.warnings.borrow_mut().push(format!( + "support.irc : invalid value ({}), must be a irc:/// or ircs:// URL", + irc_str + )); + if let Some(PhpMixed::Array(support)) = self.config.borrow_mut().get_mut("support") + { + support.shift_remove("irc"); } } - } - if self.validate_array("suggest", false) && self.config.borrow().contains_key("suggest") { - let suggest_map = self.config.borrow()["suggest"] - .as_array() - .cloned() - .unwrap_or_default(); - for (package, description) in &suggest_map { - if !is_string(description) { - self.errors.borrow_mut().push(format!( - "suggest.{} : invalid value, must be a string describing why the package is suggested", - package + for key in [ + "issues", "forum", "wiki", "source", "docs", "chat", "security", + ] { + let url_opt = self + .config + .borrow() + .get("support") + .and_then(|v| v.as_array()) + .and_then(|m| m.get(key)) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(url_str) = url_opt + && !self.filter_url(&url_str, &["http", "https"]) + { + self.warnings.borrow_mut().push(format!( + "support.{} : invalid value ({}), must be an http/https URL", + key, url_str )); - if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("suggest") + if let Some(PhpMixed::Array(support)) = + self.config.borrow_mut().get_mut("support") { - arr.shift_remove(package); + support.shift_remove(key); } } } - } - - if self.validate_string("minimum-stability", false) - && self.config.borrow().contains_key("minimum-stability") - { - let min_stability = self.config.borrow()["minimum-stability"] - .as_string() - .unwrap_or("") - .to_string(); - if !STABILITIES.contains_key(strtolower(&min_stability).as_str()) - && min_stability != "RC" - { - self.errors.borrow_mut().push(format!( - "minimum-stability : invalid value ({}), must be one of {}", - min_stability, - STABILITIES.keys().copied().collect::>().join(", ") - )); - self.config.borrow_mut().shift_remove("minimum-stability"); + if Self::is_empty_array(self.config.borrow().get("support")) { + self.config.borrow_mut().shift_remove("support"); } } - if self.validate_array("autoload", false) && self.config.borrow().contains_key("autoload") { - let types = [ - "psr-0", - "psr-4", - "classmap", - "files", - "exclude-from-classmap", - ]; - let autoload_keys: Vec = self.config.borrow()["autoload"] - .as_array() + if self.validate_array("funding", false) + && !Self::is_empty_array(self.config.borrow().get("funding")) + { + let funding_keys: Vec = self + .config + .borrow() + .get("funding") + .and_then(|v| v.as_array()) .map(|m| m.keys().cloned().collect()) .unwrap_or_default(); - for r#type in &autoload_keys { - let type_config = - self.config.borrow()["autoload"].as_array().unwrap()[r#type].clone(); - if !types.contains(&r#type.as_str()) { + for key in &funding_keys { + let funding_option = + self.config.borrow()["funding"].as_array().unwrap()[key].clone(); + if !is_array(&funding_option) { self.errors.borrow_mut().push(format!( - "autoload : invalid value ({}), must be one of {}", - r#type, - types.join(", ") + "funding.{} : should be an array, {} given", + key, + get_debug_type(&funding_option) )); - if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("autoload") + if let Some(PhpMixed::Array(funding)) = + self.config.borrow_mut().get_mut("funding") { - arr.shift_remove(r#type); + funding.shift_remove(key); } + continue; } - if r#type == "psr-4" - && let Some(type_map) = type_config.as_array() - { - for (namespace, _dirs) in type_map { - let ns_str = namespace.as_str(); - if !ns_str.is_empty() && substr(ns_str, -1, None) != "\\" { - self.errors.borrow_mut().push(format!( - "autoload.psr-4 : invalid value ({}), namespaces must end with a namespace separator, should be {}\\\\", - ns_str, ns_str - )); + for funding_data in ["type", "url"] { + let val_opt = funding_option + .as_array() + .and_then(|m| m.get(funding_data)) + .cloned(); + if let Some(val) = val_opt + && !is_string(&val) + { + self.errors.borrow_mut().push(format!( + "funding.{}.{} : invalid value, must be a string", + key, funding_data + )); + if let Some(PhpMixed::Array(funding)) = + self.config.borrow_mut().get_mut("funding") + && let Some(entry) = funding.get_mut(key) + && let PhpMixed::Array(em) = entry + { + em.shift_remove(funding_data); } } } + let url = funding_option + .as_array() + .and_then(|m| m.get("url")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(url_str) = url + && !self.filter_url(&url_str, &["http", "https"]) + { + self.warnings.borrow_mut().push(format!( + "funding.{}.url : invalid value ({}), must be an http/https URL", + key, url_str + )); + if let Some(PhpMixed::Array(funding)) = + self.config.borrow_mut().get_mut("funding") + && let Some(entry) = funding.get_mut(key) + && let PhpMixed::Array(em) = entry + { + em.shift_remove("url"); + } + } + let entry_empty = self + .config + .borrow() + .get("funding") + .and_then(|v| v.as_array()) + .and_then(|m| m.get(key)) + .and_then(|v| v.as_array()) + .map(|m| m.is_empty()) + .unwrap_or(true); + if entry_empty + && let Some(PhpMixed::Array(funding)) = + self.config.borrow_mut().get_mut("funding") + { + funding.shift_remove(key); + } + } + if Self::is_empty_array(self.config.borrow().get("funding")) { + self.config.borrow_mut().shift_remove("funding"); } } - let has_psr4 = self - .config - .borrow() - .get("autoload") - .and_then(|v| v.as_array()) - .map(|m| m.contains_key("psr-4")) - .unwrap_or(false); - if has_psr4 && self.config.borrow().contains_key("target-dir") { - self.errors.borrow_mut().push( - "target-dir : this can not be used together with the autoload.psr-4 setting, remove target-dir to upgrade to psr-4".to_string() - ); - // Unset the psr-4 setting, since unsetting target-dir might - // interfere with other settings. - if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("autoload") { - arr.shift_remove("psr-4"); + if self.config.borrow().contains_key("php-ext") && self.validate_array("php-ext", false) { + let pkg_type = self + .config + .borrow() + .get("type") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + if !["php-ext", "php-ext-zend"].contains(&pkg_type.as_str()) { + self.errors.borrow_mut().push( + "php-ext can only be set by packages of type \"php-ext\" or \"php-ext-zend\" which must be C extensions".to_string() + ); + self.config.borrow_mut().shift_remove("php-ext"); } - } - for src_type in ["source", "dist"] { - if self.validate_array(src_type, false) - && !Self::is_empty_array(self.config.borrow().get(src_type)) - { - let section = self - .config - .borrow() - .get(src_type) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - // Mirror PHP `isset()`, which is false for both missing keys and null values. - let isset = - |key: &str| matches!(section.get(key), Some(v) if !matches!(v, PhpMixed::Null)); - if !isset("type") { - self.errors - .borrow_mut() - .push(format!("{}.type : must be present", src_type)); - } - if !isset("url") { - self.errors - .borrow_mut() - .push(format!("{}.url : must be present", src_type)); + if self.config.borrow().contains_key("php-ext") { + let mut php_ext: IndexMap = + match self.config.borrow_mut().shift_remove("php-ext").unwrap() { + PhpMixed::Array(m) => m, + _ => IndexMap::new(), + }; + + if let Some(v) = php_ext.get("extension-name").cloned() + && !is_string(&v) + { + self.errors.borrow_mut().push(format!( + "php-ext.extension-name : should be a string, {} given", + get_debug_type(&v) + )); + php_ext.shift_remove("extension-name"); } - if src_type == "source" && !isset("reference") { - self.errors - .borrow_mut() - .push(format!("{}.reference : must be present", src_type)); + + if let Some(v) = php_ext.get("priority").cloned() + && !is_int(&v) + { + self.errors.borrow_mut().push(format!( + "php-ext.priority : should be an integer, {} given", + get_debug_type(&v) + )); + php_ext.shift_remove("priority"); } - if let Some(type_val) = section.get("type").filter(|_| isset("type")) - && !is_string(type_val) + + if let Some(v) = php_ext.get("support-zts").cloned() + && !is_bool(&v) { self.errors.borrow_mut().push(format!( - "{}.type : should be a string, {} given", - src_type, - get_debug_type(type_val) + "php-ext.support-zts : should be a boolean, {} given", + get_debug_type(&v) )); + php_ext.shift_remove("support-zts"); } - if let Some(url_val) = section.get("url").filter(|_| isset("url")) - && !is_string(url_val) + + if let Some(v) = php_ext.get("support-nts").cloned() + && !is_bool(&v) { self.errors.borrow_mut().push(format!( - "{}.url : should be a string, {} given", - src_type, - get_debug_type(url_val) + "php-ext.support-nts : should be a boolean, {} given", + get_debug_type(&v) )); + php_ext.shift_remove("support-nts"); } - if let Some(ref_val) = section.get("reference").filter(|_| isset("reference")) - && !is_string(ref_val) - && !is_int(ref_val) + + if let Some(v) = php_ext.get("build-path").cloned() + && !is_string(&v) + && !matches!(v, PhpMixed::Null) { self.errors.borrow_mut().push(format!( - "{}.reference : should be a string or int, {} given", - src_type, - get_debug_type(ref_val) + "php-ext.build-path : should be a string or null, {} given", + get_debug_type(&v) )); + php_ext.shift_remove("build-path"); } - if let Some(ref_val) = section.get("reference").filter(|_| isset("reference")) { - let ref_str = php_to_string(ref_val); - if Preg::is_match(php_regex!("{^\\s*-}"), &ref_str) { + + if php_ext.contains_key("download-url-method") { + let v = php_ext["download-url-method"].clone(); + if !is_array(&v) && !is_string(&v) { self.errors.borrow_mut().push(format!( - "{}.reference : must not start with a \"-\", \"{}\" given", - src_type, ref_str + "php-ext.download-url-method : should be an array or a string, {} given", + get_debug_type(&v) + )); + php_ext.shift_remove("download-url-method"); + } else { + let valid_download_url_methods = [ + "composer-default", + "pre-packaged-source", + "pre-packaged-binary", + ]; + let defined_download_url_methods: IndexMap = + if is_array(&v) { + v.as_array().unwrap().clone() + } else { + let mut m = IndexMap::new(); + m.insert("0".to_string(), v); + m + }; + + if defined_download_url_methods.is_empty() { + self.errors.borrow_mut().push( + "php-ext.download-url-method : must contain at least one element" + .to_string(), + ); + php_ext.shift_remove("download-url-method"); + } else { + for (key, download_url_method) in &defined_download_url_methods { + if !is_string(download_url_method) { + self.errors.borrow_mut().push(format!( + "php-ext.download-url-method.{} : should be a string, {} given", + key, + get_debug_type(download_url_method) + )); + php_ext.shift_remove("download-url-method"); + } else if !valid_download_url_methods + .contains(&download_url_method.as_string().unwrap_or("")) + { + self.errors.borrow_mut().push(format!( + "php-ext.download-url-method.{} : invalid value ({}), must be one of {}", + key, + download_url_method.as_string().unwrap_or(""), + valid_download_url_methods.join(", ") + )); + php_ext.shift_remove("download-url-method"); + } + } + } + } + } + + if php_ext.contains_key("os-families") + && php_ext.contains_key("os-families-exclude") + { + self.errors.borrow_mut().push( + "php-ext : os-families and os-families-exclude cannot both be specified" + .to_string(), + ); + php_ext.shift_remove("os-families"); + php_ext.shift_remove("os-families-exclude"); + } else { + let valid_os_families = + ["windows", "bsd", "darwin", "solaris", "linux", "unknown"]; + + for field_name in ["os-families", "os-families-exclude"] { + if let Some(field_val) = php_ext.get(field_name).cloned() { + if !is_array(&field_val) { + self.errors.borrow_mut().push(format!( + "php-ext.{} : should be an array, {} given", + field_name, + get_debug_type(&field_val) + )); + php_ext.shift_remove(field_name); + } else if field_val.as_array().unwrap().is_empty() { + self.errors.borrow_mut().push(format!( + "php-ext.{} : must contain at least one element", + field_name + )); + php_ext.shift_remove(field_name); + } else { + let field_keys: Vec = + field_val.as_array().unwrap().keys().cloned().collect(); + for key in &field_keys { + let os_family = field_val.as_array().unwrap()[key].clone(); + if !is_string(&os_family) { + self.errors.borrow_mut().push(format!( + "php-ext.{}.{} : should be a string, {} given", + field_name, + key, + get_debug_type(&os_family) + )); + if let Some(PhpMixed::Array(arr)) = + php_ext.get_mut(field_name) + { + arr.shift_remove(key); + } + } else if !valid_os_families + .contains(&os_family.as_string().unwrap_or("")) + { + self.errors.borrow_mut().push(format!( + "php-ext.{}.{} : invalid value ({}), must be one of {}", + field_name, + key, + os_family.as_string().unwrap_or(""), + valid_os_families.join(", ") + )); + if let Some(PhpMixed::Array(arr)) = + php_ext.get_mut(field_name) + { + arr.shift_remove(key); + } + } + } + let field_empty = php_ext + .get(field_name) + .and_then(|v| v.as_array()) + .map(|m| m.is_empty()) + .unwrap_or(true); + if field_empty { + php_ext.shift_remove(field_name); + } + } + } + } + } + + if php_ext.contains_key("configure-options") { + let configure_options = php_ext["configure-options"].clone(); + if !is_array(&configure_options) { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options : should be an array, {} given", + get_debug_type(&configure_options) )); + php_ext.shift_remove("configure-options"); + } else { + let configure_keys: Vec = configure_options + .as_array() + .unwrap() + .keys() + .cloned() + .collect(); + for key in &configure_keys { + let option = configure_options.as_array().unwrap()[key].clone(); + if !is_array(&option) { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options.{} : should be an array, {} given", + key, + get_debug_type(&option) + )); + if let Some(PhpMixed::Array(arr)) = + php_ext.get_mut("configure-options") + { + arr.shift_remove(key); + } + continue; + } + + let option_map = option.as_array().unwrap(); + if !option_map.contains_key("name") { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options.{}.name : must be present", + key + )); + if let Some(PhpMixed::Array(arr)) = + php_ext.get_mut("configure-options") + { + arr.shift_remove(key); + } + continue; + } + + let name_val = option_map["name"].clone(); + if !is_string(&name_val) { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options.{}.name : should be a string, {} given", + key, + get_debug_type(&name_val) + )); + if let Some(PhpMixed::Array(arr)) = + php_ext.get_mut("configure-options") + { + arr.shift_remove(key); + } + continue; + } + + if let Some(needs_value) = option_map.get("needs-value").cloned() + && !is_bool(&needs_value) + { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options.{}.needs-value : should be a boolean, {} given", + key, + get_debug_type(&needs_value) + )); + if let Some(PhpMixed::Array(co)) = + php_ext.get_mut("configure-options") + && let Some(entry) = co.get_mut(key) + && let PhpMixed::Array(em) = entry + { + em.shift_remove("needs-value"); + } + } + + if let Some(description) = option_map.get("description").cloned() + && !is_string(&description) + { + self.errors.borrow_mut().push(format!( + "php-ext.configure-options.{}.description : should be a string, {} given", + key, + get_debug_type(&description) + )); + if let Some(PhpMixed::Array(co)) = + php_ext.get_mut("configure-options") + && let Some(entry) = co.get_mut(key) + && let PhpMixed::Array(em) = entry + { + em.shift_remove("description"); + } + } + } + + let configure_empty = php_ext + .get("configure-options") + .and_then(|v| v.as_array()) + .map(|m| m.is_empty()) + .unwrap_or(true); + if configure_empty { + php_ext.shift_remove("configure-options"); + } } } - if let Some(url_val) = section.get("url").filter(|_| isset("url")) { - let url_str = php_to_string(url_val); - if Preg::is_match(php_regex!("{^\\s*-}"), &url_str) { - self.errors.borrow_mut().push(format!( - "{}.url : must not start with a \"-\", \"{}\" given", - src_type, url_str - )); - } + + // If php-ext is now empty, unset it + if !php_ext.is_empty() { + self.config + .borrow_mut() + .insert("php-ext".to_string(), PhpMixed::Array(php_ext)); } } } - // TODO validate repositories - // TODO validate package repositories' packages using this recursively - - self.validate_flat_array("include-path", None, false); - self.validate_array("transport-options", false); + let unbound_constraint = + SimpleConstraint::new("=".to_string(), "10000000-dev".to_string(), None).into(); - // branch alias validation - let has_branch_alias = self - .config - .borrow() - .get("extra") - .and_then(|v| v.as_array()) - .map(|m| m.contains_key("branch-alias")) - .unwrap_or(false); - if has_branch_alias { - let branch_alias_val = - self.config.borrow()["extra"].as_array().unwrap()["branch-alias"].clone(); - if !is_array(&branch_alias_val) { - self.errors.borrow_mut().push( - "extra.branch-alias : must be an array of versions => aliases".to_string(), - ); - } else { - let branch_alias_map = branch_alias_val.as_array().cloned().unwrap_or_default(); - for (source_branch, target_branch) in &branch_alias_map { - if !is_string(target_branch) { - self.warnings.borrow_mut().push(format!( - "extra.branch-alias.{} : the target branch ({}) must be a string, \"{}\" received.", - source_branch, - json_encode(target_branch).unwrap_or_default(), - get_debug_type(target_branch) + let link_types: Vec<&'static str> = SUPPORTED_LINK_TYPES.keys().copied().collect(); + for link_type in link_types { + if self.validate_array(link_type, false) && self.config.borrow().contains_key(link_type) + { + let link_section = self.config.borrow()[link_type] + .as_array() + .cloned() + .unwrap_or_default(); + for (package, constraint) in &link_section { + let package = package.to_string(); + let conflicts_with_own_name = self + .config + .borrow() + .get("name") + .and_then(|v| v.as_string()) + .is_some_and(|name_val| strcasecmp(&package, name_val) == 0); + if conflicts_with_own_name { + self.errors.borrow_mut().push(format!( + "{}.{} : a package cannot set a {} on itself", + link_type, package, link_type )); - if let Some(PhpMixed::Array(extra)) = - self.config.borrow_mut().get_mut("extra") - && let Some(ba) = extra.get_mut("branch-alias") - && let PhpMixed::Array(bam) = ba + if let Some(PhpMixed::Array(arr)) = + self.config.borrow_mut().get_mut(link_type) { - bam.shift_remove(source_branch); + arr.shift_remove(&package); } continue; } - - let target_branch_str = target_branch.as_string().unwrap_or("").to_string(); - - // ensure it is an alias to a -dev package - if substr(&target_branch_str, -4, None) != "-dev" { - self.warnings.borrow_mut().push(format!( - "extra.branch-alias.{} : the target branch ({}) must end in -dev", - source_branch, target_branch_str + if let Some(err) = Self::has_package_naming_error(&package, true) { + self.warnings + .borrow_mut() + .push(format!("{}.{}", link_type, err)); + } else if !Preg::is_match(php_regex!("{^[A-Za-z0-9_./-]+$}"), &package) { + self.errors.borrow_mut().push(format!( + "{}.{} : invalid key, package names must be strings containing only [A-Za-z0-9_./-]", + link_type, package )); - if let Some(PhpMixed::Array(extra)) = - self.config.borrow_mut().get_mut("extra") - && let Some(ba) = extra.get_mut("branch-alias") - && let PhpMixed::Array(bam) = ba - { - bam.shift_remove(source_branch); - } - continue; } - - // normalize without -dev and ensure it's a numeric branch that is parseable - let trimmed = substr( - &target_branch_str, - 0, - Some((target_branch_str.len() as i64) - 4), - ); - let validated_target_branch = self.version_parser.normalize_branch(&trimmed)?; - if substr(&validated_target_branch, -4, None) != "-dev" { - self.warnings.borrow_mut().push(format!( - "extra.branch-alias.{} : the target branch ({}) must be a parseable number like 2.0-dev", - source_branch, target_branch_str + if !is_string(constraint) { + self.errors.borrow_mut().push(format!( + "{}.{} : invalid value, must be a string containing a version constraint", + link_type, package )); - if let Some(PhpMixed::Array(extra)) = - self.config.borrow_mut().get_mut("extra") - && let Some(ba) = extra.get_mut("branch-alias") - && let PhpMixed::Array(bam) = ba - { - bam.shift_remove(source_branch); - } - continue; - } - - // If using numeric aliases ensure the alias is a valid subversion - let source_prefix = self - .version_parser - .parse_numeric_alias_prefix(source_branch); - let target_prefix = self - .version_parser - .parse_numeric_alias_prefix(&target_branch_str); - if let (Some(sp), Some(tp)) = (source_prefix, target_prefix) - && !tp.to_lowercase().starts_with(&sp.to_lowercase()) - { - self.warnings.borrow_mut().push(format!( - "extra.branch-alias.{} : the target branch ({}) is not a valid numeric alias for this version", - source_branch, target_branch_str - )); - if let Some(PhpMixed::Array(extra)) = - self.config.borrow_mut().get_mut("extra") - && let Some(ba) = extra.get_mut("branch-alias") - && let PhpMixed::Array(bam) = ba + if let Some(PhpMixed::Array(arr)) = + self.config.borrow_mut().get_mut(link_type) { - bam.shift_remove(source_branch); + arr.shift_remove(&package); } - } - } - } - } - - if !self.errors.borrow().is_empty() { - return Err(anyhow::anyhow!(InvalidPackageException::new( - self.errors.borrow().clone(), - self.warnings.borrow().clone(), - config.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - ))); - } - - let package = self.loader.load( - self.config - .borrow() - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - Some(class), - )?; - *self.config.borrow_mut() = IndexMap::new(); - - Ok(package) - } -} - -impl ValidatingArrayLoader { - pub fn get_warnings(&self) -> Vec { - self.warnings.borrow().clone() - } - - pub fn get_errors(&self) -> Vec { - self.errors.borrow().clone() - } - - pub fn has_package_naming_error(name: &str, is_link: bool) -> Option { - if PlatformRepository::is_platform_package(name) { - return None; - } - - if !Preg::is_match( - php_regex!( - "{^[a-z0-9](?:[_.-]?[a-z0-9]++)*+/[a-z0-9](?:(?:[_.]|-{1,2})?[a-z0-9]++)*+$}iD" - ), - name, - ) { - return Some(format!( - "{} is invalid, it should have a vendor name, a forward slash, and a package name. The vendor and package name can be words separated by -, . or _. The complete name should match \"^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]?|-{{0,2}})[a-z0-9]+)*$\".", - name - )); - } - - let reserved_names = [ - "nul", "con", "prn", "aux", "com1", "com2", "com3", "com4", "com5", "com6", "com7", - "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", - ]; - let lower = strtolower(name); - let bits: Vec<&str> = lower.split('/').collect(); - if reserved_names.contains(&bits[0]) || reserved_names.contains(&bits[1]) { - return Some(format!( - "{} is reserved, package and vendor names can not match any of: {}.", - name, - reserved_names.join(", ") - )); - } - - if Preg::is_match(php_regex!("{\\.json$}"), name) { - return Some(format!( - "{} is invalid, package names can not end in .json, consider renaming it or perhaps using a -json suffix instead.", - name - )); - } - - if Preg::is_match(php_regex!("{[A-Z]}"), name) { - if is_link { - return Some(format!( - "{} is invalid, it should not contain uppercase characters. Please use {} instead.", - name, - strtolower(name) - )); - } - - let suggest_name = Preg::replace( - php_regex!("{(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))}"), - "\\1\\3-\\2\\4", - name, - ); - let suggest_name = strtolower(&suggest_name); - - return Some(format!( - "{} is invalid, it should not contain uppercase characters. We suggest using {} instead.", - name, suggest_name - )); - } + } else if constraint.as_string().unwrap_or("") != "self.version" { + let constraint_str = constraint.as_string().unwrap_or("").to_string(); + let link_constraint = + match self.version_parser.parse_constraints(&constraint_str) { + Ok(c) => c, + Err(e) => { + self.errors.borrow_mut().push(format!( + "{}.{} : invalid version constraint ({})", + link_type, package, e + )); + if let Some(PhpMixed::Array(arr)) = + self.config.borrow_mut().get_mut(link_type) + { + arr.shift_remove(&package); + } + continue; + } + }; - None - } + // check requires for unbound constraints on non-platform packages + if (self.flags & Self::CHECK_UNBOUND_CONSTRAINTS) != 0 + && link_type == "require" + && link_constraint.matches(&unbound_constraint) + && !PlatformRepository::is_platform_package(&package) + { + self.warnings.borrow_mut().push(format!( + "{}.{} : unbound version constraints ({}) should be avoided", + link_type, package, constraint_str + )); + } else if (self.flags & Self::CHECK_STRICT_CONSTRAINTS) != 0 + && link_type == "require" + && link_constraint + .as_constraint() + .is_some_and(|c| ["==", "="].contains(&c.get_operator())) + && AnyConstraint::from(SimpleConstraint::new( + ">=".to_string(), + "1.0.0.0-dev".to_string(), + None, + )) + .matches(&link_constraint) + { + self.warnings.borrow_mut().push(format!( + "{}.{} : exact version constraints ({}) should be avoided if the package follows semantic versioning", + link_type, package, constraint_str + )); + } - fn validate_regex(&self, property: &str, regex: &str, mandatory: bool) -> bool { - if !self.validate_string(property, mandatory) { - return false; - } + let compacted = Intervals::compact_constraint(&link_constraint)?; + if compacted.is_match_none() { + self.warnings.borrow_mut().push(format!( + "{}.{} : this version constraint cannot possibly match anything ({})", + link_type, package, constraint_str + )); + } + } - let value = self.config.borrow()[property] - .as_string() - .unwrap_or("") - .to_string(); - if !Preg::is_match(format!("{{^{}$}}u", regex), &value) { - let message = format!( - "{} : invalid value ({}), must match {}", - property, value, regex - ); - if mandatory { - self.errors.borrow_mut().push(message); - } else { - self.warnings.borrow_mut().push(message); + if link_type == "conflict" && self.config.borrow().contains_key("replace") { + let replace_map = self + .config + .borrow() + .get("replace") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let conflict_map = self + .config + .borrow() + .get("conflict") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let replace_map_flat: IndexMap = replace_map; + let conflict_map_flat: IndexMap = conflict_map; + let keys = array_intersect_key(&replace_map_flat, &conflict_map_flat); + if !keys.is_empty() { + self.errors.borrow_mut().push(format!( + "{}.{} : you cannot conflict with a package that is also replaced, as replace already creates an implicit conflict rule", + link_type, package + )); + if let Some(PhpMixed::Array(arr)) = + self.config.borrow_mut().get_mut(link_type) + { + arr.shift_remove(&package); + } + } + } + } } - self.config.borrow_mut().shift_remove(property); - - return false; - } - - true - } - - fn validate_string(&self, property: &str, mandatory: bool) -> bool { - if self.config.borrow().contains_key(property) - && !is_string(&self.config.borrow()[property]) - { - self.errors.borrow_mut().push(format!( - "{} : should be a string, {} given", - property, - get_debug_type(&self.config.borrow()[property]) - )); - self.config.borrow_mut().shift_remove(property); - - return false; } - let is_empty = !self.config.borrow().contains_key(property) - || trim( - self.config.borrow()[property].as_string().unwrap_or(""), - Some(" \t\n\r\0\u{0B}"), - ) - .is_empty(); - if is_empty { - if mandatory { - self.errors - .borrow_mut() - .push(format!("{} : must be present", property)); + if self.validate_array("suggest", false) && self.config.borrow().contains_key("suggest") { + let suggest_map = self.config.borrow()["suggest"] + .as_array() + .cloned() + .unwrap_or_default(); + for (package, description) in &suggest_map { + if !is_string(description) { + self.errors.borrow_mut().push(format!( + "suggest.{} : invalid value, must be a string describing why the package is suggested", + package + )); + if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("suggest") + { + arr.shift_remove(package); + } + } } - self.config.borrow_mut().shift_remove(property); - - return false; } - true - } - - fn validate_array(&self, property: &str, mandatory: bool) -> bool { - if self.config.borrow().contains_key(property) && !is_array(&self.config.borrow()[property]) + if self.validate_string("minimum-stability", false) + && self.config.borrow().contains_key("minimum-stability") { - self.errors.borrow_mut().push(format!( - "{} : should be an array, {} given", - property, - get_debug_type(&self.config.borrow()[property]) - )); - self.config.borrow_mut().shift_remove(property); - - return false; - } - - let is_empty = !self.config.borrow().contains_key(property) - || match &self.config.borrow()[property] { - PhpMixed::Array(m) => m.is_empty(), - PhpMixed::List(l) => l.is_empty(), - // is_array() above guarantees the value is Array or List here. - _ => unreachable!("validate_array: non-array value survived the is_array check"), - }; - if is_empty { - if mandatory { + let min_stability = self.config.borrow()["minimum-stability"] + .as_string() + .unwrap_or("") + .to_string(); + if !STABILITIES.contains_key(strtolower(&min_stability).as_str()) + && min_stability != "RC" + { self.errors.borrow_mut().push(format!( - "{} : must be present and contain at least one element", - property + "minimum-stability : invalid value ({}), must be one of {}", + min_stability, + STABILITIES.keys().copied().collect::>().join(", ") )); + self.config.borrow_mut().shift_remove("minimum-stability"); } - self.config.borrow_mut().shift_remove(property); - - return false; - } - - true - } - - fn validate_flat_array(&self, property: &str, regex: Option<&str>, mandatory: bool) -> bool { - if !self.validate_array(property, mandatory) { - return false; } - let mut pass = true; - let entries: Vec<(String, PhpMixed)> = self.config.borrow()[property] - .as_array() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default(); - for (key, value) in entries { - if !is_string(&value) && !is_numeric(&value) { - self.errors.borrow_mut().push(format!( - "{}.{} : must be a string or int, {} given", - property, - key, - get_debug_type(&value) - )); - if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut(property) { - arr.shift_remove(&key); + if self.validate_array("autoload", false) && self.config.borrow().contains_key("autoload") { + let types = [ + "psr-0", + "psr-4", + "classmap", + "files", + "exclude-from-classmap", + ]; + let autoload_keys: Vec = self.config.borrow()["autoload"] + .as_array() + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + for r#type in &autoload_keys { + let type_config = + self.config.borrow()["autoload"].as_array().unwrap()[r#type].clone(); + if !types.contains(&r#type.as_str()) { + self.errors.borrow_mut().push(format!( + "autoload : invalid value ({}), must be one of {}", + r#type, + types.join(", ") + )); + if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("autoload") + { + arr.shift_remove(r#type); + } } - pass = false; + if r#type == "psr-4" + && let Some(type_map) = type_config.as_array() + { + for (namespace, _dirs) in type_map { + let ns_str = namespace.as_str(); + if !ns_str.is_empty() && substr(ns_str, -1, None) != "\\" { + self.errors.borrow_mut().push(format!( + "autoload.psr-4 : invalid value ({}), namespaces must end with a namespace separator, should be {}\\\\", + ns_str, ns_str + )); + } + } + } + } + } - continue; + let has_psr4 = self + .config + .borrow() + .get("autoload") + .and_then(|v| v.as_array()) + .map(|m| m.contains_key("psr-4")) + .unwrap_or(false); + if has_psr4 && self.config.borrow().contains_key("target-dir") { + self.errors.borrow_mut().push( + "target-dir : this can not be used together with the autoload.psr-4 setting, remove target-dir to upgrade to psr-4".to_string() + ); + // Unset the psr-4 setting, since unsetting target-dir might + // interfere with other settings. + if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut("autoload") { + arr.shift_remove("psr-4"); } + } - if let Some(regex_str) = regex { - let value_str = php_to_string(&value); - if !Preg::is_match(format!("{{^{}$}}u", regex_str), &value_str) { - self.warnings.borrow_mut().push(format!( - "{}.{} : invalid value ({}), must match {}", - property, key, value_str, regex_str + for src_type in ["source", "dist"] { + if self.validate_array(src_type, false) + && !Self::is_empty_array(self.config.borrow().get(src_type)) + { + let section = self + .config + .borrow() + .get(src_type) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + // Mirror PHP `isset()`, which is false for both missing keys and null values. + let isset = + |key: &str| matches!(section.get(key), Some(v) if !matches!(v, PhpMixed::Null)); + if !isset("type") { + self.errors + .borrow_mut() + .push(format!("{}.type : must be present", src_type)); + } + if !isset("url") { + self.errors + .borrow_mut() + .push(format!("{}.url : must be present", src_type)); + } + if src_type == "source" && !isset("reference") { + self.errors + .borrow_mut() + .push(format!("{}.reference : must be present", src_type)); + } + if let Some(type_val) = section.get("type").filter(|_| isset("type")) + && !is_string(type_val) + { + self.errors.borrow_mut().push(format!( + "{}.type : should be a string, {} given", + src_type, + get_debug_type(type_val) )); - if let Some(PhpMixed::Array(arr)) = self.config.borrow_mut().get_mut(property) { - arr.shift_remove(&key); + } + if let Some(url_val) = section.get("url").filter(|_| isset("url")) + && !is_string(url_val) + { + self.errors.borrow_mut().push(format!( + "{}.url : should be a string, {} given", + src_type, + get_debug_type(url_val) + )); + } + if let Some(ref_val) = section.get("reference").filter(|_| isset("reference")) + && !is_string(ref_val) + && !is_int(ref_val) + { + self.errors.borrow_mut().push(format!( + "{}.reference : should be a string or int, {} given", + src_type, + get_debug_type(ref_val) + )); + } + if let Some(ref_val) = section.get("reference").filter(|_| isset("reference")) { + let ref_str = php_to_string(ref_val); + if Preg::is_match(php_regex!("{^\\s*-}"), &ref_str) { + self.errors.borrow_mut().push(format!( + "{}.reference : must not start with a \"-\", \"{}\" given", + src_type, ref_str + )); + } + } + if let Some(url_val) = section.get("url").filter(|_| isset("url")) { + let url_str = php_to_string(url_val); + if Preg::is_match(php_regex!("{^\\s*-}"), &url_str) { + self.errors.borrow_mut().push(format!( + "{}.url : must not start with a \"-\", \"{}\" given", + src_type, url_str + )); } - pass = false; } } } - pass - } + // TODO validate repositories + // TODO validate package repositories' packages using this recursively - fn validate_url(&self, property: &str, mandatory: bool) -> bool { - if !self.validate_string(property, mandatory) { - return false; - } + self.validate_flat_array("include-path", None, false); + self.validate_array("transport-options", false); - let value = self.config.borrow()[property] - .as_string() - .unwrap_or("") - .to_string(); - if !self.filter_url(&value, &["http", "https"]) { - self.warnings.borrow_mut().push(format!( - "{} : invalid value ({}), must be an http/https URL", - property, value - )); - self.config.borrow_mut().shift_remove(property); + // branch alias validation + let has_branch_alias = self + .config + .borrow() + .get("extra") + .and_then(|v| v.as_array()) + .map(|m| m.contains_key("branch-alias")) + .unwrap_or(false); + if has_branch_alias { + let branch_alias_val = + self.config.borrow()["extra"].as_array().unwrap()["branch-alias"].clone(); + if !is_array(&branch_alias_val) { + self.errors.borrow_mut().push( + "extra.branch-alias : must be an array of versions => aliases".to_string(), + ); + } else { + let branch_alias_map = branch_alias_val.as_array().cloned().unwrap_or_default(); + for (source_branch, target_branch) in &branch_alias_map { + if !is_string(target_branch) { + self.warnings.borrow_mut().push(format!( + "extra.branch-alias.{} : the target branch ({}) must be a string, \"{}\" received.", + source_branch, + json_encode(target_branch).unwrap_or_default(), + get_debug_type(target_branch) + )); + if let Some(PhpMixed::Array(extra)) = + self.config.borrow_mut().get_mut("extra") + && let Some(ba) = extra.get_mut("branch-alias") + && let PhpMixed::Array(bam) = ba + { + bam.shift_remove(source_branch); + } + continue; + } - return false; - } + let target_branch_str = target_branch.as_string().unwrap_or("").to_string(); - true - } + // ensure it is an alias to a -dev package + if substr(&target_branch_str, -4, None) != "-dev" { + self.warnings.borrow_mut().push(format!( + "extra.branch-alias.{} : the target branch ({}) must end in -dev", + source_branch, target_branch_str + )); + if let Some(PhpMixed::Array(extra)) = + self.config.borrow_mut().get_mut("extra") + && let Some(ba) = extra.get_mut("branch-alias") + && let PhpMixed::Array(bam) = ba + { + bam.shift_remove(source_branch); + } + continue; + } - fn filter_url(&self, value: &str, schemes: &[&str]) -> bool { - if value.is_empty() { - return true; - } + // normalize without -dev and ensure it's a numeric branch that is parseable + let trimmed = substr( + &target_branch_str, + 0, + Some((target_branch_str.len() as i64) - 4), + ); + let validated_target_branch = self.version_parser.normalize_branch(&trimmed)?; + if substr(&validated_target_branch, -4, None) != "-dev" { + self.warnings.borrow_mut().push(format!( + "extra.branch-alias.{} : the target branch ({}) must be a parseable number like 2.0-dev", + source_branch, target_branch_str + )); + if let Some(PhpMixed::Array(extra)) = + self.config.borrow_mut().get_mut("extra") + && let Some(ba) = extra.get_mut("branch-alias") + && let PhpMixed::Array(bam) = ba + { + bam.shift_remove(source_branch); + } + continue; + } - let bits = parse_url_all(value); - let bits_map = match bits { - PhpMixed::Array(m) => m, - _ => return false, - }; - let scheme = bits_map - .get("scheme") - .and_then(|v| v.as_string()) - .unwrap_or(""); - let host = bits_map - .get("host") - .and_then(|v| v.as_string()) - .unwrap_or(""); - if scheme.is_empty() || host.is_empty() { - return false; + // If using numeric aliases ensure the alias is a valid subversion + let source_prefix = self + .version_parser + .parse_numeric_alias_prefix(source_branch); + let target_prefix = self + .version_parser + .parse_numeric_alias_prefix(&target_branch_str); + if let (Some(sp), Some(tp)) = (source_prefix, target_prefix) + && !tp.to_lowercase().starts_with(&sp.to_lowercase()) + { + self.warnings.borrow_mut().push(format!( + "extra.branch-alias.{} : the target branch ({}) is not a valid numeric alias for this version", + source_branch, target_branch_str + )); + if let Some(PhpMixed::Array(extra)) = + self.config.borrow_mut().get_mut("extra") + && let Some(ba) = extra.get_mut("branch-alias") + && let PhpMixed::Array(bam) = ba + { + bam.shift_remove(source_branch); + } + } + } + } } - if !schemes.contains(&scheme) { - return false; + if !self.errors.borrow().is_empty() { + return Err(anyhow::anyhow!(InvalidPackageException::new( + self.errors.borrow().clone(), + self.warnings.borrow().clone(), + config.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + ))); } - true - } + let package = self.loader.load( + self.config + .borrow() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + Some(class), + )?; + *self.config.borrow_mut() = IndexMap::new(); - fn is_empty_array(val: Option<&PhpMixed>) -> bool { - match val { - Some(v) => match v { - PhpMixed::Array(m) => m.is_empty(), - PhpMixed::Null => true, - PhpMixed::Bool(false) => true, - PhpMixed::String(s) => s.is_empty(), - PhpMixed::Int(0) => true, - _ => false, - }, - None => true, - } + Ok(package) } } -- cgit v1.3.1