aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-16 20:44:28 +0900
committernsfisis <nsfisis@gmail.com>2026-05-16 20:44:28 +0900
commitb2716a280bf1102efdf2aad4c8376c05cacf35da (patch)
treec2f82412f520d6ade0e43509e6b0303cc085738e
parent3ee1a2d274b1a43380ceaacfa0eb4cde8c660e7b (diff)
downloadphp-shirabe-b2716a280bf1102efdf2aad4c8376c05cacf35da.tar.gz
php-shirabe-b2716a280bf1102efdf2aad4c8376c05cacf35da.tar.zst
php-shirabe-b2716a280bf1102efdf2aad4c8376c05cacf35da.zip
feat(port): port Locker.php
-rw-r--r--crates/shirabe/src/package/locker.rs1025
1 files changed, 1025 insertions, 0 deletions
diff --git a/crates/shirabe/src/package/locker.rs b/crates/shirabe/src/package/locker.rs
index 63e4e70..a0401ba 100644
--- a/crates/shirabe/src/package/locker.rs
+++ b/crates/shirabe/src/package/locker.rs
@@ -1 +1,1026 @@
//! ref: composer/src/Composer/Package/Locker.php
+
+use anyhow::Result;
+use indexmap::IndexMap;
+
+use shirabe_external_packages::composer::pcre::preg::Preg;
+use shirabe_external_packages::seld::json_lint::parsing_exception::ParsingException;
+use shirabe_php_shim::{
+ array_intersect, array_keys, array_map, array_merge, call_user_func, file_get_contents,
+ filemtime, function_exists, hash, in_array, is_array, is_int, ksort, realpath, reset_first,
+ sprintf, strcmp, strtolower, touch, trim, usort, LogicException, PhpMixed, RuntimeException,
+ DATE_RFC3339,
+};
+
+use crate::installer::installation_manager::InstallationManager;
+use crate::io::io_interface::IOInterface;
+use crate::json::json_file::JsonFile;
+use crate::package::alias_package::AliasPackage;
+use crate::package::base_package::BasePackage;
+use crate::package::complete_alias_package::CompleteAliasPackage;
+use crate::package::dumper::array_dumper::ArrayDumper;
+use crate::package::link::Link;
+use crate::package::loader::array_loader::ArrayLoader;
+use crate::package::package_interface::PackageInterface;
+use crate::package::root_package_interface::RootPackageInterface;
+use crate::package::version::version_parser::VersionParser;
+use crate::plugin::plugin_interface::PluginInterface;
+use crate::repository::installed_repository::InstalledRepository;
+use crate::repository::lock_array_repository::LockArrayRepository;
+use crate::repository::platform_repository::PlatformRepository;
+use crate::repository::root_package_repository::RootPackageRepository;
+use crate::util::git::Git as GitUtil;
+use crate::util::process_executor::ProcessExecutor;
+
+/// Reads/writes project lockfile (composer.lock).
+#[derive(Debug)]
+pub struct Locker {
+ /// @var JsonFile
+ lock_file: JsonFile,
+ /// @var InstallationManager
+ installation_manager: InstallationManager,
+ /// @var string
+ hash: String,
+ /// @var string
+ content_hash: String,
+ /// @var ArrayLoader
+ loader: ArrayLoader,
+ /// @var ArrayDumper
+ dumper: ArrayDumper,
+ /// @var ProcessExecutor
+ process: ProcessExecutor,
+ /// @var mixed[]|null
+ lock_data_cache: Option<IndexMap<String, PhpMixed>>,
+ /// @var bool
+ virtual_file_written: bool,
+}
+
+impl Locker {
+ /// Initializes packages locker.
+ pub fn new(
+ io: Box<dyn IOInterface>,
+ lock_file: JsonFile,
+ installation_manager: InstallationManager,
+ composer_file_contents: &str,
+ process: Option<ProcessExecutor>,
+ ) -> Self {
+ let process = process.unwrap_or_else(|| ProcessExecutor::new(Some(io), None));
+ Self {
+ lock_file,
+ installation_manager,
+ hash: hash("md5", composer_file_contents),
+ content_hash: Self::get_content_hash(composer_file_contents).unwrap_or_default(),
+ loader: ArrayLoader::new(None, true),
+ dumper: ArrayDumper::new(),
+ process,
+ lock_data_cache: None,
+ virtual_file_written: false,
+ }
+ }
+
+ /// @internal
+ pub fn get_json_file(&self) -> &JsonFile {
+ &self.lock_file
+ }
+
+ /// Returns the md5 hash of the sorted content of the composer file.
+ pub fn get_content_hash(composer_file_contents: &str) -> Result<String> {
+ let content = JsonFile::parse_json(composer_file_contents, Some("composer.json"))?;
+
+ let relevant_keys: Vec<&str> = vec![
+ "name",
+ "version",
+ "require",
+ "require-dev",
+ "conflict",
+ "replace",
+ "provide",
+ "minimum-stability",
+ "prefer-stable",
+ "repositories",
+ "extra",
+ ];
+
+ let mut relevant_content: IndexMap<String, PhpMixed> = IndexMap::new();
+
+ let content_keys: Vec<String> = array_keys(&content);
+ let relevant_keys_strings: Vec<String> = relevant_keys.iter().map(|s| s.to_string()).collect();
+ let intersected = array_intersect(&relevant_keys_strings, &content_keys);
+ for key in intersected {
+ if let Some(value) = content.get(&key) {
+ relevant_content.insert(key, value.clone());
+ }
+ }
+ let platform_value = content
+ .get("config")
+ .and_then(|v| match v {
+ PhpMixed::Array(m) => m.get("platform").cloned(),
+ _ => None,
+ });
+ if let Some(platform) = platform_value {
+ let mut config_map: IndexMap<String, Box<PhpMixed>> = IndexMap::new();
+ config_map.insert("platform".to_string(), platform);
+ relevant_content.insert("config".to_string(), PhpMixed::Array(config_map));
+ }
+
+ ksort(&mut relevant_content);
+
+ Ok(hash(
+ "md5",
+ &JsonFile::encode(
+ &PhpMixed::Array(
+ relevant_content
+ .into_iter()
+ .map(|(k, v)| (k, Box::new(v)))
+ .collect(),
+ ),
+ 0,
+ JsonFile::INDENT_DEFAULT,
+ ),
+ ))
+ }
+
+ /// Checks whether locker has been locked (lockfile found).
+ pub fn is_locked(&mut self) -> bool {
+ if !self.virtual_file_written && !self.lock_file.exists() {
+ return false;
+ }
+
+ let data_result = self.get_lock_data();
+ if let Ok(data) = data_result {
+ return data.contains_key("packages");
+ }
+ false
+ }
+
+ /// Checks whether the lock file is still up to date with the current hash
+ pub fn is_fresh(&self) -> Result<bool> {
+ let lock = self.lock_file.read()?;
+
+ let content_hash = lock.get("content-hash");
+ if content_hash.is_some() && !shirabe_php_shim::empty(content_hash.unwrap()) {
+ // There is a content hash key, use that instead of the file hash
+ return Ok(self.content_hash == content_hash.unwrap().as_string().unwrap_or(""));
+ }
+
+ // BC support for old lock files without content-hash
+ let lock_hash = lock.get("hash");
+ if lock_hash.is_some() && !shirabe_php_shim::empty(lock_hash.unwrap()) {
+ return Ok(self.hash == lock_hash.unwrap().as_string().unwrap_or(""));
+ }
+
+ // should not be reached unless the lock file is corrupted, so assume it's out of date
+ Ok(false)
+ }
+
+ /// Searches and returns an array of locked packages, retrieved from registered repositories.
+ pub fn get_locked_repository(
+ &mut self,
+ with_dev_reqs: bool,
+ ) -> Result<LockArrayRepository> {
+ let lock_data = self.get_lock_data()?;
+ let mut packages = LockArrayRepository::new(vec![])?;
+
+ let mut locked_packages = lock_data
+ .get("packages")
+ .cloned()
+ .unwrap_or(PhpMixed::List(vec![]));
+ if with_dev_reqs {
+ if let Some(packages_dev) = lock_data.get("packages-dev").cloned() {
+ locked_packages = array_merge(locked_packages, packages_dev);
+ } else {
+ return Err(RuntimeException {
+ message: "The lock file does not contain require-dev information, run install with the --no-dev option or delete it and run composer update to generate a new lock file.".to_string(),
+ code: 0,
+ }
+ .into());
+ }
+ }
+
+ if shirabe_php_shim::empty(&locked_packages) {
+ return Ok(packages);
+ }
+
+ // PHP: if (isset($lockedPackages[0]['name']))
+ let has_name = if let PhpMixed::List(list) = &locked_packages {
+ list.first()
+ .map(|v| match v.as_ref() {
+ PhpMixed::Array(m) => m.contains_key("name"),
+ _ => false,
+ })
+ .unwrap_or(false)
+ } else {
+ false
+ };
+ if has_name {
+ let mut package_by_name: IndexMap<String, Box<BasePackage>> = IndexMap::new();
+ if let PhpMixed::List(list) = locked_packages {
+ for info in list {
+ if let PhpMixed::Array(m) = info.as_ref() {
+ let info_map: IndexMap<String, PhpMixed> =
+ m.iter().map(|(k, v)| (k.clone(), (**v).clone())).collect();
+ let package = self.loader.load(info_map, None)?;
+ packages.add_package(package.clone())?;
+ package_by_name
+ .insert(package.get_name().to_string(), package.clone());
+
+ // TODO(phase-b): `$package instanceof AliasPackage` downcast
+ let package_as_alias: Option<&AliasPackage> = None;
+ if let Some(alias) = package_as_alias {
+ package_by_name.insert(
+ alias.get_alias_of().get_name().to_string(),
+ alias.get_alias_of(),
+ );
+ }
+ }
+ }
+ }
+
+ if let Some(aliases) = lock_data.get("aliases") {
+ if let PhpMixed::List(alias_list) = aliases {
+ for alias in alias_list {
+ if let PhpMixed::Array(m) = alias.as_ref() {
+ let alias_pkg_name = m
+ .get("package")
+ .and_then(|v| v.as_string())
+ .unwrap_or("")
+ .to_string();
+ if let Some(base_pkg) = package_by_name.get(&alias_pkg_name).cloned()
+ {
+ let mut alias_pkg = CompleteAliasPackage::new(
+ todo!("phase-b: downcast Box<BasePackage> to CompletePackage"),
+ m.get("alias_normalized")
+ .and_then(|v| v.as_string())
+ .unwrap_or("")
+ .to_string(),
+ m.get("alias")
+ .and_then(|v| v.as_string())
+ .unwrap_or("")
+ .to_string(),
+ );
+ alias_pkg.set_root_package_alias(true);
+ let _ = base_pkg;
+ // TODO(phase-b): packages.add_package(Box::new(alias_pkg))
+ let _ = alias_pkg;
+ }
+ }
+ }
+ }
+ }
+
+ return Ok(packages);
+ }
+
+ Err(RuntimeException {
+ message: "Your composer.lock is invalid. Run \"composer update\" to generate a new one."
+ .to_string(),
+ code: 0,
+ }
+ .into())
+ }
+
+ /// @return string[] Names of dependencies installed through require-dev
+ pub fn get_dev_package_names(&mut self) -> Result<Vec<String>> {
+ let mut names: Vec<String> = vec![];
+ let lock_data = self.get_lock_data()?;
+ if let Some(PhpMixed::List(list)) = lock_data.get("packages-dev") {
+ for package in list {
+ if let PhpMixed::Array(m) = package.as_ref() {
+ names.push(strtolower(
+ m.get("name").and_then(|v| v.as_string()).unwrap_or(""),
+ ));
+ }
+ }
+ }
+
+ Ok(names)
+ }
+
+ /// Returns the platform requirements stored in the lock file
+ pub fn get_platform_requirements(&mut self, with_dev_reqs: bool) -> Result<Vec<Link>> {
+ let lock_data = self.get_lock_data()?;
+ let mut requirements: IndexMap<String, Link> = IndexMap::new();
+
+ let platform_value = lock_data.get("platform");
+ if platform_value.is_some() && !shirabe_php_shim::empty(platform_value.unwrap()) {
+ requirements = self.loader.parse_links(
+ "__root__",
+ "1.0.0",
+ Link::TYPE_REQUIRE,
+ match platform_value.unwrap() {
+ PhpMixed::Array(m) => m.iter().map(|(k, v)| (k.clone(), (**v).clone())).collect(),
+ _ => IndexMap::new(),
+ },
+ )?;
+ }
+
+ let platform_dev_value = lock_data.get("platform-dev");
+ if with_dev_reqs
+ && platform_dev_value.is_some()
+ && !shirabe_php_shim::empty(platform_dev_value.unwrap())
+ {
+ let dev_requirements = self.loader.parse_links(
+ "__root__",
+ "1.0.0",
+ Link::TYPE_REQUIRE,
+ match platform_dev_value.unwrap() {
+ PhpMixed::Array(m) => m.iter().map(|(k, v)| (k.clone(), (**v).clone())).collect(),
+ _ => IndexMap::new(),
+ },
+ )?;
+
+ for (k, v) in dev_requirements {
+ requirements.insert(k, v);
+ }
+ }
+
+ Ok(requirements.into_iter().map(|(_, v)| v).collect())
+ }
+
+ /// @return key-of<BasePackage::STABILITIES>
+ pub fn get_minimum_stability(&mut self) -> Result<String> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data
+ .get("minimum-stability")
+ .and_then(|v| v.as_string())
+ .unwrap_or("stable")
+ .to_string())
+ }
+
+ /// @return array<string, string>
+ pub fn get_stability_flags(&mut self) -> Result<IndexMap<String, String>> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data
+ .get("stability-flags")
+ .and_then(|v| match v {
+ PhpMixed::Array(m) => Some(
+ m.iter()
+ .map(|(k, v)| (k.clone(), v.as_string().unwrap_or("").to_string()))
+ .collect(),
+ ),
+ _ => None,
+ })
+ .unwrap_or_default())
+ }
+
+ pub fn get_prefer_stable(&mut self) -> Result<Option<bool>> {
+ let lock_data = self.get_lock_data()?;
+
+ // return null if not set to allow caller logic to choose the
+ // right behavior since old lock files have no prefer-stable
+ Ok(lock_data.get("prefer-stable").and_then(|v| v.as_bool()))
+ }
+
+ pub fn get_prefer_lowest(&mut self) -> Result<Option<bool>> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data.get("prefer-lowest").and_then(|v| v.as_bool()))
+ }
+
+ /// @return array<string, string>
+ pub fn get_platform_overrides(&mut self) -> Result<IndexMap<String, String>> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data
+ .get("platform-overrides")
+ .and_then(|v| match v {
+ PhpMixed::Array(m) => Some(
+ m.iter()
+ .map(|(k, v)| (k.clone(), v.as_string().unwrap_or("").to_string()))
+ .collect(),
+ ),
+ _ => None,
+ })
+ .unwrap_or_default())
+ }
+
+ /// @return string[][]
+ pub fn get_aliases(&mut self) -> Result<Vec<IndexMap<String, String>>> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data
+ .get("aliases")
+ .and_then(|v| match v {
+ PhpMixed::List(list) => Some(
+ list.iter()
+ .filter_map(|v| match v.as_ref() {
+ PhpMixed::Array(m) => Some(
+ m.iter()
+ .map(|(k, v)| {
+ (k.clone(), v.as_string().unwrap_or("").to_string())
+ })
+ .collect(),
+ ),
+ _ => None,
+ })
+ .collect(),
+ ),
+ _ => None,
+ })
+ .unwrap_or_default())
+ }
+
+ pub fn get_plugin_api(&mut self) -> Result<String> {
+ let lock_data = self.get_lock_data()?;
+
+ Ok(lock_data
+ .get("plugin-api-version")
+ .and_then(|v| v.as_string())
+ .unwrap_or("1.1.0")
+ .to_string())
+ }
+
+ /// @return array<string, mixed>
+ pub fn get_lock_data(&mut self) -> Result<IndexMap<String, PhpMixed>> {
+ if let Some(cache) = self.lock_data_cache.clone() {
+ return Ok(cache);
+ }
+
+ if !self.lock_file.exists() {
+ return Err(LogicException {
+ message: "No lockfile found. Unable to read locked packages".to_string(),
+ code: 0,
+ }
+ .into());
+ }
+
+ let data = self.lock_file.read()?;
+ self.lock_data_cache = Some(data.clone());
+ Ok(data)
+ }
+
+ /// Locks provided data into lockfile.
+ pub fn set_lock_data(
+ &mut self,
+ packages: Vec<Box<dyn PackageInterface>>,
+ dev_packages: Option<Vec<Box<dyn PackageInterface>>>,
+ platform_reqs: IndexMap<String, String>,
+ platform_dev_reqs: IndexMap<String, String>,
+ aliases: Vec<IndexMap<String, PhpMixed>>,
+ minimum_stability: &str,
+ stability_flags: IndexMap<String, i64>,
+ prefer_stable: bool,
+ prefer_lowest: bool,
+ platform_overrides: IndexMap<String, PhpMixed>,
+ write: bool,
+ ) -> Result<bool> {
+ // keep old default branch names normalized to DEFAULT_BRANCH_ALIAS for BC as that is how Composer 1 outputs the lock file
+ // when loading the lock file the version is anyway ignored in Composer 2, so it has no adverse effect
+ let aliases: Vec<IndexMap<String, PhpMixed>> = array_map(
+ |alias: &IndexMap<String, PhpMixed>| {
+ let mut alias = alias.clone();
+ let version = alias
+ .get("version")
+ .and_then(|v| v.as_string())
+ .unwrap_or("")
+ .to_string();
+ if in_array(
+ PhpMixed::String(version),
+ &PhpMixed::List(vec![
+ Box::new(PhpMixed::String("dev-master".to_string())),
+ Box::new(PhpMixed::String("dev-trunk".to_string())),
+ Box::new(PhpMixed::String("dev-default".to_string())),
+ ]),
+ true,
+ ) {
+ alias.insert(
+ "version".to_string(),
+ PhpMixed::String(VersionParser::DEFAULT_BRANCH_ALIAS.to_string()),
+ );
+ }
+ alias
+ },
+ &aliases,
+ );
+
+ let mut lock: IndexMap<String, PhpMixed> = IndexMap::new();
+ lock.insert(
+ "_readme".to_string(),
+ PhpMixed::List(vec![
+ Box::new(PhpMixed::String(
+ "This file locks the dependencies of your project to a known state".to_string(),
+ )),
+ Box::new(PhpMixed::String(
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
+ )),
+ Box::new(PhpMixed::String(
+ format!("This file is @{}ated automatically", "gener"),
+ )),
+ ]),
+ );
+ lock.insert(
+ "content-hash".to_string(),
+ PhpMixed::String(self.content_hash.clone()),
+ );
+ lock.insert(
+ "packages".to_string(),
+ self.lock_packages(&packages)?,
+ );
+ lock.insert("packages-dev".to_string(), PhpMixed::Null);
+ lock.insert(
+ "aliases".to_string(),
+ PhpMixed::List(
+ aliases
+ .iter()
+ .map(|m| {
+ Box::new(PhpMixed::Array(
+ m.iter().map(|(k, v)| (k.clone(), Box::new(v.clone()))).collect(),
+ ))
+ })
+ .collect(),
+ ),
+ );
+ lock.insert(
+ "minimum-stability".to_string(),
+ PhpMixed::String(minimum_stability.to_string()),
+ );
+ lock.insert(
+ "stability-flags".to_string(),
+ PhpMixed::Array(
+ stability_flags
+ .iter()
+ .map(|(k, v)| (k.clone(), Box::new(PhpMixed::Int(*v))))
+ .collect(),
+ ),
+ );
+ lock.insert("prefer-stable".to_string(), PhpMixed::Bool(prefer_stable));
+ lock.insert("prefer-lowest".to_string(), PhpMixed::Bool(prefer_lowest));
+
+ if let Some(dev_packages) = dev_packages {
+ lock.insert(
+ "packages-dev".to_string(),
+ self.lock_packages(&dev_packages)?,
+ );
+ }
+
+ lock.insert(
+ "platform".to_string(),
+ PhpMixed::Array(
+ platform_reqs
+ .iter()
+ .map(|(k, v)| (k.clone(), Box::new(PhpMixed::String(v.clone()))))
+ .collect(),
+ ),
+ );
+ lock.insert(
+ "platform-dev".to_string(),
+ PhpMixed::Array(
+ platform_dev_reqs
+ .iter()
+ .map(|(k, v)| (k.clone(), Box::new(PhpMixed::String(v.clone()))))
+ .collect(),
+ ),
+ );
+ if platform_overrides.len() > 0 {
+ lock.insert(
+ "platform-overrides".to_string(),
+ PhpMixed::Array(
+ platform_overrides
+ .into_iter()
+ .map(|(k, v)| (k, Box::new(v)))
+ .collect(),
+ ),
+ );
+ }
+ lock.insert(
+ "plugin-api-version".to_string(),
+ PhpMixed::String(PluginInterface::PLUGIN_API_VERSION.to_string()),
+ );
+
+ let lock = self.fixup_json_data_type(lock);
+
+ let is_locked = match self.is_locked_result() {
+ Ok(b) => b,
+ Err(e) => {
+ // TODO(phase-b): catch only ParsingException
+ if e.downcast_ref::<ParsingException>().is_some() {
+ false
+ } else {
+ return Err(e);
+ }
+ }
+ };
+ let current_data = if is_locked {
+ self.get_lock_data().ok()
+ } else {
+ None
+ };
+ if !is_locked || Some(&lock) != current_data.as_ref() {
+ if write {
+ self.lock_file.write(
+ PhpMixed::Array(
+ lock.into_iter()
+ .map(|(k, v)| (k, Box::new(v)))
+ .collect(),
+ ),
+ None,
+ )?;
+ self.lock_data_cache = None;
+ self.virtual_file_written = false;
+ } else {
+ self.virtual_file_written = true;
+ self.lock_data_cache = Some(JsonFile::parse_json(
+ &JsonFile::encode(
+ &PhpMixed::Array(
+ lock.into_iter()
+ .map(|(k, v)| (k, Box::new(v)))
+ .collect(),
+ ),
+ shirabe_php_shim::JSON_UNESCAPED_SLASHES
+ | shirabe_php_shim::JSON_PRETTY_PRINT
+ | shirabe_php_shim::JSON_UNESCAPED_UNICODE,
+ JsonFile::INDENT_DEFAULT,
+ ),
+ None,
+ )?);
+ }
+
+ return Ok(true);
+ }
+
+ Ok(false)
+ }
+
+ fn is_locked_result(&mut self) -> Result<bool> {
+ Ok(self.is_locked())
+ }
+
+ /// Updates the lock file's hash in-place from a given composer.json's JsonFile
+ pub fn update_hash<F>(
+ &mut self,
+ composer_json: JsonFile,
+ data_processor: Option<F>,
+ ) -> Result<()>
+ where
+ F: FnOnce(IndexMap<String, PhpMixed>) -> IndexMap<String, PhpMixed>,
+ {
+ let contents = file_get_contents(&composer_json.get_path(), false, None);
+ let contents = match contents {
+ Some(s) => s,
+ None => {
+ return Err(RuntimeException {
+ message: format!(
+ "Unable to read {} contents to update the lock file hash.",
+ composer_json.get_path()
+ ),
+ code: 0,
+ }
+ .into());
+ }
+ };
+
+ let lock_mtime = filemtime(&self.lock_file.get_path());
+ let mut lock_data = self.lock_file.read()?;
+ lock_data.insert(
+ "content-hash".to_string(),
+ PhpMixed::String(Self::get_content_hash(&contents)?),
+ );
+ if let Some(processor) = data_processor {
+ lock_data = processor(lock_data);
+ }
+
+ self.lock_file.write(
+ PhpMixed::Array(
+ self.fixup_json_data_type(lock_data)
+ .into_iter()
+ .map(|(k, v)| (k, Box::new(v)))
+ .collect(),
+ ),
+ None,
+ )?;
+ self.lock_data_cache = None;
+ self.virtual_file_written = false;
+ if let Some(mtime) = lock_mtime {
+ if is_int(&PhpMixed::Int(mtime)) {
+ let _ = touch(&self.lock_file.get_path(), Some(mtime));
+ }
+ }
+ Ok(())
+ }
+
+ /// Ensures correct data types and ordering for the JSON lock format
+ fn fixup_json_data_type(
+ &self,
+ mut lock_data: IndexMap<String, PhpMixed>,
+ ) -> IndexMap<String, PhpMixed> {
+ for key in ["stability-flags", "platform", "platform-dev"].iter() {
+ let should_replace = lock_data
+ .get(*key)
+ .map(|v| match v {
+ PhpMixed::Array(m) => m.is_empty(),
+ _ => false,
+ })
+ .unwrap_or(false);
+ if should_replace {
+ // PHP: $lockData[$key] = new \stdClass();
+ // TODO(phase-b): represent empty stdClass distinctly from empty array
+ lock_data.insert(key.to_string(), PhpMixed::Array(IndexMap::new()));
+ }
+ }
+
+ if let Some(PhpMixed::Array(m)) = lock_data.get_mut("stability-flags") {
+ let mut as_map: IndexMap<String, PhpMixed> =
+ m.iter().map(|(k, v)| (k.clone(), (**v).clone())).collect();
+ ksort(&mut as_map);
+ *m = as_map.into_iter().map(|(k, v)| (k, Box::new(v))).collect();
+ }
+
+ lock_data
+ }
+
+ /// @param PackageInterface[] $packages
+ fn lock_packages(&mut self, packages: &[Box<dyn PackageInterface>]) -> Result<PhpMixed> {
+ let mut locked: Vec<IndexMap<String, PhpMixed>> = vec![];
+
+ for package in packages {
+ // TODO(phase-b): `$package instanceof AliasPackage` downcast
+ let package_as_alias: Option<&AliasPackage> = None;
+ if package_as_alias.is_some() {
+ continue;
+ }
+
+ let name = package.get_pretty_name();
+ let version = package.get_pretty_version();
+
+ if name.is_empty() || version.is_empty() {
+ return Err(LogicException {
+ message: sprintf(
+ "Package \"%s\" has no version or name and can not be locked",
+ &[PhpMixed::String(package.to_string())],
+ ),
+ code: 0,
+ }
+ .into());
+ }
+
+ let mut spec = self.dumper.dump(&**package);
+ spec.shift_remove("version_normalized");
+
+ // always move time to the end of the package definition
+ let time = spec.get("time").cloned();
+ spec.shift_remove("time");
+ let time = if package.is_dev() && package.get_installation_source() == Some("source") {
+ // use the exact commit time of the current reference if it's a dev package
+ let pkg_time = self.get_package_time(&**package)?;
+ pkg_time.map(PhpMixed::String).or(time)
+ } else {
+ time
+ };
+ if let Some(t) = time {
+ spec.insert("time".to_string(), t);
+ }
+
+ spec.shift_remove("installation-source");
+
+ locked.push(spec);
+ }
+
+ usort(&mut locked, |a, b| {
+ let comparison = strcmp(
+ a.get("name").and_then(|v| v.as_string()).unwrap_or(""),
+ b.get("name").and_then(|v| v.as_string()).unwrap_or(""),
+ );
+
+ if 0 != comparison {
+ return comparison;
+ }
+
+ // If it is the same package, compare the versions to make the order deterministic
+ strcmp(
+ a.get("version").and_then(|v| v.as_string()).unwrap_or(""),
+ b.get("version").and_then(|v| v.as_string()).unwrap_or(""),
+ )
+ });
+
+ Ok(PhpMixed::List(
+ locked
+ .into_iter()
+ .map(|m| {
+ Box::new(PhpMixed::Array(
+ m.into_iter().map(|(k, v)| (k, Box::new(v))).collect(),
+ ))
+ })
+ .collect(),
+ ))
+ }
+
+ /// Returns the packages's datetime for its source reference.
+ fn get_package_time(&mut self, package: &dyn PackageInterface) -> Result<Option<String>> {
+ if !function_exists("proc_open") {
+ return Ok(None);
+ }
+
+ let path = self.installation_manager.get_install_path(package);
+ if path.is_none() {
+ return Ok(None);
+ }
+ let path = realpath(&path.unwrap());
+ let source_type = package.get_source_type();
+ let mut datetime: Option<chrono::DateTime<chrono::Utc>> = None;
+
+ if path.is_some()
+ && in_array(
+ PhpMixed::String(source_type.unwrap_or("").to_string()),
+ &PhpMixed::List(vec![
+ Box::new(PhpMixed::String("git".to_string())),
+ Box::new(PhpMixed::String("hg".to_string())),
+ ]),
+ false,
+ )
+ {
+ let source_ref = package
+ .get_source_reference()
+ .or_else(|| package.get_dist_reference())
+ .unwrap_or("")
+ .to_string();
+ match source_type.unwrap_or("") {
+ "git" => {
+ GitUtil::clean_env(&self.process);
+
+ let no_show_signature_flags =
+ GitUtil::get_no_show_signature_flags(&self.process);
+ let mut args: Vec<String> = vec![
+ "-n1".to_string(),
+ "--format=%ct".to_string(),
+ source_ref.clone(),
+ ];
+ args.extend(no_show_signature_flags);
+ let command = GitUtil::build_rev_list_command(&self.process, args);
+ let mut output = PhpMixed::Null;
+ if 0 == self.process.execute(
+ PhpMixed::String(command),
+ Some(&mut output),
+ path.as_deref(),
+ )? {
+ let output_str = trim(
+ &GitUtil::parse_rev_list_output(
+ output.as_string().unwrap_or(""),
+ &self.process,
+ ),
+ None,
+ );
+ if Preg::is_match(r"{^\s*\d+\s*$}", &output_str) {
+ // TODO(phase-b): new \DateTime('@'.trim($output), new \DateTimeZone('UTC'))
+ let ts = trim(&output_str, None).parse::<i64>().unwrap_or(0);
+ datetime = chrono::DateTime::from_timestamp(ts, 0);
+ }
+ }
+ }
+ "hg" => {
+ let mut output = PhpMixed::Null;
+ if 0 == self.process.execute(
+ PhpMixed::List(vec![
+ Box::new(PhpMixed::String("hg".to_string())),
+ Box::new(PhpMixed::String("log".to_string())),
+ Box::new(PhpMixed::String("--template".to_string())),
+ Box::new(PhpMixed::String("{date|hgdate}".to_string())),
+ Box::new(PhpMixed::String("-r".to_string())),
+ Box::new(PhpMixed::String(source_ref.clone())),
+ ]),
+ Some(&mut output),
+ path.as_deref(),
+ )? {
+ if let Some(m) = Preg::is_match_strict_groups(
+ r"{^\s*(\d+)\s*}",
+ output.as_string().unwrap_or(""),
+ ) {
+ let ts = m.get(1).cloned().unwrap_or_default().parse::<i64>().unwrap_or(0);
+ datetime = chrono::DateTime::from_timestamp(ts, 0);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(datetime.map(|d| d.format(DATE_RFC3339).to_string()))
+ }
+
+ /// @return array<string>
+ pub fn get_missing_requirement_info(
+ &mut self,
+ package: &dyn RootPackageInterface,
+ include_dev: bool,
+ ) -> Result<Vec<String>> {
+ let mut missing_requirement_info: Vec<String> = vec![];
+ let mut missing_requirements = false;
+ let mut sets: Vec<SetEntry> = vec![SetEntry {
+ repo: Box::new(self.get_locked_repository(false)?),
+ method: "getRequires".to_string(),
+ description: "Required".to_string(),
+ }];
+ if include_dev == true {
+ sets.push(SetEntry {
+ repo: Box::new(self.get_locked_repository(true)?),
+ method: "getDevRequires".to_string(),
+ description: "Required (in require-dev)".to_string(),
+ });
+ }
+ // TODO(phase-b): clone $package to a RootPackageRepository
+ let root_repo = RootPackageRepository::new(todo!("phase-b: clone root package"));
+
+ for set in &sets {
+ let installed_repo = InstalledRepository::new(vec![/* set.repo, root_repo */])?;
+
+ // PHP: call_user_func([$package, $set['method']])
+ // TODO(phase-b): dynamic method dispatch by name
+ let links: Vec<Link> = vec![];
+ for link in links {
+ if PlatformRepository::is_platform_package(&link.get_target()) {
+ continue;
+ }
+ if link.get_pretty_constraint().as_deref() == Some("self.version") {
+ continue;
+ }
+ if installed_repo
+ .find_packages_with_replacers_and_providers(
+ &link.get_target(),
+ Some(link.get_constraint()),
+ )
+ .is_empty()
+ {
+ let results = installed_repo.find_packages_with_replacers_and_providers(
+ &link.get_target(),
+ None,
+ );
+
+ if !results.is_empty() {
+ let provider = reset_first(&results).unwrap();
+ let mut description = provider.get_pretty_version().to_string();
+ if provider.get_name() != link.get_target() {
+ 'outer: for (method, text) in [
+ ("getReplaces", "replaced as %s by %s"),
+ ("getProvides", "provided as %s by %s"),
+ ]
+ .iter()
+ {
+ // TODO(phase-b): dynamic method dispatch
+ let provider_links: Vec<Link> = vec![];
+ let _ = method;
+ for provider_link in provider_links {
+ if provider_link.get_target() == link.get_target() {
+ description = sprintf(
+ text,
+ &[
+ PhpMixed::String(
+ provider_link
+ .get_pretty_constraint()
+ .unwrap_or_default(),
+ ),
+ PhpMixed::String(format!(
+ "{} {}",
+ provider.get_pretty_name(),
+ provider.get_pretty_version()
+ )),
+ ],
+ );
+ break 'outer;
+ }
+ }
+ }
+ }
+ missing_requirement_info.push(format!(
+ "- {} package \"{}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{}\".",
+ set.description,
+ link.get_target(),
+ description,
+ link.get_pretty_constraint().unwrap_or_default()
+ ));
+ } else {
+ missing_requirement_info.push(format!(
+ "- {} package \"{}\" is not present in the lock file.",
+ set.description,
+ link.get_target()
+ ));
+ }
+ missing_requirements = true;
+ }
+ }
+ let _ = root_repo;
+ let _ = installed_repo;
+ }
+
+ if missing_requirements {
+ missing_requirement_info.push("This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string());
+ missing_requirement_info.push("Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string());
+ missing_requirement_info.push("and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string());
+ }
+
+ Ok(missing_requirement_info)
+ }
+}
+
+struct SetEntry {
+ repo: Box<LockArrayRepository>,
+ method: String,
+ description: String,
+}
+
+// Suppress unused-import warnings for items kept for parity with the PHP source.
+#[allow(dead_code)]
+const _USE_PARITY: () = {
+ let _ = is_array;
+ let _ = call_user_func;
+};