diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 20:22:58 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 22:17:14 +0900 |
| commit | 400a4f05a4f0c57b5bb212b4434c9300eb5fce3f (patch) | |
| tree | 171e3a6f21824c6348cd7de263a7c7825ac87dc6 /crates | |
| parent | 165257cac771f4b9e4ef3afee9954c65611908ae (diff) | |
| download | php-shirabe-400a4f05a4f0c57b5bb212b4434c9300eb5fce3f.tar.gz php-shirabe-400a4f05a4f0c57b5bb212b4434c9300eb5fce3f.tar.zst php-shirabe-400a4f05a4f0c57b5bb212b4434c9300eb5fce3f.zip | |
feat(port): port ConfigCommand.php
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/shirabe/src/command/config_command.rs | 1555 |
1 files changed, 1555 insertions, 0 deletions
diff --git a/crates/shirabe/src/command/config_command.rs b/crates/shirabe/src/command/config_command.rs index 2f37154..3924002 100644 --- a/crates/shirabe/src/command/config_command.rs +++ b/crates/shirabe/src/command/config_command.rs @@ -1 +1,1556 @@ //! ref: composer/src/Composer/Command/ConfigCommand.php + +use indexmap::IndexMap; + +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_external_packages::symfony::component::console::completion::completion_input::CompletionInput; +use shirabe_external_packages::symfony::component::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::component::console::input::input_option::InputOption; +use shirabe_external_packages::symfony::component::console::output::output_interface::OutputInterface; +use shirabe_php_shim::{ + array_filter, array_filter_use_key, array_is_list, array_map, array_merge, array_unique, + call_user_func, count, escapeshellcmd, exec, explode, file_exists, file_get_contents, implode, + in_array, is_array, is_bool, is_dir, is_numeric, is_object, is_string, json_encode, key, sort, + sprintf, str_replace, str_starts_with, strpos, strtolower, system, touch, var_export, + InvalidArgumentException, JsonObject, PhpMixed, RuntimeException, + ArrayObject, + JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, +}; + +use crate::advisory::auditor::Auditor; +use crate::command::base_config_command::BaseConfigCommand; +use crate::config::json_config_source::JsonConfigSource; +use crate::config::Config; +use crate::console::input::input_argument::InputArgument; +use crate::factory::Factory; +use crate::io::io_interface::IOInterface; +use crate::json::json_file::JsonFile; +use crate::package::base_package::{self, BasePackage}; +use crate::util::filesystem::Filesystem; +use crate::util::platform::Platform; +use crate::util::silencer::Silencer; +use shirabe_semver::version_parser::VersionParser; + +#[derive(Debug)] +pub struct ConfigCommand { + inner: BaseConfigCommand, + pub(crate) auth_config_file: Option<JsonFile>, + pub(crate) auth_config_source: Option<JsonConfigSource>, +} + +impl ConfigCommand { + /// List of additional configurable package-properties + pub(crate) const CONFIGURABLE_PACKAGE_PROPERTIES: &'static [&'static str] = &[ + "name", + "type", + "description", + "homepage", + "version", + "minimum-stability", + "prefer-stable", + "keywords", + "license", + "repositories", + "suggest", + "extra", + ]; + + pub(crate) fn configure(&mut self) { + let suggest_setting_keys = self.suggest_setting_keys(); + self.inner + .inner + .set_name("config") + .set_description("Sets config options") + .set_definition(vec![ + InputOption::new("global", Some("g"), Some(InputOption::VALUE_NONE), "Apply command to the global config file", None, vec![]), + InputOption::new("editor", Some("e"), Some(InputOption::VALUE_NONE), "Open editor", None, vec![]), + InputOption::new("auth", Some("a"), Some(InputOption::VALUE_NONE), "Affect auth config file (only used for --editor)", None, vec![]), + InputOption::new("unset", None, Some(InputOption::VALUE_NONE), "Unset the given setting-key", None, vec![]), + InputOption::new("list", Some("l"), Some(InputOption::VALUE_NONE), "List configuration settings", None, vec![]), + InputOption::new("file", Some("f"), Some(InputOption::VALUE_REQUIRED), "If you want to choose a different composer.json or config.json", None, vec![]), + InputOption::new("absolute", None, Some(InputOption::VALUE_NONE), "Returns absolute paths when fetching *-dir config values instead of relative", None, vec![]), + InputOption::new("json", Some("j"), Some(InputOption::VALUE_NONE), "JSON decode the setting value, to be used with extra.* keys", None, vec![]), + InputOption::new("merge", Some("m"), 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, vec![]), + 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, vec![]), + InputOption::new("source", None, Some(InputOption::VALUE_NONE), "Display where the config value is loaded from", None, vec![]), + InputArgument::new("setting-key", None, "Setting key", None, suggest_setting_keys), + InputArgument::new("setting-value", Some(InputArgument::IS_ARRAY), "Setting value", None, Box::new(|_| vec![])), + ]) + .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<comment>%command.full_name% bin-dir bin/</comment>\n\n\ + To read a config setting:\n\n\ + \t<comment>%command.full_name% bin-dir</comment>\n\ + \tOutputs: <info>bin</info>\n\n\ + To edit the global config.json file:\n\n\ + \t<comment>%command.full_name% --global</comment>\n\n\ + To add a repository:\n\n\ + \t<comment>%command.full_name% repositories.foo vcs https://bar.com</comment>\n\n\ + To remove a repository (repo is a short alias for repositories):\n\n\ + \t<comment>%command.full_name% --unset repo.foo</comment>\n\n\ + To disable packagist.org:\n\n\ + \t<comment>%command.full_name% repo.packagist.org false</comment>\n\n\ + You can alter repositories in the global config.json file by passing in the\n\ + <info>--global</info> option.\n\n\ + To add or edit suggested packages you can use:\n\n\ + \t<comment>%command.full_name% suggest.package reason for the suggestion</comment>\n\n\ + To add or edit extra properties you can use:\n\n\ + \t<comment>%command.full_name% extra.property value</comment>\n\n\ + Or to add a complex value you can use json with:\n\n\ + \t<comment>%command.full_name% extra.property --json '{\"foo\":true, \"bar\": []}'</comment>\n\n\ + To edit the file in an external editor:\n\n\ + \t<comment>%command.full_name% --editor</comment>\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<comment>%command.full_name% --list</comment>\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<comment>%command.full_name% --editor --global</comment>\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#config", + ); + } + + pub(crate) fn initialize( + &mut self, + input: &dyn InputInterface, + output: &dyn OutputInterface, + ) -> anyhow::Result<()> { + self.inner.initialize(input, output)?; + + let auth_config_file = self.inner.get_auth_config_file(input, self.inner.config.as_ref().unwrap()); + + self.auth_config_file = Some(JsonFile::new(auth_config_file, None, Some(self.inner.inner.get_io()))); + self.auth_config_source = Some(JsonConfigSource::new_with_auth(self.auth_config_file.as_ref().unwrap(), true)); + + // Initialize the global file if it's not there, ignoring any warnings or notices + if input.get_option("global").as_bool() == Some(true) && !self.auth_config_file.as_ref().unwrap().exists() { + touch(self.auth_config_file.as_ref().unwrap().get_path()); + let mut empty_objs: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + for k in &[ + "bitbucket-oauth", "github-oauth", "gitlab-oauth", "gitlab-token", + "http-basic", "bearer", "forgejo-token", + ] { + empty_objs.insert(k.to_string(), Box::new(PhpMixed::Object(ArrayObject::new()))); + } + self.auth_config_file.as_mut().unwrap().write(PhpMixed::Array(empty_objs))?; + let path_clone = self.auth_config_file.as_ref().unwrap().get_path().to_string(); + Silencer::call(|| { + shirabe_php_shim::chmod(&path_clone, 0o600); + Ok(()) + }); + } + Ok(()) + } + + pub(crate) fn execute( + &mut self, + input: &dyn InputInterface, + output: &dyn OutputInterface, + ) -> anyhow::Result<i64> { + // Open file in editor + if input.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)).is_empty() { + editor = Some(candidate.to_string()); + break; + } + } + } + } else { + editor = Some(escapeshellcmd(&editor.unwrap())); + } + + let file = if input.get_option("auth").as_bool() == Some(true) { + self.auth_config_file.as_ref().unwrap().get_path().to_string() + } else { + self.inner.config_file.as_ref().unwrap().get_path().to_string() + }; + system(&format!( + "{} {}{}", + editor.unwrap_or_default(), + file, + if Platform::is_windows() { "" } else { " > `tty`" } + )); + + return Ok(0); + } + + if input.get_option("global").as_bool() != Some(true) { + self.inner.config.as_mut().unwrap().merge( + self.inner.config_file.as_ref().unwrap().read()?, + self.inner.config_file.as_ref().unwrap().get_path(), + ); + let auth_data: PhpMixed = if self.auth_config_file.as_ref().unwrap().exists() { + self.auth_config_file.as_ref().unwrap().read()? + } else { + PhpMixed::Array(IndexMap::new()) + }; + let mut wrap: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + wrap.insert("config".to_string(), Box::new(auth_data)); + self.inner.config.as_mut().unwrap().merge( + PhpMixed::Array(wrap), + self.auth_config_file.as_ref().unwrap().get_path(), + ); + } + + self.inner.inner.get_io().load_configuration(self.inner.config.as_ref().unwrap()); + + // List the configuration of the file settings + if input.get_option("list").as_bool() == Some(true) { + self.list_configuration( + self.inner.config.as_ref().unwrap().all(), + self.inner.config.as_ref().unwrap().raw(), + output, + None, + input.get_option("source").as_bool() == Some(true), + ); + + return Ok(0); + } + + let setting_key_arg = input.get_argument("setting-key"); + let setting_key = match setting_key_arg.as_string() { + Some(s) => s.to_string(), + None => return Ok(0), + }; + + // If the user enters in a config variable, parse it and save to file + let setting_values_raw = input.get_argument("setting-value"); + let setting_values: Vec<String> = 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.get_option("unset").as_bool() == Some(true) { + return Err(RuntimeException { + message: "You can not combine a setting value with --unset".to_string(), + code: 0, + } + .into()); + } + + // show the value if no value is provided + if setting_values.is_empty() && input.get_option("unset").as_bool() != Some(true) { + let properties: Vec<&'static str> = Self::CONFIGURABLE_PACKAGE_PROPERTIES.to_vec(); + let mut properties_defaults: IndexMap<String, PhpMixed> = 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 = self.inner.config_file.as_ref().unwrap().read()?; + let mut data = self.inner.config.as_ref().unwrap().all(); + let mut source = self.inner.config.as_ref().unwrap().get_source_of_value(&setting_key); + + let mut value: PhpMixed; + let mut matches: Vec<String> = vec![]; + if Preg::is_match("/^repos?(?:itories)?(?:\\.(.+))?/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if matches.get(1).is_none() { + value = data.as_array().and_then(|a| a.get("repositories")).map(|v| (**v).clone()).unwrap_or_else(|| PhpMixed::Array(IndexMap::new())); + } else { + let repo_key = matches[1].clone(); + let repos = data.as_array().and_then(|a| a.get("repositories")).map(|v| (**v).clone()); + 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); + if bits[0] == "extra" || bits[0] == "suggest" { + data = raw_data.clone(); + } else { + data = data.as_array().and_then(|a| a.get("config")).map(|v| (**v).clone()).unwrap_or(PhpMixed::Null); + } + let mut r#match = false; + let mut key_acc: Option<String> = 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) = data.as_array() { + if let Some(v) = arr.get(&new_key) { + r#match = true; + data = (**v).clone(); + key_acc = None; + } + } + } + + if !r#match { + return Err(RuntimeException { + message: format!("{} is not defined.", setting_key), + code: 0, + } + .into()); + } + + value = data; + } else if data.as_array().and_then(|a| a.get("config")).and_then(|c| c.as_array()).map(|c| c.contains_key(&setting_key)).unwrap_or(false) { + value = self.inner.config.as_ref().unwrap().get_with_flags( + &setting_key, + if input.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( + &file_get_contents(JsonFile::COMPOSER_SCHEMA_PATH).unwrap_or_default(), + "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")) + .map(|v| (**v).clone()); + if let Some(tv) = type_value { + let type_array = match &tv { + PhpMixed::List(_) | PhpMixed::Array(_) => tv, + other => PhpMixed::List(vec![Box::new(other.clone())]), + }; + if in_array( + "object", + &type_array + .as_list() + .map(|l| l.iter().filter_map(|v| v.as_string().map(|s| s.to_string())).collect::<Vec<_>>()) + .unwrap_or_default(), + true, + ) { + value = PhpMixed::Object(ArrayObject::new()); + } + } + } + } else if raw_data.as_array().and_then(|a| a.get(&setting_key)).is_some() + && in_array(setting_key.as_str(), &properties, true) + { + value = (**raw_data.as_array().unwrap().get(&setting_key).unwrap()).clone(); + source = self.inner.config_file.as_ref().unwrap().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()); + } + + let value_str = if is_array(&value) || is_object(&value) || is_bool(&value) { + JsonFile::encode(&value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)? + } else { + value.as_string().unwrap_or("").to_string() + }; + + let mut source_of_config_value = String::new(); + if input.get_option("source").as_bool() == Some(true) { + source_of_config_value = format!(" ({})", source); + } + + self.inner.inner.get_io().write( + &format!("{}{}", value_str, source_of_config_value), + true, + IOInterface::QUIET, + ); + + return Ok(0); + } + + let values: Vec<String> = setting_values; // what the user is trying to add/change + + let boolean_validator = |val: &PhpMixed| -> bool { + in_array( + val.as_string().unwrap_or(""), + &vec!["true".to_string(), "false".to_string(), "1".to_string(), "0".to_string()], + true, + ) + }; + let boolean_normalizer = |val: &PhpMixed| -> PhpMixed { + let s = val.as_string().unwrap_or(""); + PhpMixed::Bool(s != "false" && s != "" && 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.get_option("unset").as_bool() == Some(true) && setting_key == "audit" { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&setting_key); + + return Ok(0); + } + + if input.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" && self.inner.config.as_ref().unwrap().get("disable-tls").as_bool().unwrap_or(false) { + self.inner.inner.get_io().write_error("<info>You are now running Composer with SSL/TLS protection enabled.</info>"); + } + + self.inner.config_source.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: Vec<String> = vec![]; + if Preg::is_match("/^preferred-install\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.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.inner.config_source.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: Vec<String> = vec![]; + if Preg::is_match("{^allow-plugins\\.([a-zA-Z0-9/*-]+)}", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&setting_key); + + return Ok(0); + } + + if !boolean_validator(&PhpMixed::String(values[0].clone())) { + return Err(RuntimeException { + message: sprintf( + "\"%s\" is an invalid value", + &[values[0].clone().into()], + ), + code: 0, + } + .into()); + } + + let normalized_value = boolean_normalizer(&PhpMixed::String(values[0].clone())); + + self.inner.config_source.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.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.get_option("unset").as_bool() == Some(true) + && (unique_props.contains_key(&setting_key) || multi_props.contains_key(&setting_key)) + { + self.inner.config_source.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: Vec<String> = vec![]; + if Preg::is_match_strict_groups("/^repos?(?:itories)?\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_repository(&matches[1]); + + return Ok(0); + } + + if 2 == count(&values) { + let mut repo: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + repo.insert("type".to_string(), Box::new(PhpMixed::String(values[0].clone()))); + repo.insert("url".to_string(), Box::new(PhpMixed::String(values[1].clone()))); + self.inner.config_source.as_mut().unwrap().add_repository( + &matches[1], + PhpMixed::Array(repo), + input.get_option("append").as_bool() == Some(true), + ); + + return Ok(0); + } + + if 1 == count(&values) { + let value = strtolower(&values[0]); + if boolean_validator(&PhpMixed::String(value.clone())) { + if !boolean_normalizer(&PhpMixed::String(value.clone())).as_bool().unwrap_or(false) { + self.inner.config_source.as_mut().unwrap().add_repository( + &matches[1], + PhpMixed::Bool(false), + input.get_option("append").as_bool() == Some(true), + ); + + return Ok(0); + } + } else { + let value = JsonFile::parse_json(&values[0], "composer.json")?; + self.inner.config_source.as_mut().unwrap().add_repository( + &matches[1], + value, + input.get_option("append").as_bool() == Some(true), + ); + + return Ok(0); + } + } + + return Err(RuntimeException { + message: "You must pass the type and a url. Example: php composer.phar config repositories.foo vcs https://bar.com".to_string(), + code: 0, + } + .into()); + } + + // handle extra + let mut matches: Vec<String> = vec![]; + if Preg::is_match("/^extra\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_property(&setting_key); + + return Ok(0); + } + + let mut value = PhpMixed::String(values[0].clone()); + if input.get_option("json").as_bool() == Some(true) { + value = JsonFile::parse_json(&values[0], "composer.json")?; + if input.get_option("merge").as_bool() == Some(true) { + let current_value_outer = self.inner.config_file.as_ref().unwrap().read()?; + let bits = explode(".", &setting_key); + let mut current_value: PhpMixed = current_value_outer; + for bit in &bits { + current_value = current_value + .as_array() + .and_then(|a| a.get(bit)) + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Null); + } + if is_array(¤t_value) && is_array(&value) { + if array_is_list(¤t_value) && array_is_list(&value) { + value = PhpMixed::List(array_merge( + current_value.as_list().cloned().unwrap_or_default(), + value.as_list().cloned().unwrap_or_default(), + )); + } else { + // PHP "+" operator on arrays: keep keys from left, fill from right + let mut merged: IndexMap<String, Box<PhpMixed>> = value.as_array().cloned().unwrap_or_default(); + if let Some(cv) = current_value.as_array() { + for (k, v) in cv { + if !merged.contains_key(k) { + merged.insert(k.clone(), v.clone()); + } + } + } + value = PhpMixed::Array(merged); + } + } + } + } + self.inner.config_source.as_mut().unwrap().add_property(&setting_key, value); + + return Ok(0); + } + + // handle suggest + let mut matches: Vec<String> = vec![]; + if Preg::is_match("/^suggest\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_property(&setting_key); + + return Ok(0); + } + + self.inner + .config_source + .as_mut() + .unwrap() + .add_property(&setting_key, PhpMixed::String(implode(" ", &values))); + + return Ok(0); + } + + // handle unsetting extra/suggest + if in_array(setting_key.as_str(), &vec!["suggest".to_string(), "extra".to_string()], true) + && input.get_option("unset").as_bool() == Some(true) + { + self.inner.config_source.as_mut().unwrap().remove_property(&setting_key); + + return Ok(0); + } + + // handle platform + let mut matches: Vec<String> = vec![]; + if Preg::is_match("/^platform\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&setting_key); + + return Ok(0); + } + + let value = if values[0] == "false" { + PhpMixed::Bool(false) + } else { + PhpMixed::String(values[0].clone()) + }; + self.inner.config_source.as_mut().unwrap().add_config_setting(&setting_key, value); + + return Ok(0); + } + + // handle unsetting platform + if setting_key == "platform" && input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&setting_key); + + return Ok(0); + } + + // handle audit.ignore and audit.ignore-abandoned with --merge support + if in_array( + setting_key.as_str(), + &vec!["audit.ignore".to_string(), "audit.ignore-abandoned".to_string()], + true, + ) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&setting_key); + + return Ok(0); + } + + let mut value: PhpMixed = PhpMixed::List(values.iter().map(|s| Box::new(PhpMixed::String(s.clone()))).collect()); + if input.get_option("json").as_bool() == Some(true) { + value = JsonFile::parse_json(&values[0], "composer.json")?; + if !is_array(&value) { + return Err(RuntimeException { + message: format!("Expected an array or object for {}", setting_key), + code: 0, + } + .into()); + } + } + + if input.get_option("merge").as_bool() == Some(true) { + let current_config = self.inner.config_file.as_ref().unwrap().read()?; + let key_suffix = str_replace("audit.", "", &setting_key); + let current_value = current_config + .as_array() + .and_then(|a| a.get("config")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get("audit")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get(&key_suffix)) + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Null); + + if !current_value.is_null() && is_array(¤t_value) && is_array(&value) { + if array_is_list(¤t_value) && array_is_list(&value) { + // Both are lists, merge them + value = PhpMixed::List(array_merge( + current_value.as_list().cloned().unwrap_or_default(), + value.as_list().cloned().unwrap_or_default(), + )); + } else if !array_is_list(¤t_value) && !array_is_list(&value) { + // Both are associative arrays (objects), merge them + let mut merged: IndexMap<String, Box<PhpMixed>> = value.as_array().cloned().unwrap_or_default(); + if let Some(cv) = current_value.as_array() { + for (k, v) in cv { + if !merged.contains_key(k) { + merged.insert(k.clone(), v.clone()); + } + } + } + value = PhpMixed::Array(merged); + } else { + return Err(RuntimeException { + message: format!("Cannot merge array and object for {}", setting_key), + code: 0, + } + .into()); + } + } + } + + self.inner.config_source.as_mut().unwrap().add_config_setting(&setting_key, value); + + return Ok(0); + } + + // handle auth + let mut matches: Vec<String> = vec![]; + if Preg::is_match( + "/^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|http-basic|custom-headers|bearer|forgejo-token)\\.(.+)/", + &setting_key, + Some(&mut matches), + ).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.auth_config_source.as_mut().unwrap().remove_config_setting(&format!("{}.{}", matches[1], matches[2])); + self.inner.config_source.as_mut().unwrap().remove_config_setting(&format!("{}.{}", matches[1], matches[2])); + + return Ok(0); + } + + let key = format!("{}.{}", matches[1], matches[2]); + if matches[1] == "bitbucket-oauth" { + if 2 != count(&values) { + return Err(RuntimeException { + message: format!("Expected two arguments (consumer-key, consumer-secret), got {}", count(&values)), + code: 0, + } + .into()); + } + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + let mut obj: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + obj.insert("consumer-key".to_string(), Box::new(PhpMixed::String(values[0].clone()))); + obj.insert("consumer-secret".to_string(), Box::new(PhpMixed::String(values[1].clone()))); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::Array(obj)); + } else if matches[1] == "gitlab-token" && 2 == count(&values) { + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + let mut obj: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + obj.insert("username".to_string(), Box::new(PhpMixed::String(values[0].clone()))); + obj.insert("token".to_string(), Box::new(PhpMixed::String(values[1].clone()))); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::Array(obj)); + } else if in_array( + matches[1].as_str(), + &vec!["github-oauth".to_string(), "gitlab-oauth".to_string(), "gitlab-token".to_string(), "bearer".to_string()], + true, + ) { + if 1 != count(&values) { + return Err(RuntimeException { + message: "Too many arguments, expected only one token".to_string(), + code: 0, + } + .into()); + } + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::String(values[0].clone())); + } else if matches[1] == "http-basic" { + if 2 != count(&values) { + return Err(RuntimeException { + message: format!("Expected two arguments (username, password), got {}", count(&values)), + code: 0, + } + .into()); + } + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + let mut obj: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + obj.insert("username".to_string(), Box::new(PhpMixed::String(values[0].clone()))); + obj.insert("password".to_string(), Box::new(PhpMixed::String(values[1].clone()))); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::Array(obj)); + } else if matches[1] == "custom-headers" { + if count(&values) == 0 { + 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<Box<PhpMixed>> = 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: Vec<String> = vec![]; + if !Preg::is_match("/^[^:]+:\\s*.+$/", header, Some(&mut header_parts)).unwrap_or(false) { + return Err(RuntimeException { + message: format!("Header \"{}\" is not in \"Header-Name: Header-Value\" format", header), + code: 0, + } + .into()); + } + + formatted_headers.push(Box::new(PhpMixed::String(header.clone()))); + } + + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::List(formatted_headers)); + } else if matches[1] == "forgejo-token" { + if 2 != count(&values) { + return Err(RuntimeException { + message: format!("Expected two arguments (username, access token), got {}", count(&values)), + code: 0, + } + .into()); + } + self.inner.config_source.as_mut().unwrap().remove_config_setting(&key); + let mut obj: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + obj.insert("username".to_string(), Box::new(PhpMixed::String(values[0].clone()))); + obj.insert("token".to_string(), Box::new(PhpMixed::String(values[1].clone()))); + self.auth_config_source.as_mut().unwrap().add_config_setting(&key, PhpMixed::Array(obj)); + } + + return Ok(0); + } + + // handle script + let mut matches: Vec<String> = vec![]; + if Preg::is_match("/^scripts\\.(.+)/", &setting_key, Some(&mut matches)).unwrap_or(false) { + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.as_mut().unwrap().remove_property(&setting_key); + + return Ok(0); + } + + let value: PhpMixed = if count(&values) > 1 { + PhpMixed::List(values.iter().map(|s| Box::new(PhpMixed::String(s.clone()))).collect()) + } else { + PhpMixed::String(values[0].clone()) + }; + self.inner.config_source.as_mut().unwrap().add_property(&setting_key, value); + + return Ok(0); + } + + // handle unsetting other top level properties + if input.get_option("unset").as_bool() == Some(true) { + self.inner.config_source.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()) + } + + pub(crate) fn handle_single_value( + &mut self, + key: &str, + callbacks: &(ValidatorFn, NormalizerFn), + values: &Vec<String>, + method: &str, + ) -> anyhow::Result<()> { + let (validator, normalizer) = callbacks; + if 1 != count(values) { + return Err(RuntimeException { + message: "You can only pass one value. Example: php composer.phar 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: sprintf( + &format!("\"%s\" is an invalid value{}", suffix), + &[values[0].clone().into()], + ), + code: 0, + } + .into()); + } + + let normalized_value = normalizer(&PhpMixed::String(values[0].clone())); + + if key == "disable-tls" { + if !normalized_value.as_bool().unwrap_or(false) + && self.inner.config.as_ref().unwrap().get("disable-tls").as_bool().unwrap_or(false) + { + self.inner.inner.get_io().write_error("<info>You are now running Composer with SSL/TLS protection enabled.</info>"); + } else if normalized_value.as_bool().unwrap_or(false) + && !self.inner.config.as_ref().unwrap().get("disable-tls").as_bool().unwrap_or(false) + { + self.inner.inner.get_io().write_error("<warning>You are now running Composer with SSL/TLS protection disabled.</warning>"); + } + } + + call_user_func( + self.inner.config_source.as_mut().unwrap(), + method, + vec![PhpMixed::String(key.to_string()), normalized_value], + ); + Ok(()) + } + + pub(crate) fn handle_multi_value( + &mut self, + key: &str, + callbacks: &(ValidatorFn, NormalizerFn), + values: &Vec<String>, + method: &str, + ) -> anyhow::Result<()> { + let (validator, normalizer) = callbacks; + let values_mixed = PhpMixed::List(values.iter().map(|s| Box::new(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: sprintf( + &format!("%s is an invalid value{}", suffix), + &[json_encode(&values_mixed, 0).into()], + ), + code: 0, + } + .into()); + } + + call_user_func( + self.inner.config_source.as_mut().unwrap(), + method, + vec![PhpMixed::String(key.to_string()), normalizer(&values_mixed)], + ); + Ok(()) + } + + /// Display the contents of the file in a pretty formatted way + pub(crate) fn list_configuration( + &self, + contents: PhpMixed, + raw_contents: PhpMixed, + output: &dyn OutputInterface, + k: Option<String>, + show_source: bool, + ) { + let orig_k = k.clone(); + let io = self.inner.inner.get_io(); + 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() && !in_array( + key.as_str(), + &vec!["config".to_string(), "repositories".to_string()], + true, + ) { + continue; + } + + let raw_val = raw_contents_arr.get(key).map(|v| (**v).clone()).unwrap_or(PhpMixed::Null); + + let value_inner = (**value).clone(); + + if is_array(&value_inner) + && (!is_numeric(&key_first_key(&value_inner).unwrap_or_default()) + || (key == "repositories" && k.is_none())) + { + let mut new_k = k.clone().unwrap_or_default(); + new_k.push_str(&Preg::replace("{^config\\.}", "", &format!("{}.", key))); + k = Some(new_k); + self.list_configuration(value_inner, raw_val, output, k.clone(), show_source); + k = orig_k.clone(); + + continue; + } + + let value_display: String = if is_array(&value_inner) { + let arr_strs: Vec<String> = value_inner + .as_list() + .map(|l| { + l.iter() + .map(|val| { + if is_array(val) { + json_encode(val, 0) + } else { + val.as_string().unwrap_or("").to_string() + } + }) + .collect::<Vec<_>>() + }) + .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.inner.config.as_ref().unwrap().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) { + link = "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("{\\..*$}", "", &id_source); + let id = Preg::replace("{[^a-z0-9]}i", "-", &strtolower(&shirabe_php_shim::trim(&id, " \t\n\r\0\u{0B}"))); + let id = Preg::replace("{-+}", "-", &id); + link = 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 { + io.write( + &format!( + "[<fg=yellow;href={}>{}{}</>] <info>{} ({})</info>{}", + link, + k.clone().unwrap_or_default(), + key, + raw_val.as_string().unwrap_or(""), + value_display, + source + ), + true, + IOInterface::QUIET, + ); + } else { + io.write( + &format!( + "[<fg=yellow;href={}>{}{}</>] <info>{}</info>{}", + link, + k.clone().unwrap_or_default(), + key, + value_display, + source + ), + true, + IOInterface::QUIET, + ); + } + } + } + + /// Suggest setting-keys, while taking given options in account. + fn suggest_setting_keys(&self) -> Box<dyn Fn(&CompletionInput) -> Vec<String>> { + Box::new(|input: &CompletionInput| -> Vec<String> { + if input.get_option("list").as_bool() == Some(true) + || input.get_option("editor").as_bool() == Some(true) + || input.get_option("auth").as_bool() == Some(true) + { + return vec![]; + } + + // initialize configuration + let mut config = match Factory::create_config(None) { + Ok(c) => c, + Err(_) => return vec![], + }; + + // load configuration + // TODO: BaseConfigCommand::get_composer_config_file is an instance method; using a free helper here. + let config_file = JsonFile::new(get_composer_config_file_static(input, &config), None, None); + if config_file.exists() { + config.merge(config_file.read().unwrap_or(PhpMixed::Null), config_file.get_path()); + } + + // load auth-configuration + let auth_config_file = JsonFile::new(get_auth_config_file_static(input, &config), None, None); + if auth_config_file.exists() { + let mut wrap: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + wrap.insert("config".to_string(), Box::new(auth_config_file.read().unwrap_or(PhpMixed::Null))); + config.merge(PhpMixed::Array(wrap), auth_config_file.get_path()); + } + + // collect all configuration setting-keys + let raw_config = config.raw(); + let raw_arr = raw_config.as_array().cloned().unwrap_or_default(); + let mut keys: Vec<String> = array_merge( + flatten_setting_keys(raw_arr.get("config").map(|v| (**v).clone()).unwrap_or(PhpMixed::Null), ""), + flatten_setting_keys(raw_arr.get("repositories").map(|v| (**v).clone()).unwrap_or(PhpMixed::Null), "repositories."), + ); + + // if unsetting … + if input.get_option("unset").as_bool() == Some(true) { + // … keep only the currently customized setting-keys … + let sources = vec![config_file.get_path().to_string(), auth_config_file.get_path().to_string()]; + keys = array_filter(keys, |k: &String| -> bool { + in_array(config.get_source_of_value(k).as_str(), &sources, true) + }); + } else { + // … add all configurable package-properties, no matter if it exist + let configurable: Vec<String> = ConfigCommand::CONFIGURABLE_PACKAGE_PROPERTIES.iter().map(|s| s.to_string()).collect(); + keys = array_merge(keys, configurable); + + // 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 + } + + // add all existing configurable package-properties + if config_file.exists() { + let configurable: Vec<String> = ConfigCommand::CONFIGURABLE_PACKAGE_PROPERTIES.iter().map(|s| s.to_string()).collect(); + let properties = array_filter_use_key( + config_file.read().unwrap_or(PhpMixed::Null).as_array().cloned().unwrap_or_default(), + |key: &String| -> bool { in_array(key.as_str(), &configurable, true) }, + ); + + keys = array_merge(keys, flatten_setting_keys(PhpMixed::Array(properties), "")); + } + + // filter settings-keys by completion value + let completion_value = input.get_completion_value(); + + if completion_value != "" { + keys = array_filter(keys, |key: &String| -> bool { + str_starts_with(key, &completion_value) + }); + } + + sort(&mut keys); + + array_unique(keys) + }) + } +} + +// PHP signature: function ($val): bool / ($val) -> bool/string +pub type ValidatorFn = Box<dyn Fn(&PhpMixed) -> PhpMixed>; +pub type NormalizerFn = Box<dyn Fn(&PhpMixed) -> PhpMixed>; + +fn boolean_validator(val: &PhpMixed) -> PhpMixed { + PhpMixed::Bool(in_array( + val.as_string().unwrap_or(""), + &vec!["true".to_string(), "false".to_string(), "1".to_string(), "0".to_string()], + true, + )) +} + +fn boolean_normalizer(val: &PhpMixed) -> PhpMixed { + let s = val.as_string().unwrap_or(""); + PhpMixed::Bool(s != "false" && s != "" && s != "0") +} + +fn build_unique_config_values() -> IndexMap<String, (ValidatorFn, NormalizerFn)> { + let mut m: IndexMap<String, (ValidatorFn, NormalizerFn)> = IndexMap::new(); + + let identity: NormalizerFn = Box::new(|val: &PhpMixed| -> PhpMixed { val.clone() }); + + m.insert( + "process-timeout".to_string(), + ( + Box::new(|val| PhpMixed::Bool(is_numeric(val.as_string().unwrap_or("")))), + Box::new(|val| PhpMixed::Int(shirabe_php_shim::intval(val.as_string().unwrap_or("0")))), + ), + ); + m.insert("use-include-path".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("use-github-api".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert( + "preferred-install".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["auto".to_string(), "source".to_string(), "dist".to_string()], true))), + Box::new(|val| val.clone()), + ), + ); + m.insert( + "gitlab-protocol".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["git".to_string(), "http".to_string(), "https".to_string()], true))), + Box::new(|val| val.clone()), + ), + ); + m.insert( + "store-auths".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["true".to_string(), "false".to_string(), "prompt".to_string()], true))), + Box::new(|val| { + let s = val.as_string().unwrap_or(""); + if s == "prompt" { + PhpMixed::String("prompt".to_string()) + } else { + PhpMixed::Bool(s != "false" && s != "" && s != "0") + } + }), + ), + ); + m.insert("notify-on-install".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("vendor-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("bin-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("archive-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("archive-format".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("data-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("cache-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("cache-files-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("cache-repo-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("cache-vcs-dir".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert( + "cache-ttl".to_string(), + ( + Box::new(|val| PhpMixed::Bool(is_numeric(val.as_string().unwrap_or("")))), + Box::new(|val| PhpMixed::Int(shirabe_php_shim::intval(val.as_string().unwrap_or("0")))), + ), + ); + m.insert( + "cache-files-ttl".to_string(), + ( + Box::new(|val| PhpMixed::Bool(is_numeric(val.as_string().unwrap_or("")))), + Box::new(|val| PhpMixed::Int(shirabe_php_shim::intval(val.as_string().unwrap_or("0")))), + ), + ); + m.insert( + "cache-files-maxsize".to_string(), + ( + Box::new(|val| PhpMixed::Bool(Preg::is_match("/^\\s*([0-9.]+)\\s*(?:([kmg])(?:i?b)?)?\\s*$/i", val.as_string().unwrap_or(""), None).unwrap_or(false))), + Box::new(|val| val.clone()), + ), + ); + m.insert( + "bin-compat".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["auto".to_string(), "full".to_string(), "proxy".to_string(), "symlink".to_string()], false))), + Box::new(|val| val.clone()), + ), + ); + m.insert( + "discard-changes".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["stash".to_string(), "true".to_string(), "false".to_string(), "1".to_string(), "0".to_string()], true))), + Box::new(|val| { + let s = val.as_string().unwrap_or(""); + if s == "stash" { + PhpMixed::String("stash".to_string()) + } else { + PhpMixed::Bool(s != "false" && s != "" && s != "0") + } + }), + ), + ); + m.insert( + "autoloader-suffix".to_string(), + ( + Box::new(|val| PhpMixed::Bool(is_string(val))), + Box::new(|val| { + if val.as_string() == Some("null") { + PhpMixed::Null + } else { + val.clone() + } + }), + ), + ); + m.insert("sort-packages".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("optimize-autoloader".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("classmap-authoritative".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("apcu-autoloader".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("prepend-autoloader".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("update-with-minimal-changes".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("disable-tls".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("secure-http".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert( + "bump-after-update".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["dev".to_string(), "no-dev".to_string(), "true".to_string(), "false".to_string(), "1".to_string(), "0".to_string()], true))), + Box::new(|val| { + let s = val.as_string().unwrap_or(""); + if s == "dev" || s == "no-dev" { + val.clone() + } else { + PhpMixed::Bool(s != "false" && s != "" && s != "0") + } + }), + ), + ); + m.insert( + "cafile".to_string(), + ( + Box::new(|val| PhpMixed::Bool(file_exists(val.as_string().unwrap_or("")) && Filesystem::is_readable(val.as_string().unwrap_or("")))), + Box::new(|val| if val.as_string() == Some("null") { PhpMixed::Null } else { val.clone() }), + ), + ); + m.insert( + "capath".to_string(), + ( + Box::new(|val| PhpMixed::Bool(is_dir(val.as_string().unwrap_or("")) && Filesystem::is_readable(val.as_string().unwrap_or("")))), + Box::new(|val| if val.as_string() == Some("null") { PhpMixed::Null } else { val.clone() }), + ), + ); + m.insert("github-expose-hostname".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("htaccess-protect".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("lock".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("allow-plugins".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert( + "platform-check".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["php-only".to_string(), "true".to_string(), "false".to_string(), "1".to_string(), "0".to_string()], true))), + Box::new(|val| { + let s = val.as_string().unwrap_or(""); + if s == "php-only" { + PhpMixed::String("php-only".to_string()) + } else { + PhpMixed::Bool(s != "false" && s != "" && s != "0") + } + }), + ), + ); + m.insert( + "use-parent-dir".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec!["true".to_string(), "false".to_string(), "prompt".to_string()], true))), + Box::new(|val| { + let s = val.as_string().unwrap_or(""); + if s == "prompt" { + PhpMixed::String("prompt".to_string()) + } else { + PhpMixed::Bool(s != "false" && s != "" && s != "0") + } + }), + ), + ); + m.insert( + "audit.abandoned".to_string(), + ( + Box::new(|val| PhpMixed::Bool(in_array(val.as_string().unwrap_or(""), &vec![Auditor::ABANDONED_IGNORE.to_string(), Auditor::ABANDONED_REPORT.to_string(), Auditor::ABANDONED_FAIL.to_string()], true))), + Box::new(|val| val.clone()), + ), + ); + m.insert("audit.ignore-unreachable".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("audit.block-insecure".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m.insert("audit.block-abandoned".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + + let _ = identity; + m +} + +fn build_multi_config_values() -> IndexMap<String, (ValidatorFn, NormalizerFn)> { + let mut m: IndexMap<String, (ValidatorFn, NormalizerFn)> = IndexMap::new(); + m.insert( + "github-protocols".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + if let Some(list) = vals.as_list() { + for val in list { + if !in_array(val.as_string().unwrap_or(""), &vec!["git".to_string(), "https".to_string(), "ssh".to_string()], false) { + return PhpMixed::String("valid protocols include: git, https, ssh".to_string()); + } + } + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m.insert( + "github-domains".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m.insert( + "gitlab-domains".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m.insert( + "audit.ignore-severity".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + if let Some(list) = vals.as_list() { + for val in list { + if !in_array(val.as_string().unwrap_or(""), &vec!["low".to_string(), "medium".to_string(), "high".to_string(), "critical".to_string()], true) { + return PhpMixed::String("valid severities include: low, medium, high, critical".to_string()); + } + } + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m +} + +fn build_unique_props() -> IndexMap<String, (ValidatorFn, NormalizerFn)> { + let mut m: IndexMap<String, (ValidatorFn, NormalizerFn)> = IndexMap::new(); + m.insert("name".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("type".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("description".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("homepage".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert("version".to_string(), (Box::new(|val| PhpMixed::Bool(is_string(val))), Box::new(|val| val.clone()))); + m.insert( + "minimum-stability".to_string(), + ( + Box::new(|val| { + let normalized = VersionParser::normalize_stability(val.as_string().unwrap_or("")); + PhpMixed::Bool(base_package::STABILITIES.contains_key(normalized.as_str())) + }), + Box::new(|val| PhpMixed::String(VersionParser::normalize_stability(val.as_string().unwrap_or("")))), + ), + ); + m.insert("prefer-stable".to_string(), (Box::new(boolean_validator), Box::new(boolean_normalizer))); + m +} + +fn build_multi_props() -> IndexMap<String, (ValidatorFn, NormalizerFn)> { + let mut m: IndexMap<String, (ValidatorFn, NormalizerFn)> = IndexMap::new(); + m.insert( + "keywords".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m.insert( + "license".to_string(), + ( + Box::new(|vals| { + if !is_array(vals) { + return PhpMixed::String("array expected".to_string()); + } + PhpMixed::Bool(true) + }), + Box::new(|vals| vals.clone()), + ), + ); + m +} + +/// build a flat list of dot-separated setting-keys from given config +fn flatten_setting_keys(config: PhpMixed, prefix: &str) -> Vec<String> { + let mut keys: Vec<Vec<String>> = vec![]; + let arr = match config.as_array() { + Some(a) => a.clone(), + None => return vec![], + }; + for (key, value) in &arr { + keys.push(vec![format!("{}{}", prefix, key)]); + // array-lists must not be added to completion + // sub-keys of repository-keys must not be added to completion + if is_array(value) && !array_is_list(value) && prefix != "repositories." { + keys.push(flatten_setting_keys((**value).clone(), &format!("{}{}.", prefix, key))); + } + } + + let mut merged: Vec<String> = vec![]; + for k in keys { + merged = array_merge(merged, k); + } + merged +} + +// Helpers for the suggester since BaseConfigCommand methods need an instance. +fn get_composer_config_file_static(input: &CompletionInput, config: &Config) -> String { + if input.get_option("global").as_bool() == Some(true) { + format!("{}/config.json", config.get("home").as_string().unwrap_or("")) + } else { + input + .get_option("file") + .as_string() + .map(|s| s.to_string()) + .unwrap_or_else(|| Factory::get_composer_file()) + } +} + +fn get_auth_config_file_static(input: &CompletionInput, config: &Config) -> String { + if input.get_option("global").as_bool() == Some(true) { + format!("{}/auth.json", config.get("home").as_string().unwrap_or("")) + } else { + let composer_config = get_composer_config_file_static(input, config); + let parent = std::path::Path::new(&composer_config) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + format!("{}/auth.json", parent) + } +} + +// PHP key($value) — first key of an array +fn key_first_key(value: &PhpMixed) -> Option<String> { + if let Some(arr) = value.as_array() { + return arr.keys().next().cloned(); + } + if let Some(list) = value.as_list() { + if !list.is_empty() { + return Some("0".to_string()); + } + } + None +} |
