Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

gleam / compiler-cli / src / dependencies.rs
59 kB 1757 lines
1// SPDX-License-Identifier: Apache-2.0 2// SPDX-FileCopyrightText: 2021 The Gleam contributors 3 4mod dependency_manager; 5 6use std::{ 7 cell::RefCell, 8 collections::{HashMap, HashSet}, 9 io::ErrorKind, 10 process::Command, 11 rc::Rc, 12 time::Instant, 13}; 14 15use camino::{Utf8Path, Utf8PathBuf}; 16use ecow::{EcoString, eco_format}; 17use flate2::read::GzDecoder; 18use gleam_core::{ 19 Error, Result, 20 build::{Mode, SourceFingerprint, Target, Telemetry}, 21 config::PackageConfig, 22 dependency::{self, PackageFetchError}, 23 error::{FileIoAction, FileKind, ShellCommandFailureReason, StandardIoAction}, 24 hex::{self, HEXPM_PUBLIC_KEY}, 25 io::{HttpClient as _, TarUnpacker, WrappedReader}, 26 manifest::{Base16Checksum, Manifest, ManifestPackage, ManifestPackageSource, PackageChanges}, 27 paths::ProjectPaths, 28 requirement::Requirement, 29}; 30use hexpm::version::Version; 31use itertools::Itertools; 32use same_file::is_same_file; 33use strum::IntoEnumIterator; 34 35pub use dependency_manager::DependencyManagerConfig; 36 37#[cfg(test)] 38mod tests; 39 40use crate::{ 41 TreeOptions, 42 build_lock::{BuildLock, Guard}, 43 cli, 44 fs::{self, ProjectIO}, 45 http::HttpClient, 46 text_layout::space_table, 47}; 48 49struct Symbols { 50 down: &'static str, 51 tee: &'static str, 52 ell: &'static str, 53 right: &'static str, 54} 55 56static UTF8_SYMBOLS: Symbols = Symbols { 57 down: "", 58 tee: "", 59 ell: "", 60 right: "", 61}; 62 63/// When set to `Yes`, the cli will check for major version updates of direct dependencies and 64/// print them to the console if the major versions are not upgradeable due to constraints. 65#[derive(Debug, Clone, Copy)] 66pub enum CheckMajorVersions { 67 Yes, 68 No, 69} 70 71pub fn list(paths: &ProjectPaths) -> Result<()> { 72 let (_, manifest) = get_manifest_details(paths)?; 73 list_manifest_packages(std::io::stdout(), manifest) 74} 75 76pub fn tree(paths: &ProjectPaths, options: TreeOptions) -> Result<()> { 77 let (config, manifest) = get_manifest_details(paths)?; 78 79 // Initialize the root package since it is not part of the manifest 80 let root_package = ManifestPackage { 81 build_tools: vec![], 82 name: config.name.clone(), 83 requirements: config.all_direct_dependencies()?.keys().cloned().collect(), 84 version: config.version.clone(), 85 source: ManifestPackageSource::Local { 86 path: paths.root().to_path_buf(), 87 }, 88 otp_app: None, 89 }; 90 91 // Get the manifest packages and add the root package to the vec 92 let mut packages = manifest.packages.iter().cloned().collect_vec(); 93 packages.push(root_package); 94 95 list_package_and_dependencies_tree(std::io::stdout(), options, packages.clone(), config.name) 96} 97 98fn get_manifest_details(paths: &ProjectPaths) -> Result<(PackageConfig, Manifest)> { 99 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime"); 100 let config = crate::config::root_config(paths)?; 101 let package_fetcher = PackageFetcher::new(runtime.handle().clone()); 102 let dependency_manager = DependencyManagerConfig { 103 use_manifest: UseManifest::Yes, 104 check_major_versions: CheckMajorVersions::No, 105 } 106 .into_dependency_manager( 107 runtime.handle().clone(), 108 package_fetcher, 109 cli::Reporter::new(), 110 Mode::Dev, 111 ); 112 let manifest = dependency_manager 113 .resolve_versions(paths, &config, Vec::new())? 114 .manifest; 115 Ok((config, manifest)) 116} 117 118fn list_manifest_packages<W: std::io::Write>(mut buffer: W, manifest: Manifest) -> Result<()> { 119 let packages = manifest 120 .packages 121 .into_iter() 122 .map(|package| vec![package.name.to_string(), package.version.to_string()]) 123 .collect_vec(); 124 let out = space_table(&["Package", "Version"], packages); 125 126 write!(buffer, "{out}").map_err(|e| Error::StandardIo { 127 action: StandardIoAction::Write, 128 err: Some(e.kind()), 129 }) 130} 131 132fn list_package_and_dependencies_tree<W: std::io::Write>( 133 mut buffer: W, 134 options: TreeOptions, 135 packages: Vec<ManifestPackage>, 136 root_package_name: EcoString, 137) -> Result<()> { 138 let mut invert = false; 139 140 let package: Option<&ManifestPackage> = if let Some(input_package_name) = options.package { 141 packages.iter().find(|p| p.name == input_package_name) 142 } else if let Some(input_package_name) = options.invert { 143 invert = true; 144 packages.iter().find(|p| p.name == input_package_name) 145 } else { 146 packages.iter().find(|p| p.name == root_package_name) 147 }; 148 149 if let Some(package) = package { 150 let tree = Vec::from([eco_format!("{0} v{1}", package.name, package.version)]); 151 let tree = 152 list_dependencies_tree(tree, package.clone(), packages, EcoString::new(), invert); 153 154 tree.iter() 155 .try_for_each(|line| writeln!(buffer, "{line}")) 156 .map_err(|e| Error::StandardIo { 157 action: StandardIoAction::Write, 158 err: Some(e.kind()), 159 }) 160 } else { 161 writeln!(buffer, "Package not found. Please check the package name.").map_err(|e| { 162 Error::StandardIo { 163 action: StandardIoAction::Write, 164 err: Some(e.kind()), 165 } 166 }) 167 } 168} 169 170fn list_dependencies_tree( 171 mut tree: Vec<EcoString>, 172 package: ManifestPackage, 173 packages: Vec<ManifestPackage>, 174 accum: EcoString, 175 invert: bool, 176) -> Vec<EcoString> { 177 let dependencies = packages 178 .iter() 179 .filter(|p| { 180 (invert && p.requirements.contains(&package.name)) 181 || (!invert && package.requirements.contains(&p.name)) 182 }) 183 .cloned() 184 .collect_vec(); 185 186 let dependencies = dependencies.iter().sorted().enumerate(); 187 188 let deps_length = dependencies.len(); 189 for (index, dependency) in dependencies { 190 let is_last = index == deps_length - 1; 191 let prefix = if is_last { 192 UTF8_SYMBOLS.ell 193 } else { 194 UTF8_SYMBOLS.tee 195 }; 196 197 tree.push(eco_format!( 198 "{0}{1}{2}{2} {3} v{4}", 199 accum.clone(), 200 prefix, 201 UTF8_SYMBOLS.right, 202 dependency.name, 203 dependency.version 204 )); 205 206 let accum = accum.clone() + (if !is_last { UTF8_SYMBOLS.down } else { " " }) + " "; 207 208 tree = list_dependencies_tree( 209 tree.clone(), 210 dependency.clone(), 211 packages.clone(), 212 accum.clone(), 213 invert, 214 ); 215 } 216 217 tree 218} 219 220pub fn outdated(paths: &ProjectPaths) -> Result<()> { 221 let (_, manifest) = get_manifest_details(paths)?; 222 223 let total_packages = manifest 224 .packages 225 .iter() 226 .filter(|package| matches!(package.source, ManifestPackageSource::Hex { .. })) 227 .count(); 228 229 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime"); 230 let package_fetcher = PackageFetcher::new(runtime.handle().clone()); 231 232 let version_updates = dependency::check_for_version_updates(&manifest, &package_fetcher); 233 234 print!( 235 "{}", 236 pretty_print_outdated_versions(total_packages, version_updates) 237 ); 238 239 Ok(()) 240} 241 242#[derive(Debug, Clone, Copy)] 243pub enum UseManifest { 244 Yes, 245 No, 246} 247 248pub fn update(paths: &ProjectPaths, packages: Vec<String>) -> Result<()> { 249 let use_manifest = if packages.is_empty() { 250 UseManifest::No 251 } else { 252 UseManifest::Yes 253 }; 254 255 // Update specific packages 256 _ = resolve_and_download( 257 paths, 258 cli::Reporter::new(), 259 None, 260 packages.into_iter().map(EcoString::from).collect(), 261 DependencyManagerConfig { 262 use_manifest, 263 check_major_versions: CheckMajorVersions::Yes, 264 }, 265 )?; 266 267 Ok(()) 268} 269 270/// Edit the manifest.toml file in this proejct, removing all extra requirements and packages 271/// that are no longer present in the gleam.toml config. 272pub fn cleanup<Telem: Telemetry>(paths: &ProjectPaths, telemetry: Telem) -> Result<Manifest> { 273 let span = tracing::info_span!("remove_deps"); 274 let _enter = span.enter(); 275 276 // We do this before acquiring the build lock so that we don't create the 277 // build directory if there is no gleam.toml 278 crate::config::ensure_config_exists(paths)?; 279 280 let lock = BuildLock::new_packages(paths)?; 281 let _guard: Guard = lock.lock(&telemetry)?; 282 283 // Read the project config 284 let config = crate::root_config(paths)?; 285 let old_manifest = read_manifest_from_disc(paths)?; 286 let mut manifest = old_manifest.clone(); 287 288 remove_extra_requirements(&config, &mut manifest)?; 289 290 // Remove any packages that are no longer required due to manifest changes 291 let local = LocalPackages::read_from_disc(paths)?; 292 remove_extra_packages(paths, &local, &manifest, &telemetry)?; 293 294 // Record new state of the packages directory 295 tracing::debug!("writing_manifest_toml"); 296 write_manifest_to_disc(paths, &manifest)?; 297 LocalPackages::from_manifest(&manifest).write_to_disc(paths)?; 298 299 let changes = PackageChanges::between_manifests(&old_manifest, &manifest); 300 telemetry.resolved_package_versions(&changes); 301 302 // Cleanup build cache of the root package if there are some changes. 303 // Without this, if a removed dependency is still used, teh build will 304 // succeed, resulting in runtime crash due to missing files. 305 if changes.any_changes() { 306 tracing::debug!("cleaning_root_package_build_cache"); 307 for mode in Mode::iter() { 308 for target in Target::iter() { 309 let lock = BuildLock::new_target(paths, mode, target)?; 310 let _guard = lock.lock(&telemetry)?; 311 312 let build_directory_path = 313 paths.build_directory_for_package(mode, target, &config.name); 314 if build_directory_path.exists() { 315 fs::delete_directory(build_directory_path.as_ref())?; 316 } 317 } 318 } 319 } 320 321 Ok(manifest) 322} 323 324/// Remove requirements and unneeded packages from manifest that are no longer present in config. 325fn remove_extra_requirements(config: &PackageConfig, manifest: &mut Manifest) -> Result<()> { 326 // "extra requirements" are all packages that are requirements in the manifest, but no longer 327 // part of the gleam.toml config. 328 let is_extra_requirement = |name: &EcoString| { 329 !config.dev_dependencies.contains_key(name) && !config.dependencies.contains_key(name) 330 }; 331 332 // If a requirement is also used as a dependency, we do not want to force-unlock it. 333 // If the dependents get deleted as well, this transitive dependency will be dropped. 334 let is_unlockable_requirement = |name: &EcoString| { 335 manifest 336 .packages 337 .iter() 338 .all(|p| !p.requirements.contains(name)) 339 }; 340 341 let extra_requirements = manifest 342 .requirements 343 .keys() 344 .filter(|&name| is_extra_requirement(name) && is_unlockable_requirement(name)) 345 .cloned() 346 .collect::<Vec<_>>(); 347 348 manifest 349 .requirements 350 .retain(|name, _| !is_extra_requirement(name)); 351 352 // Unlock all packages that we we want to remove - this removes them and all unneeded 353 // dependencies from `locked`. 354 let mut locked = config.locked(Some(manifest))?; 355 unlock_packages(&mut locked, extra_requirements.as_slice(), Some(manifest))?; 356 // Remove all unlocked packages from the manifest - these are truly no longer needed. 357 manifest 358 .packages 359 .retain(|package| locked.contains_key(&package.name)); 360 361 Ok(()) 362} 363 364pub fn parse_gleam_add_specifier(package: &str) -> Result<(EcoString, Requirement)> { 365 let Some((package, version)) = package.split_once('@') else { 366 // Default to the latest version available. 367 return Ok(( 368 package.into(), 369 Requirement::hex(">= 0.0.0").expect("'>= 0.0.0' should be a valid pubgrub range"), 370 )); 371 }; 372 373 // Parse the major and minor from the provided semantic version. 374 let parts = version.split('.').collect::<Vec<_>>(); 375 let major = match parts.first() { 376 Some(major) => Ok(major), 377 None => Err(Error::InvalidVersionFormat { 378 input: package.to_string(), 379 error: "Failed to parse semantic major version".to_string(), 380 }), 381 }?; 382 let minor = match parts.get(1) { 383 Some(minor) => minor, 384 None => "0", 385 }; 386 387 // Using the major version specifier, calculate the maximum 388 // allowable version (i.e., the next major version). 389 let Ok(num) = major.parse::<usize>() else { 390 return Err(Error::InvalidVersionFormat { 391 input: version.to_string(), 392 error: "Failed to parse semantic major version as integer".to_string(), 393 }); 394 }; 395 396 let max_ver = [&(num + 1).to_string(), "0", "0"].join("."); 397 398 // Pad the provided version specifier with zeros map to a Hex version. 399 let requirement = match parts.len() { 400 1 | 2 => { 401 let min_ver = [major, minor, "0"].join("."); 402 Requirement::hex(&[">=", &min_ver, "and", "<", &max_ver].join(" ")) 403 } 404 3 => Requirement::hex(version), 405 n => { 406 return Err(Error::InvalidVersionFormat { 407 input: version.to_string(), 408 error: format!( 409 "Expected up to 3 numbers in version specifier (MAJOR.MINOR.PATCH), found {n}" 410 ), 411 }); 412 } 413 }?; 414 415 Ok((package.into(), requirement)) 416} 417 418pub fn resolve_and_download<Telem: Telemetry>( 419 paths: &ProjectPaths, 420 telemetry: Telem, 421 new_package: Option<(Vec<(EcoString, Requirement)>, bool)>, 422 packages_to_update: Vec<EcoString>, 423 config: DependencyManagerConfig, 424) -> Result<Manifest> { 425 // Start event loop so we can run async functions to call the Hex API 426 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime"); 427 let package_fetcher = PackageFetcher::new(runtime.handle().clone()); 428 429 let dependency_manager = config.into_dependency_manager( 430 runtime.handle().clone(), 431 package_fetcher, 432 telemetry, 433 Mode::Dev, 434 ); 435 436 dependency_manager.resolve_and_download_versions(paths, new_package, packages_to_update) 437} 438 439fn format_versions_and_extract_longest_parts( 440 versions: dependency::PackageVersionDiffs, 441) -> Vec<Vec<String>> { 442 versions 443 .iter() 444 .map(|(name, (v1, v2))| vec![name.to_string(), v1.to_string(), v2.to_string()]) 445 .sorted() 446 .collect_vec() 447} 448 449fn pretty_print_major_versions_available(versions: dependency::PackageVersionDiffs) -> String { 450 let versions = format_versions_and_extract_longest_parts(versions); 451 452 format!( 453 "\nThe following dependencies have new major versions available:\n\n{}", 454 space_table(&["Package", "Current", "Latest"], &versions) 455 ) 456} 457 458fn pretty_print_version_updates(versions: dependency::PackageVersionDiffs) -> EcoString { 459 let versions = format_versions_and_extract_longest_parts(versions); 460 space_table(&["Package", "Current", "Latest"], &versions) 461} 462 463fn pretty_print_outdated_versions( 464 total_packages: usize, 465 versions: dependency::PackageVersionDiffs, 466) -> EcoString { 467 let summary = eco_format!( 468 "{} of {} packages have newer versions available.", 469 versions.len(), 470 total_packages 471 ); 472 473 if versions.is_empty() { 474 eco_format!("{}\n", summary) 475 } else { 476 eco_format!("{}\n\n{}", summary, pretty_print_version_updates(versions)) 477 } 478} 479 480async fn add_missing_packages<Telem: Telemetry>( 481 paths: &ProjectPaths, 482 fs: Box<ProjectIO>, 483 manifest: &Manifest, 484 local: &LocalPackages, 485 project_name: EcoString, 486 telemetry: &Telem, 487) -> Result<(), Error> { 488 let missing_packages = local.missing_local_packages(manifest, &project_name); 489 490 let mut num_to_download = 0; 491 492 let missing_git_packages = missing_packages 493 .iter() 494 .copied() 495 .filter(|package| package.is_git()) 496 .inspect(|_| { 497 num_to_download += 1; 498 }) 499 .collect_vec(); 500 501 let mut missing_hex_packages = missing_packages 502 .iter() 503 .copied() 504 .filter(|package| package.is_hex()) 505 .inspect(|_| { 506 num_to_download += 1; 507 }) 508 .peekable(); 509 510 // If we need to download at-least one package 511 if missing_hex_packages.peek().is_some() || !missing_git_packages.is_empty() { 512 let http = HttpClient::boxed(); 513 let downloader = hex::Downloader::new(fs.clone(), fs, http, Untar::boxed(), paths.clone()); 514 let start = Instant::now(); 515 telemetry.downloading_package("packages"); 516 downloader 517 .download_hex_packages(missing_hex_packages, &project_name) 518 .await?; 519 for package in missing_git_packages { 520 let ManifestPackageSource::Git { repo, commit, path } = &package.source else { 521 continue; 522 }; 523 524 let checkout = 525 download_git_package(&package.name, repo, commit, path.as_deref(), paths)?; 526 checkout.cleanup()?; 527 } 528 telemetry.packages_downloaded(start, num_to_download); 529 } 530 531 Ok(()) 532} 533 534fn remove_extra_packages<Telem: Telemetry>( 535 paths: &ProjectPaths, 536 local: &LocalPackages, 537 manifest: &Manifest, 538 telemetry: &Telem, 539) -> Result<()> { 540 let _guard = BuildLock::lock_all_build(paths, telemetry)?; 541 542 for (package_name, version) in local.extra_local_packages(manifest) { 543 // TODO: test 544 // Delete the package source 545 let path = paths.build_packages_package(&package_name); 546 if path.exists() { 547 tracing::debug!(package=%package_name, version=%version, "removing_unneeded_package"); 548 fs::delete_directory(&path)?; 549 } 550 551 // TODO: test 552 // Delete any build artefacts for the package 553 for mode in Mode::iter() { 554 for target in Target::iter() { 555 let name = manifest 556 .packages 557 .iter() 558 .find(|p| p.name == package_name) 559 .map(|p| p.application_name().as_str()) 560 .unwrap_or(package_name.as_str()); 561 let path = paths.build_directory_for_package(mode, target, name); 562 if path.exists() { 563 tracing::debug!(package=%package_name, version=%version, "deleting_build_cache"); 564 fs::delete_directory(&path)?; 565 } 566 } 567 } 568 } 569 570 remove_unused_git_clones(paths, manifest)?; 571 Ok(()) 572} 573 574fn remove_unused_git_clones(paths: &ProjectPaths, manifest: &Manifest) -> Result<()> { 575 let git_directory = paths.build_git_directory(); 576 if !git_directory.is_dir() { 577 return Ok(()); 578 } 579 580 let expected: HashSet<String> = manifest 581 .packages 582 .iter() 583 .filter_map(|package| match &package.source { 584 ManifestPackageSource::Git { 585 repo, 586 path: Some(_), 587 .. 588 } => Some(git_repo_dir_name(repo)), 589 _ => None, 590 }) 591 .collect(); 592 593 for entry in fs::read_dir(&git_directory)?.filter_map(Result::ok) { 594 if !expected.contains(entry.file_name()) { 595 tracing::debug!(path=%entry.path(), "removing_unused_git_clone"); 596 fs::delete_directory(entry.path())?; 597 } 598 } 599 Ok(()) 600} 601 602fn read_manifest_from_disc(paths: &ProjectPaths) -> Result<Manifest> { 603 tracing::debug!("reading_manifest_toml"); 604 let manifest_path = paths.manifest(); 605 let toml = fs::read(&manifest_path)?; 606 let manifest = toml::from_str(&toml).map_err(|e| Error::FileIo { 607 action: FileIoAction::Parse, 608 kind: FileKind::File, 609 path: manifest_path.clone(), 610 err: Some(e.to_string()), 611 })?; 612 Ok(manifest) 613} 614 615fn write_manifest_to_disc(paths: &ProjectPaths, manifest: &Manifest) -> Result<()> { 616 let path = paths.manifest(); 617 fs::write(&path, &manifest.to_toml(paths.root())) 618} 619 620// This is the container for locally pinned packages, representing the current contents of 621// the `project/build/packages` directory. 622// For descriptions of packages provided by paths and git deps, see the ProvidedPackage struct. 623// The same package may appear in both at different times. 624#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] 625struct LocalPackages { 626 #[serde(deserialize_with = "gleam_core::config::map_with_package_name_keys::deserialize")] 627 packages: HashMap<EcoString, Version>, 628 // Git packages can resolve to a new commit (or a new sub-path) without 629 // their version changing, so their on-disc freshness is keyed by commit 630 // and path rather than version alone. Absent for hex and local packages. 631 #[serde(default)] 632 git: HashMap<EcoString, GitState>, 633} 634 635#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] 636struct GitState { 637 commit: EcoString, 638 #[serde(default)] 639 path: Option<Utf8PathBuf>, 640} 641 642impl LocalPackages { 643 pub fn extra_local_packages(&self, manifest: &Manifest) -> Vec<(EcoString, Version)> { 644 let manifest_packages: HashSet<_> = manifest 645 .packages 646 .iter() 647 .map(|p| (&p.name, &p.version)) 648 .collect(); 649 self.packages 650 .iter() 651 .filter(|(n, v)| !manifest_packages.contains(&(&EcoString::from(*n), v))) 652 .map(|(n, v)| (n.clone(), v.clone())) 653 .collect() 654 } 655 656 pub fn missing_local_packages<'a>( 657 &self, 658 manifest: &'a Manifest, 659 root: &str, 660 ) -> Vec<&'a ManifestPackage> { 661 manifest 662 .packages 663 .iter() 664 // We don't need to download the root package 665 .filter(|p| p.name != root) 666 // We don't need to download local packages because we use the linked source directly 667 .filter(|p| !p.is_local()) 668 // We don't need to download packages which we already have on disc. For 669 // git packages this means the same commit and path. 670 .filter(|p| !self.has_fresh(p)) 671 .collect() 672 } 673 674 fn has_fresh(&self, package: &ManifestPackage) -> bool { 675 match &package.source { 676 ManifestPackageSource::Git { commit, path, .. } => match self.git.get(&package.name) { 677 Some(state) => &state.commit == commit && &state.path == path, 678 None => false, 679 }, 680 ManifestPackageSource::Hex { .. } | ManifestPackageSource::Local { .. } => { 681 self.packages.get(package.name.as_str()) == Some(&package.version) 682 } 683 } 684 } 685 686 pub fn read_from_disc(paths: &ProjectPaths) -> Result<Self> { 687 let path = paths.build_packages_toml(); 688 if !path.exists() { 689 return Ok(Self { 690 packages: HashMap::new(), 691 git: HashMap::new(), 692 }); 693 } 694 let toml = fs::read(&path)?; 695 toml::from_str(&toml).map_err(|e| Error::FileIo { 696 action: FileIoAction::Parse, 697 kind: FileKind::File, 698 path: path.clone(), 699 err: Some(e.to_string()), 700 }) 701 } 702 703 pub fn write_to_disc(&self, paths: &ProjectPaths) -> Result<()> { 704 let path = paths.build_packages_toml(); 705 let toml = toml::to_string(&self).expect("packages.toml serialization"); 706 fs::write(&path, &toml) 707 } 708 709 pub fn from_manifest(manifest: &Manifest) -> Self { 710 let packages = manifest 711 .packages 712 .iter() 713 .map(|p| (p.name.clone(), p.version.clone())) 714 .collect(); 715 let git = manifest 716 .packages 717 .iter() 718 .filter_map(|p| match &p.source { 719 ManifestPackageSource::Git { commit, path, .. } => Some(( 720 p.name.clone(), 721 GitState { 722 commit: commit.clone(), 723 path: path.clone(), 724 }, 725 )), 726 ManifestPackageSource::Hex { .. } | ManifestPackageSource::Local { .. } => None, 727 }) 728 .collect(); 729 730 Self { packages, git } 731 } 732} 733 734#[test] 735fn local_packages_deserialise_ok() { 736 let toml = r#" 737[packages] 738gleam_stdlib = "1.0.0" 739gleam_otp = "1.1.0" 740"#; 741 let packages: LocalPackages = toml::from_str(toml).unwrap(); 742 assert_eq!( 743 packages, 744 LocalPackages { 745 packages: HashMap::from_iter([ 746 ("gleam_stdlib".into(), Version::new(1, 0, 0)), 747 ("gleam_otp".into(), Version::new(1, 1, 0)), 748 ]), 749 git: HashMap::new(), 750 } 751 ) 752} 753 754#[test] 755fn local_packages_deserialise_invalid_name() { 756 let toml = r#" 757[packages] 758gleam_stdlib = "1.0.0" 759"../../stuff" = "1.1.0" 760"#; 761 let error = toml::from_str::<LocalPackages>(toml) 762 .expect_err("should fail to deserialise because of invalid name"); 763 insta::assert_snapshot!(insta::internals::AutoName, error.to_string()); 764} 765 766fn is_same_requirements( 767 requirements1: &HashMap<EcoString, Requirement>, 768 requirements2: &HashMap<EcoString, Requirement>, 769 root_path: &Utf8Path, 770) -> Result<bool> { 771 if requirements1.len() != requirements2.len() { 772 return Ok(false); 773 } 774 775 for (key, requirement1) in requirements1 { 776 if !same_requirements(requirement1, requirements2.get(key), root_path)? { 777 return Ok(false); 778 } 779 } 780 781 Ok(true) 782} 783 784/// Returns true if all path dependency configs are unchanged since last build. 785/// 786/// If any of the path dependency configs have changed that means that we need to 787/// re-perform dependency resolution, as their dependencies could have changed 788/// themselves. 789/// 790/// We use gleam.toml rather than manifest.toml as: 791/// 792/// 1. The dependency requirements could have changed but resolution not have been 793/// run in that package yet, so the manifest would be the same, resulting in us 794/// failing to detect that resolution is required. 795/// 796/// 2. Dependency manifests are not used in any way, so a change in the manifest 797/// may not have any impact on this package. 798/// 799/// This does mean that changes unrelated to the path dependency's dependencies 800/// will trigger resolution, but gleam.toml is edited rarely, and no-change 801/// resolution is fast enough, so that's OK. 802/// 803/// Note: This does not check path dependencies of path dependencies! Changes to 804/// their configs will fail to be picked up. To resolve this we would need to keep 805/// a list of all the path dependencies in the project, instead of only the direct 806/// path dependencies. 807/// 808fn path_dependency_configs_unchanged( 809 requirements: &HashMap<EcoString, Requirement>, 810 paths: &ProjectPaths, 811) -> Result<bool> { 812 for (name, requirement) in requirements { 813 let Requirement::Path { path } = requirement else { 814 continue; 815 }; 816 817 let config_path = paths.path_dependency_gleam_toml_path(path); 818 let fingerprint_path = paths.dependency_gleam_toml_fingerprint_path(name.as_str()); 819 820 // Check mtimes before hashing, to avoid extra work 821 if fingerprint_path.exists() { 822 let config_time = fs::modification_time(&config_path)?; 823 let fingerprint_time = fs::modification_time(&fingerprint_path)?; 824 if config_time <= fingerprint_time { 825 continue; 826 } 827 }; 828 829 let config_text = fs::read(&config_path)?; 830 let current_fingerprint = SourceFingerprint::new(&config_text).to_numerical_string(); 831 832 // If cached hash file doesn't exist, this is the first time we're checking this dependency 833 if !fingerprint_path.exists() { 834 // Save the current hash for future comparisons 835 fs::write(&fingerprint_path, &current_fingerprint)?; 836 return Ok(false); 837 } 838 839 let previous_fingerprint = fs::read(&fingerprint_path)?; 840 841 if previous_fingerprint != current_fingerprint { 842 tracing::debug!("path_dependency_config_changed_forcing_rebuild"); 843 fs::write(&fingerprint_path, &current_fingerprint)?; 844 return Ok(false); 845 } 846 } 847 848 Ok(true) 849} 850 851fn same_requirements( 852 requirement1: &Requirement, 853 requirement2: Option<&Requirement>, 854 root_path: &Utf8Path, 855) -> Result<bool> { 856 let (left, right) = match (requirement1, requirement2) { 857 (Requirement::Path { path: path1 }, Some(Requirement::Path { path: path2 })) => { 858 let left = fs::canonicalise(&root_path.join(path1))?; 859 let right = fs::canonicalise(&root_path.join(path2))?; 860 (left, right) 861 } 862 (_, Some(requirement2)) => return Ok(requirement1 == requirement2), 863 (_, None) => return Ok(false), 864 }; 865 866 Ok(left == right) 867} 868 869#[derive(Clone, Eq, PartialEq, Debug)] 870struct ProvidedPackage { 871 version: Version, 872 source: ProvidedPackageSource, 873 requirements: HashMap<EcoString, hexpm::version::Range>, 874} 875 876#[derive(Clone, Eq, Debug)] 877enum ProvidedPackageSource { 878 Git { 879 repo: EcoString, 880 commit: EcoString, 881 path: Option<Utf8PathBuf>, 882 }, 883 Local { 884 path: Utf8PathBuf, 885 }, 886} 887 888impl ProvidedPackage { 889 fn to_hex_package(&self, name: &EcoString) -> hexpm::Package { 890 let requirements = self 891 .requirements 892 .iter() 893 .map(|(name, version)| { 894 ( 895 name.as_str().into(), 896 hexpm::Dependency { 897 requirement: version.clone(), 898 optional: false, 899 app: None, 900 repository: None, 901 }, 902 ) 903 }) 904 .collect(); 905 let release = hexpm::Release { 906 version: self.version.clone(), 907 requirements, 908 retirement_status: None, 909 outer_checksum: vec![], 910 meta: (), 911 }; 912 hexpm::Package { 913 name: name.as_str().into(), 914 repository: "local".into(), 915 releases: vec![release], 916 } 917 } 918 919 fn to_manifest_package(&self, name: &str) -> ManifestPackage { 920 let mut package = ManifestPackage { 921 name: name.into(), 922 version: self.version.clone(), 923 otp_app: None, // Note, this will probably need to be set to something eventually 924 build_tools: vec!["gleam".into()], 925 requirements: self.requirements.keys().cloned().collect(), 926 source: self.source.to_manifest_package_source(), 927 }; 928 package.requirements.sort(); 929 package 930 } 931} 932 933impl ProvidedPackageSource { 934 fn to_manifest_package_source(&self) -> ManifestPackageSource { 935 match self { 936 Self::Git { repo, commit, path } => ManifestPackageSource::Git { 937 repo: repo.clone(), 938 commit: commit.clone(), 939 path: path.clone(), 940 }, 941 Self::Local { path } => ManifestPackageSource::Local { path: path.clone() }, 942 } 943 } 944 945 fn to_toml(&self) -> String { 946 match self { 947 Self::Git { repo, commit, path } => match path { 948 Some(path) => { 949 format!(r#"{{ repo: "{repo}", commit: "{commit}", path: "{path}" }}"#) 950 } 951 None => format!(r#"{{ repo: "{repo}", commit: "{commit}" }}"#), 952 }, 953 Self::Local { path } => { 954 format!(r#"{{ path: "{path}" }}"#) 955 } 956 } 957 } 958} 959 960impl PartialEq for ProvidedPackageSource { 961 fn eq(&self, other: &Self) -> bool { 962 match (self, other) { 963 (Self::Local { path: own_path }, Self::Local { path: other_path }) => { 964 is_same_file(own_path, other_path).unwrap_or(false) 965 } 966 967 ( 968 Self::Git { 969 repo: own_repo, 970 commit: own_commit, 971 path: own_path, 972 }, 973 Self::Git { 974 repo: other_repo, 975 commit: other_commit, 976 path: other_path, 977 }, 978 ) => own_repo == other_repo && own_commit == other_commit && own_path == other_path, 979 980 (Self::Git { .. }, Self::Local { .. }) | (Self::Local { .. }, Self::Git { .. }) => { 981 false 982 } 983 } 984 } 985} 986 987/// Where a provided package came from. Git sources carry the on-disc repository 988/// root so path dependencies can be resolved relative to it. 989enum SourceContext<'a> { 990 Local { 991 path: Utf8PathBuf, 992 }, 993 Git { 994 repo: EcoString, 995 commit: EcoString, 996 path: Option<Utf8PathBuf>, 997 repo_root: &'a Utf8Path, 998 }, 999} 1000 1001impl SourceContext<'_> { 1002 fn to_provided_source(&self) -> ProvidedPackageSource { 1003 match self { 1004 Self::Local { path } => ProvidedPackageSource::Local { path: path.clone() }, 1005 Self::Git { 1006 repo, commit, path, .. 1007 } => ProvidedPackageSource::Git { 1008 repo: repo.clone(), 1009 commit: commit.clone(), 1010 path: path.clone(), 1011 }, 1012 } 1013 } 1014} 1015 1016/// Provide a package from a local project 1017fn provide_local_package( 1018 package_name: EcoString, 1019 package_path: &Utf8Path, 1020 parent_path: &Utf8Path, 1021 project_paths: &ProjectPaths, 1022 provided: &mut HashMap<EcoString, ProvidedPackage>, 1023 parents: &mut Vec<EcoString>, 1024) -> Result<hexpm::version::Range> { 1025 let package_path = if package_path.is_absolute() { 1026 package_path.to_path_buf() 1027 } else { 1028 fs::canonicalise(&parent_path.join(package_path))? 1029 }; 1030 1031 provide_package( 1032 package_name, 1033 package_path.clone(), 1034 SourceContext::Local { path: package_path }, 1035 project_paths, 1036 provided, 1037 parents, 1038 ) 1039} 1040 1041/// Resolve a path dependency of a git package to its canonical filesystem 1042/// location and its repository-relative path. 1043fn resolve_git_path_package( 1044 package_name: &EcoString, 1045 path: &Utf8Path, 1046 repo: &EcoString, 1047 parent_path: &Utf8Path, 1048 repo_root: &Utf8Path, 1049) -> Result<(Utf8PathBuf, Utf8PathBuf)> { 1050 let location = parent_path.join(path); 1051 if !location.is_dir() { 1052 return Err(Error::GitDependencyPathNotFound { 1053 package: package_name.to_string(), 1054 path: path.to_string(), 1055 repo: repo.to_string(), 1056 }); 1057 } 1058 1059 // The path may name a symlink that points outside the repository 1060 // checkout, so resolve it and take the repository-relative path from the 1061 // canonical location. 1062 let package_path = fs::canonicalise(&location)?; 1063 let repo_path = package_path 1064 .strip_prefix(repo_root) 1065 .map_err(|_| Error::GitDependencyPathNotFound { 1066 package: package_name.to_string(), 1067 path: path.to_string(), 1068 repo: repo.to_string(), 1069 })? 1070 .to_path_buf(); 1071 1072 Ok((package_path, repo_path)) 1073} 1074 1075fn execute_command(command: &mut Command) -> Result<std::process::Output> { 1076 let result = command.output(); 1077 match result { 1078 Ok(output) if output.status.success() => Ok(output), 1079 Ok(output) => { 1080 let reason = match String::from_utf8(output.stderr) { 1081 Ok(stderr) => ShellCommandFailureReason::ShellCommandError(stderr), 1082 Err(_) => ShellCommandFailureReason::Unknown, 1083 }; 1084 Err(Error::ShellCommand { 1085 program: "git".into(), 1086 reason, 1087 }) 1088 } 1089 Err(error) => Err(git_io_error(error)), 1090 } 1091} 1092 1093/// A `git` command with its working directory set to `dir`. 1094fn git_command(dir: &Utf8Path) -> Command { 1095 let mut command = Command::new("git"); 1096 let _ = command.current_dir(dir); 1097 command 1098} 1099 1100/// Map an IO error from spawning `git` onto the appropriate `Error`. 1101fn git_io_error(error: std::io::Error) -> Error { 1102 match error.kind() { 1103 ErrorKind::NotFound => Error::ShellProgramNotFound { 1104 program: "git".into(), 1105 os: fs::get_os(), 1106 }, 1107 other => Error::ShellCommand { 1108 program: "git".into(), 1109 reason: ShellCommandFailureReason::IoError(other), 1110 }, 1111 } 1112} 1113 1114/// Parse the trimmed UTF-8 stdout of a `git` command. 1115fn git_stdout(output: std::process::Output) -> EcoString { 1116 String::from_utf8(output.stdout) 1117 .expect("Output should be UTF-8") 1118 .trim() 1119 .into() 1120} 1121 1122fn git_repo_dir_name(repo: &str) -> String { 1123 let name = repo 1124 .trim_end_matches('/') 1125 .trim_end_matches(".git") 1126 .rsplit(['/', ':', '\\']) 1127 .next() 1128 .filter(|name| !name.is_empty()) 1129 .unwrap_or("repo"); 1130 let hash = xxhash_rust::xxh3::xxh3_64(repo.as_bytes()); 1131 format!("{name}-{hash:016x}") 1132} 1133 1134fn git_staging_path(project_paths: &ProjectPaths, repo: &str, package_name: &str) -> Utf8PathBuf { 1135 project_paths.build_git_repo(&format!( 1136 "{}-{package_name}-staging", 1137 git_repo_dir_name(repo) 1138 )) 1139} 1140 1141enum GitCheckout { 1142 InPlace { 1143 commit: EcoString, 1144 }, 1145 Staged { 1146 commit: EcoString, 1147 staging_path: Utf8PathBuf, 1148 }, 1149} 1150 1151impl GitCheckout { 1152 fn cleanup(self) -> Result<()> { 1153 // The clones dangling worktree registration is left for the next 1154 // download to prune before it re-adds the staging worktree. 1155 if let Self::Staged { staging_path, .. } = self { 1156 fs::delete_directory(&staging_path)?; 1157 } 1158 Ok(()) 1159 } 1160} 1161 1162/// Downloads a git package from a remote repository. 1163/// 1164/// For a package at the root of its repository the commands that are run look 1165/// like this, cloning directly into `build/packages/<name>`: 1166/// 1167/// ```sh 1168/// git init 1169/// git remote remove origin 1170/// git remote add origin <repo> 1171/// git fetch origin 1172/// git checkout <ref> 1173/// git rev-parse HEAD 1174/// ``` 1175/// 1176/// For a package in a subdirectory of its repository the repository is 1177/// instead cloned into `build/git/<repo-name>-<hash>` as a bare repository 1178/// with no work tree, and the package is checked out into a transient staging 1179/// worktree which the subdirectory is hard-linked out of: 1180/// 1181/// ```sh 1182/// git init --bare 1183/// git remote remove origin 1184/// git remote add origin <repo> 1185/// git fetch origin 1186/// git rev-parse --verify --quiet <ref>^{commit} 1187/// git worktree prune 1188/// git worktree add --force --detach <clone>-<pkg>-staging <commit> 1189/// ``` 1190/// 1191/// The staging worktree exists only for the duration of one package download. 1192/// It is deleted (and the clone's worktree registration pruned) by 1193/// `GitCheckout::cleanup` once the package has been hard-linked and read. 1194/// 1195/// This is somewhat inefficient as we have to fetch the entire git history before 1196/// switching to the exact commit we want. There a few alternatives to this: 1197/// 1198/// - `git clone --depth 1 --branch="<ref>"` This works, but only allows us to use 1199/// branch names as refs, however we want to allow commit hashes as well. 1200/// - `git fetch --depth 1 origin <ref>` Similarly, this imposes an unwanted 1201/// restriction. `git fetch` only allows branch names or full commit hashes, 1202/// but we want to allow partial hashes as well. 1203/// 1204/// Since Git dependencies will be used quite rarely, this option was settled upon 1205/// because it allows branch names, full and partial commit hashes as refs. 1206/// 1207/// In the future we can optimise this more, for example first checking if we 1208/// are already checked out to the commit stored in the manifest, or by only 1209/// fetching the history without the objects to resolve partial commit hashes. 1210/// For now though this is good enough until it become an actual performance 1211/// problem. 1212/// 1213fn download_git_package( 1214 package_name: &str, 1215 repo: &str, 1216 ref_: &str, 1217 path: Option<&Utf8Path>, 1218 project_paths: &ProjectPaths, 1219) -> Result<GitCheckout> { 1220 match path { 1221 None => download_git_package_in_place(package_name, repo, ref_, project_paths), 1222 Some(subdir) => { 1223 download_git_package_to_staged_path(package_name, repo, ref_, project_paths, subdir) 1224 } 1225 } 1226} 1227 1228fn download_git_package_in_place( 1229 package_name: &str, 1230 repo: &str, 1231 ref_: &str, 1232 project_paths: &ProjectPaths, 1233) -> Result<GitCheckout, Error> { 1234 let clone_path = project_paths.build_packages_package(package_name); 1235 prepare_git_clone(&clone_path, repo, CloneFormat::WorkTree)?; 1236 let _ = execute_command(git_command(&clone_path).arg("checkout").arg(ref_))?; 1237 let output = execute_command(git_command(&clone_path).arg("rev-parse").arg("HEAD"))?; 1238 let commit = git_stdout(output); 1239 1240 Ok(GitCheckout::InPlace { commit }) 1241} 1242 1243fn download_git_package_to_staged_path( 1244 package_name: &str, 1245 repo: &str, 1246 ref_: &str, 1247 project_paths: &ProjectPaths, 1248 subdir: &Utf8Path, 1249) -> Result<GitCheckout, Error> { 1250 let clone_path = project_paths.build_git_repo(&git_repo_dir_name(repo)); 1251 prepare_git_clone(&clone_path, repo, CloneFormat::Bare)?; 1252 1253 let commit = resolve_git_ref(&clone_path, repo, ref_)?; 1254 1255 // Delete any staging worktree left behind by a previous crash, and prune 1256 // its registration from the clone so `git worktree add` can reuse the 1257 // path. 1258 let staging_path = git_staging_path(project_paths, repo, package_name); 1259 fs::delete_directory(&staging_path)?; 1260 let _ = git_command(&clone_path) 1261 .arg("worktree") 1262 .arg("prune") 1263 .output(); 1264 1265 let _ = execute_command( 1266 git_command(&clone_path) 1267 .arg("worktree") 1268 .arg("add") 1269 .arg("--force") 1270 .arg("--detach") 1271 .arg(&staging_path) 1272 .arg(commit.as_str()), 1273 )?; 1274 1275 let Some(subdir_source) = resolve_git_subdir(&staging_path, subdir) else { 1276 return Err(Error::GitDependencyPathNotFound { 1277 package: package_name.into(), 1278 path: subdir.to_string(), 1279 repo: repo.into(), 1280 }); 1281 }; 1282 1283 let package_path = project_paths.build_packages_package(package_name); 1284 fs::delete_directory(&package_path)?; 1285 fs::mkdir(&package_path)?; 1286 fs::hardlink_dir(&subdir_source, &package_path)?; 1287 1288 Ok(GitCheckout::Staged { 1289 commit, 1290 staging_path, 1291 }) 1292} 1293 1294#[derive(Clone, Copy)] 1295enum CloneFormat { 1296 /// A regular clone with a checked-out work tree. 1297 WorkTree, 1298 /// A bare repository with no work tree. 1299 Bare, 1300} 1301 1302/// Initialise (or reuse) a git clone at the given path and fetch from the 1303/// remote repository. 1304fn prepare_git_clone(clone_path: &Utf8Path, repo: &str, format: CloneFormat) -> Result<()> { 1305 // If the clone path exists but is not the kind of git repo we expect, we 1306 // need to remove the directory. 1307 let reusable = match format { 1308 CloneFormat::Bare => fs::is_bare_git_repo_root(clone_path), 1309 CloneFormat::WorkTree => fs::is_git_work_tree_root(clone_path), 1310 }; 1311 1312 if !reusable { 1313 fs::delete_directory(clone_path)?; 1314 } 1315 1316 fs::mkdir(clone_path)?; 1317 1318 let mut init = git_command(clone_path); 1319 let _ = init.arg("init"); 1320 if matches!(format, CloneFormat::Bare) { 1321 let _ = init.arg("--bare"); 1322 } 1323 let _ = execute_command(&mut init)?; 1324 1325 // If this directory already exists, but the remote URL has been edited in 1326 // `gleam.toml` without a `gleam clean`, `git remote add` will fail, causing 1327 // the remote to be stuck as the original value. Here we remove the remote 1328 // first, which ensures that `git remote add` properly add the remote each 1329 // time. If this fails, that means we haven't set the remote in the first 1330 // place, so we can safely ignore the error. 1331 let _ = git_command(clone_path) 1332 .arg("remote") 1333 .arg("remove") 1334 .arg("origin") 1335 .output(); 1336 1337 let _ = execute_command( 1338 git_command(clone_path) 1339 .arg("remote") 1340 .arg("add") 1341 .arg("origin") 1342 .arg(repo), 1343 )?; 1344 1345 let _ = execute_command(git_command(clone_path).arg("fetch").arg("origin"))?; 1346 1347 Ok(()) 1348} 1349 1350/// Resolve a ref (a branch name, tag, or full or partial commit hash) to a 1351/// full commit hash using the objects fetched into the clone, without 1352/// checking anything out. Branch names only exist as remote-tracking refs in 1353/// the never-checked-out clone, so when the ref does not resolve directly we 1354/// fall back to `origin/<ref>`. 1355fn resolve_git_ref(clone_path: &Utf8Path, repo: &str, ref_: &str) -> Result<EcoString> { 1356 let revisions = [ 1357 format!("{ref_}^{{commit}}"), 1358 format!("origin/{ref_}^{{commit}}"), 1359 ]; 1360 1361 for revision in revisions { 1362 let result = git_command(clone_path) 1363 .arg("rev-parse") 1364 .arg("--verify") 1365 .arg("--quiet") 1366 .arg(&revision) 1367 .output(); 1368 1369 match result { 1370 // A failed rev-parse is expected when the ref form doesn't match 1371 // this pattern, so try the next one. 1372 Ok(output) if !output.status.success() => (), 1373 Ok(output) => return Ok(git_stdout(output)), 1374 Err(error) => return Err(git_io_error(error)), 1375 } 1376 } 1377 1378 Err(Error::ShellCommand { 1379 program: "git".into(), 1380 reason: ShellCommandFailureReason::ShellCommandError(format!( 1381 "Unable to resolve git ref `{ref_}` for repository `{repo}`\n" 1382 )), 1383 }) 1384} 1385 1386/// Resolves a subdirectory within a cloned git repository, ensuring that the 1387/// resolved directory (after following any symlinks) is still inside the 1388/// repository checkout. Returns `None` if the directory does not exist or 1389/// escapes the checkout. 1390fn resolve_git_subdir(clone_path: &Utf8Path, subdir: &Utf8Path) -> Option<Utf8PathBuf> { 1391 let subdir_source = clone_path.join(subdir); 1392 if !subdir_source.is_dir() { 1393 return None; 1394 } 1395 1396 let canonical_clone = fs::canonicalise(clone_path).ok()?; 1397 let canonical_subdir = fs::canonicalise(&subdir_source).ok()?; 1398 if !canonical_subdir.starts_with(&canonical_clone) { 1399 return None; 1400 } 1401 1402 Some(canonical_subdir) 1403} 1404 1405/// Provide a package from a git repository 1406fn provide_git_package( 1407 package_name: EcoString, 1408 repo: &str, 1409 // A git ref, such as a branch name, commit hash or tag name 1410 ref_: &str, 1411 path: Option<Utf8PathBuf>, 1412 project_paths: &ProjectPaths, 1413 provided: &mut HashMap<EcoString, ProvidedPackage>, 1414 parents: &mut Vec<EcoString>, 1415) -> Result<hexpm::version::Range> { 1416 let checkout = download_git_package(&package_name, repo, ref_, path.as_deref(), project_paths)?; 1417 let (commit, staging_path) = match &checkout { 1418 GitCheckout::InPlace { commit } => (commit, None), 1419 GitCheckout::Staged { 1420 commit, 1421 staging_path, 1422 } => (commit, Some(staging_path)), 1423 }; 1424 1425 // Use the package's location in the staging worktree, where its gleam.toml 1426 // lives, not build/packages/. A `../sibling` path dep is resolved next to 1427 // that gleam.toml, and the sibling is only present in the worktree. 1428 let (package_path, repo_root) = match (&path, staging_path) { 1429 (Some(subdir), Some(staging_path)) => { 1430 let repo_root = fs::canonicalise(staging_path)?; 1431 (fs::canonicalise(&staging_path.join(subdir))?, repo_root) 1432 } 1433 _ => { 1434 let package_path = 1435 fs::canonicalise(&project_paths.build_packages_package(&package_name))?; 1436 (package_path.clone(), package_path) 1437 } 1438 }; 1439 1440 let version = provide_package( 1441 package_name, 1442 package_path, 1443 SourceContext::Git { 1444 repo: repo.into(), 1445 commit: commit.clone(), 1446 path, 1447 repo_root: &repo_root, 1448 }, 1449 project_paths, 1450 provided, 1451 parents, 1452 )?; 1453 1454 checkout.cleanup()?; 1455 Ok(version) 1456} 1457 1458/// Adds a gleam project located at a specific path to the list of "provided packages" 1459fn provide_package( 1460 package_name: EcoString, 1461 package_path: Utf8PathBuf, 1462 source: SourceContext<'_>, 1463 project_paths: &ProjectPaths, 1464 provided: &mut HashMap<EcoString, ProvidedPackage>, 1465 parents: &mut Vec<EcoString>, 1466) -> Result<hexpm::version::Range> { 1467 let package_source = source.to_provided_source(); 1468 1469 // Return early if a package cycle is detected 1470 if parents.contains(&package_name) { 1471 let mut last_cycle = parents 1472 .split(|p| p == &package_name) 1473 .next_back() 1474 .unwrap_or_default() 1475 .to_vec(); 1476 last_cycle.push(package_name); 1477 return Err(Error::PackageCycle { 1478 packages: last_cycle, 1479 }); 1480 } 1481 // Check that we do not have a cached version of this package already 1482 match provided.get(&package_name) { 1483 Some(package) if package.source == package_source => { 1484 // This package has already been provided from this source, return the version 1485 let version = hexpm::version::Range::new(format!("== {}", package.version)) 1486 .expect("== {version} should be a valid range"); 1487 return Ok(version); 1488 } 1489 Some(package) => { 1490 // This package has already been provided from a different source which conflicts 1491 return Err(Error::ProvidedDependencyConflict { 1492 package: package_name.into(), 1493 source_1: package_source.to_toml(), 1494 source_2: package.source.to_toml(), 1495 }); 1496 } 1497 None => (), 1498 } 1499 // Load the package 1500 let config = crate::config::read(package_path.join("gleam.toml"))?; 1501 // Check that we are loading the correct project 1502 if config.name != package_name { 1503 return Err(Error::WrongDependencyProvided { 1504 expected: package_name.into(), 1505 path: package_path.to_path_buf(), 1506 found: config.name.into(), 1507 }); 1508 }; 1509 // Walk the requirements of the package 1510 let mut requirements = HashMap::new(); 1511 parents.push(package_name); 1512 for (name, requirement) in config.dependencies.into_iter() { 1513 let version = match requirement { 1514 Requirement::Hex { version } => version, 1515 Requirement::Path { path } => match &source { 1516 // A path dependency of a git package points to another 1517 // package within the same repository, so lock it as a git 1518 // source to keep the manifest portable. 1519 SourceContext::Git { 1520 repo, 1521 commit, 1522 repo_root, 1523 .. 1524 } => { 1525 let (child_path, child_repo_path) = 1526 resolve_git_path_package(&name, &path, repo, &package_path, repo_root)?; 1527 // A path dependency resolving to the repository root has an 1528 // empty repo-relative path. Normalise it to `None` so it 1529 // matches a package declared directly as a git dependency 1530 // with no path. 1531 let child_repo_path = if child_repo_path.as_str().is_empty() { 1532 None 1533 } else { 1534 Some(child_repo_path) 1535 }; 1536 1537 provide_package( 1538 name.clone(), 1539 child_path, 1540 SourceContext::Git { 1541 repo: repo.clone(), 1542 commit: commit.clone(), 1543 path: child_repo_path, 1544 repo_root, 1545 }, 1546 project_paths, 1547 provided, 1548 parents, 1549 )? 1550 } 1551 SourceContext::Local { .. } => { 1552 // Recursively walk local packages 1553 provide_local_package( 1554 name.clone(), 1555 &path, 1556 &package_path, 1557 project_paths, 1558 provided, 1559 parents, 1560 )? 1561 } 1562 }, 1563 Requirement::Git { git, ref_, path } => provide_git_package( 1564 name.clone(), 1565 &git, 1566 &ref_, 1567 path, 1568 project_paths, 1569 provided, 1570 parents, 1571 )?, 1572 }; 1573 let _ = requirements.insert(name, version); 1574 } 1575 let _ = parents.pop(); 1576 // Add the package to the provided packages dictionary 1577 let version = hexpm::version::Range::new(format!("== {}", config.version)) 1578 .expect("== {version} should be a valid range"); 1579 let _ = provided.insert( 1580 config.name, 1581 ProvidedPackage { 1582 version: config.version, 1583 source: package_source, 1584 requirements, 1585 }, 1586 ); 1587 // Return the version 1588 Ok(version) 1589} 1590 1591/// Unlocks specified packages and their unique dependencies. 1592/// 1593/// If a manifest is provided, it also unlocks indirect dependencies that are 1594/// not required by any other package or the root project. 1595pub fn unlock_packages( 1596 locked: &mut HashMap<EcoString, Version>, 1597 packages_to_unlock: &[EcoString], 1598 manifest: Option<&Manifest>, 1599) -> Result<()> { 1600 if let Some(manifest) = manifest { 1601 let mut packages_to_unlock: Vec<EcoString> = packages_to_unlock.to_vec(); 1602 1603 while let Some(package_name) = packages_to_unlock.pop() { 1604 if locked.remove(&package_name).is_some() 1605 && let Some(package) = manifest.packages.iter().find(|p| p.name == package_name) 1606 { 1607 let deps_to_unlock = find_deps_to_unlock(package, locked, manifest); 1608 packages_to_unlock.extend(deps_to_unlock); 1609 } 1610 } 1611 } else { 1612 for package_name in packages_to_unlock { 1613 let _ = locked.remove(package_name); 1614 } 1615 } 1616 1617 Ok(()) 1618} 1619 1620/// Identifies which dependencies of a package should be unlocked. 1621/// 1622/// A dependency is eligible for unlocking if it is currently locked, 1623/// is not a root dependency, and is not required by any locked package. 1624fn find_deps_to_unlock( 1625 package: &ManifestPackage, 1626 locked: &HashMap<EcoString, Version>, 1627 manifest: &Manifest, 1628) -> Vec<EcoString> { 1629 package 1630 .requirements 1631 .iter() 1632 .filter(|&dep| { 1633 locked.contains_key(dep) 1634 && !manifest.requirements.contains_key(dep) 1635 && manifest 1636 .packages 1637 .iter() 1638 .all(|p| !locked.contains_key(&p.name) || !p.requirements.contains(dep)) 1639 }) 1640 .cloned() 1641 .collect() 1642} 1643 1644/// Determine the information to add to the manifest for a specific package 1645async fn lookup_package( 1646 name: String, 1647 version: Version, 1648 provided: &HashMap<EcoString, ProvidedPackage>, 1649) -> Result<ManifestPackage> { 1650 match provided.get(name.as_str()) { 1651 Some(provided_package) => Ok(provided_package.to_manifest_package(name.as_str())), 1652 None => { 1653 let config = hexpm::Config::new(); 1654 let release = 1655 hex::get_package_release(&name, &version, &config, &HttpClient::new()).await?; 1656 let build_tools = release 1657 .meta 1658 .build_tools 1659 .iter() 1660 .map(|s| EcoString::from(s.as_str())) 1661 .collect_vec(); 1662 let requirements = release 1663 .requirements 1664 .keys() 1665 .map(|s| EcoString::from(s.as_str())) 1666 .collect_vec(); 1667 Ok(ManifestPackage { 1668 name: name.into(), 1669 version, 1670 otp_app: Some(release.meta.app.into()), 1671 build_tools, 1672 requirements, 1673 source: ManifestPackageSource::Hex { 1674 outer_checksum: Base16Checksum(release.outer_checksum), 1675 }, 1676 }) 1677 } 1678 } 1679} 1680 1681struct PackageFetcher { 1682 runtime_cache: RefCell<HashMap<String, Rc<hexpm::Package>>>, 1683 runtime: tokio::runtime::Handle, 1684 http: HttpClient, 1685} 1686 1687impl PackageFetcher { 1688 pub fn new(runtime: tokio::runtime::Handle) -> Self { 1689 Self { 1690 runtime_cache: RefCell::new(HashMap::new()), 1691 runtime, 1692 http: HttpClient::new(), 1693 } 1694 } 1695 1696 /// Caches the result of `get_dependencies` so that we don't need to make a network request. 1697 /// Currently dependencies are fetched during initial version resolution, and then during check 1698 /// for major version availability. 1699 fn cache_package(&self, package: &str, result: Rc<hexpm::Package>) { 1700 let mut runtime_cache = self.runtime_cache.borrow_mut(); 1701 let _ = runtime_cache.insert(package.to_string(), result); 1702 } 1703} 1704 1705#[derive(Debug)] 1706pub struct Untar; 1707 1708impl Untar { 1709 pub fn boxed() -> Box<Self> { 1710 Box::new(Self) 1711 } 1712} 1713 1714impl TarUnpacker for Untar { 1715 fn io_result_entries<'a>( 1716 &self, 1717 archive: &'a mut tar::Archive<WrappedReader>, 1718 ) -> std::io::Result<tar::Entries<'a, WrappedReader>> { 1719 archive.entries() 1720 } 1721 1722 fn io_result_unpack( 1723 &self, 1724 path: &Utf8Path, 1725 mut archive: tar::Archive<GzDecoder<tar::Entry<'_, WrappedReader>>>, 1726 ) -> std::io::Result<()> { 1727 archive.unpack(path) 1728 } 1729} 1730 1731impl dependency::PackageFetcher for PackageFetcher { 1732 fn get_dependencies(&self, package: &str) -> Result<Rc<hexpm::Package>, PackageFetchError> { 1733 { 1734 let runtime_cache = self.runtime_cache.borrow(); 1735 let result = runtime_cache.get(package); 1736 1737 if let Some(result) = result { 1738 return Ok(result.clone()); 1739 } 1740 } 1741 1742 tracing::debug!(package = package, "looking_up_hex_package"); 1743 let config = hexpm::Config::new(); 1744 let request = hexpm::repository_v2_get_package_request(package, None, &config); 1745 let response = self 1746 .runtime 1747 .block_on(self.http.send(request)) 1748 .map_err(PackageFetchError::fetch_error)?; 1749 1750 let pkg = hexpm::repository_v2_get_package_response(response, HEXPM_PUBLIC_KEY) 1751 .map_err(|e| PackageFetchError::from_api_error(e, package))?; 1752 let pkg = Rc::new(pkg); 1753 let pkg_ref = Rc::clone(&pkg); 1754 self.cache_package(package, pkg); 1755 Ok(pkg_ref) 1756 } 1757}