Fork of daniellemaywood.uk/gleam — Wasm codegen work
59 kB
1769 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(
514 fs.clone(),
515 fs,
516 http,
517 Untar::boxed(),
518 crate::hex::read_env_readonly_api_key(),
519 paths.clone(),
520 );
521 let start = Instant::now();
522 telemetry.downloading_package("packages");
523 downloader
524 .download_hex_packages(missing_hex_packages, &project_name)
525 .await?;
526 for package in missing_git_packages {
527 let ManifestPackageSource::Git { repo, commit, path } = &package.source else {
528 continue;
529 };
530
531 let checkout =
532 download_git_package(&package.name, repo, commit, path.as_deref(), paths)?;
533 checkout.cleanup()?;
534 }
535 telemetry.packages_downloaded(start, num_to_download);
536 }
537
538 Ok(())
539}
540
541fn remove_extra_packages<Telem: Telemetry>(
542 paths: &ProjectPaths,
543 local: &LocalPackages,
544 manifest: &Manifest,
545 telemetry: &Telem,
546) -> Result<()> {
547 let _guard = BuildLock::lock_all_build(paths, telemetry)?;
548
549 for (package_name, version) in local.extra_local_packages(manifest) {
550 // TODO: test
551 // Delete the package source
552 let path = paths.build_packages_package(&package_name);
553 if path.exists() {
554 tracing::debug!(package=%package_name, version=%version, "removing_unneeded_package");
555 fs::delete_directory(&path)?;
556 }
557
558 // TODO: test
559 // Delete any build artefacts for the package
560 for mode in Mode::iter() {
561 for target in Target::iter() {
562 let name = manifest
563 .packages
564 .iter()
565 .find(|p| p.name == package_name)
566 .map(|p| p.application_name().as_str())
567 .unwrap_or(package_name.as_str());
568 let path = paths.build_directory_for_package(mode, target, name);
569 if path.exists() {
570 tracing::debug!(package=%package_name, version=%version, "deleting_build_cache");
571 fs::delete_directory(&path)?;
572 }
573 }
574 }
575 }
576
577 remove_unused_git_clones(paths, manifest)?;
578 Ok(())
579}
580
581fn remove_unused_git_clones(paths: &ProjectPaths, manifest: &Manifest) -> Result<()> {
582 let git_directory = paths.build_git_directory();
583 if !git_directory.is_dir() {
584 return Ok(());
585 }
586
587 let expected: HashSet<String> = manifest
588 .packages
589 .iter()
590 .filter_map(|package| match &package.source {
591 ManifestPackageSource::Git {
592 repo,
593 path: Some(_),
594 ..
595 } => Some(git_repo_dir_name(repo)),
596 _ => None,
597 })
598 .collect();
599
600 for entry in fs::read_dir(&git_directory)?.filter_map(Result::ok) {
601 if !expected.contains(entry.file_name()) {
602 tracing::debug!(path=%entry.path(), "removing_unused_git_clone");
603 fs::delete_directory(entry.path())?;
604 }
605 }
606 Ok(())
607}
608
609fn read_manifest_from_disc(paths: &ProjectPaths) -> Result<Manifest> {
610 tracing::debug!("reading_manifest_toml");
611 let manifest_path = paths.manifest();
612 let toml = fs::read(&manifest_path)?;
613 let manifest = toml::from_str(&toml).map_err(|e| Error::FileIo {
614 action: FileIoAction::Parse,
615 kind: FileKind::File,
616 path: manifest_path.clone(),
617 err: Some(e.to_string()),
618 })?;
619 Ok(manifest)
620}
621
622fn write_manifest_to_disc(paths: &ProjectPaths, manifest: &Manifest) -> Result<()> {
623 let path = paths.manifest();
624 fs::write(&path, &manifest.to_toml(paths.root()))
625}
626
627// This is the container for locally pinned packages, representing the current contents of
628// the `project/build/packages` directory.
629// For descriptions of packages provided by paths and git deps, see the ProvidedPackage struct.
630// The same package may appear in both at different times.
631#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
632struct LocalPackages {
633 #[serde(deserialize_with = "gleam_core::config::map_with_package_name_keys::deserialize")]
634 packages: HashMap<EcoString, Version>,
635 // Git packages can resolve to a new commit (or a new sub-path) without
636 // their version changing, so their on-disc freshness is keyed by commit
637 // and path rather than version alone. Absent for hex and local packages.
638 #[serde(default)]
639 git: HashMap<EcoString, GitState>,
640}
641
642#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
643struct GitState {
644 commit: EcoString,
645 #[serde(default)]
646 path: Option<Utf8PathBuf>,
647}
648
649impl LocalPackages {
650 pub fn extra_local_packages(&self, manifest: &Manifest) -> Vec<(EcoString, Version)> {
651 let manifest_packages: HashSet<_> = manifest
652 .packages
653 .iter()
654 .map(|p| (&p.name, &p.version))
655 .collect();
656 self.packages
657 .iter()
658 .filter(|(n, v)| !manifest_packages.contains(&(&EcoString::from(*n), v)))
659 .map(|(n, v)| (n.clone(), v.clone()))
660 .collect()
661 }
662
663 pub fn missing_local_packages<'a>(
664 &self,
665 manifest: &'a Manifest,
666 root: &str,
667 ) -> Vec<&'a ManifestPackage> {
668 manifest
669 .packages
670 .iter()
671 // We don't need to download the root package
672 .filter(|p| p.name != root)
673 // We don't need to download local packages because we use the linked source directly
674 .filter(|p| !p.is_local())
675 // We don't need to download packages which we already have on disc. For
676 // git packages this means the same commit and path.
677 .filter(|p| !self.has_fresh(p))
678 .collect()
679 }
680
681 fn has_fresh(&self, package: &ManifestPackage) -> bool {
682 match &package.source {
683 ManifestPackageSource::Git { commit, path, .. } => match self.git.get(&package.name) {
684 Some(state) => &state.commit == commit && &state.path == path,
685 None => false,
686 },
687 ManifestPackageSource::Hex { .. } | ManifestPackageSource::Local { .. } => {
688 self.packages.get(package.name.as_str()) == Some(&package.version)
689 }
690 }
691 }
692
693 pub fn read_from_disc(paths: &ProjectPaths) -> Result<Self> {
694 let path = paths.build_packages_toml();
695 if !path.exists() {
696 return Ok(Self {
697 packages: HashMap::new(),
698 git: HashMap::new(),
699 });
700 }
701 let toml = fs::read(&path)?;
702 toml::from_str(&toml).map_err(|e| Error::FileIo {
703 action: FileIoAction::Parse,
704 kind: FileKind::File,
705 path: path.clone(),
706 err: Some(e.to_string()),
707 })
708 }
709
710 pub fn write_to_disc(&self, paths: &ProjectPaths) -> Result<()> {
711 let path = paths.build_packages_toml();
712 let toml = toml::to_string(&self).expect("packages.toml serialization");
713 fs::write(&path, &toml)
714 }
715
716 pub fn from_manifest(manifest: &Manifest) -> Self {
717 let packages = manifest
718 .packages
719 .iter()
720 .map(|p| (p.name.clone(), p.version.clone()))
721 .collect();
722 let git = manifest
723 .packages
724 .iter()
725 .filter_map(|p| match &p.source {
726 ManifestPackageSource::Git { commit, path, .. } => Some((
727 p.name.clone(),
728 GitState {
729 commit: commit.clone(),
730 path: path.clone(),
731 },
732 )),
733 ManifestPackageSource::Hex { .. } | ManifestPackageSource::Local { .. } => None,
734 })
735 .collect();
736
737 Self { packages, git }
738 }
739}
740
741#[test]
742fn local_packages_deserialise_ok() {
743 let toml = r#"
744[packages]
745gleam_stdlib = "1.0.0"
746gleam_otp = "1.1.0"
747"#;
748 let packages: LocalPackages = toml::from_str(toml).unwrap();
749 assert_eq!(
750 packages,
751 LocalPackages {
752 packages: HashMap::from_iter([
753 ("gleam_stdlib".into(), Version::new(1, 0, 0)),
754 ("gleam_otp".into(), Version::new(1, 1, 0)),
755 ]),
756 git: HashMap::new(),
757 }
758 )
759}
760
761#[test]
762fn local_packages_deserialise_invalid_name() {
763 let toml = r#"
764[packages]
765gleam_stdlib = "1.0.0"
766"../../stuff" = "1.1.0"
767"#;
768 let error = toml::from_str::<LocalPackages>(toml)
769 .expect_err("should fail to deserialise because of invalid name");
770 insta::assert_snapshot!(insta::internals::AutoName, error.to_string());
771}
772
773fn is_same_requirements(
774 requirements1: &HashMap<EcoString, Requirement>,
775 requirements2: &HashMap<EcoString, Requirement>,
776 root_path: &Utf8Path,
777) -> Result<bool> {
778 if requirements1.len() != requirements2.len() {
779 return Ok(false);
780 }
781
782 for (key, requirement1) in requirements1 {
783 if !same_requirements(requirement1, requirements2.get(key), root_path)? {
784 return Ok(false);
785 }
786 }
787
788 Ok(true)
789}
790
791/// Returns true if all path dependency configs are unchanged since last build.
792///
793/// If any of the path dependency configs have changed that means that we need to
794/// re-perform dependency resolution, as their dependencies could have changed
795/// themselves.
796///
797/// We use gleam.toml rather than manifest.toml as:
798///
799/// 1. The dependency requirements could have changed but resolution not have been
800/// run in that package yet, so the manifest would be the same, resulting in us
801/// failing to detect that resolution is required.
802///
803/// 2. Dependency manifests are not used in any way, so a change in the manifest
804/// may not have any impact on this package.
805///
806/// This does mean that changes unrelated to the path dependency's dependencies
807/// will trigger resolution, but gleam.toml is edited rarely, and no-change
808/// resolution is fast enough, so that's OK.
809///
810/// Note: This does not check path dependencies of path dependencies! Changes to
811/// their configs will fail to be picked up. To resolve this we would need to keep
812/// a list of all the path dependencies in the project, instead of only the direct
813/// path dependencies.
814///
815fn path_dependency_configs_unchanged(
816 requirements: &HashMap<EcoString, Requirement>,
817 paths: &ProjectPaths,
818) -> Result<bool> {
819 for (name, requirement) in requirements {
820 let Requirement::Path { path } = requirement else {
821 continue;
822 };
823
824 let config_path = paths.path_dependency_gleam_toml_path(path);
825 let fingerprint_path = paths.dependency_gleam_toml_fingerprint_path(name.as_str());
826
827 // Check mtimes before hashing, to avoid extra work
828 if fingerprint_path.exists() {
829 let config_time = fs::modification_time(&config_path)?;
830 let fingerprint_time = fs::modification_time(&fingerprint_path)?;
831 if config_time <= fingerprint_time {
832 continue;
833 }
834 };
835
836 let config_text = fs::read(&config_path)?;
837 let current_fingerprint = SourceFingerprint::new(&config_text).to_numerical_string();
838
839 // If cached hash file doesn't exist, this is the first time we're checking this dependency
840 if !fingerprint_path.exists() {
841 // Save the current hash for future comparisons
842 fs::write(&fingerprint_path, ¤t_fingerprint)?;
843 return Ok(false);
844 }
845
846 let previous_fingerprint = fs::read(&fingerprint_path)?;
847
848 if previous_fingerprint != current_fingerprint {
849 tracing::debug!("path_dependency_config_changed_forcing_rebuild");
850 fs::write(&fingerprint_path, ¤t_fingerprint)?;
851 return Ok(false);
852 }
853 }
854
855 Ok(true)
856}
857
858fn same_requirements(
859 requirement1: &Requirement,
860 requirement2: Option<&Requirement>,
861 root_path: &Utf8Path,
862) -> Result<bool> {
863 let (left, right) = match (requirement1, requirement2) {
864 (Requirement::Path { path: path1 }, Some(Requirement::Path { path: path2 })) => {
865 let left = fs::canonicalise(&root_path.join(path1))?;
866 let right = fs::canonicalise(&root_path.join(path2))?;
867 (left, right)
868 }
869 (_, Some(requirement2)) => return Ok(requirement1 == requirement2),
870 (_, None) => return Ok(false),
871 };
872
873 Ok(left == right)
874}
875
876#[derive(Clone, Eq, PartialEq, Debug)]
877struct ProvidedPackage {
878 version: Version,
879 source: ProvidedPackageSource,
880 requirements: HashMap<EcoString, hexpm::version::Range>,
881}
882
883#[derive(Clone, Eq, Debug)]
884enum ProvidedPackageSource {
885 Git {
886 repo: EcoString,
887 commit: EcoString,
888 path: Option<Utf8PathBuf>,
889 },
890 Local {
891 path: Utf8PathBuf,
892 },
893}
894
895impl ProvidedPackage {
896 fn to_hex_package(&self, name: &EcoString) -> hexpm::Package {
897 let requirements = self
898 .requirements
899 .iter()
900 .map(|(name, version)| {
901 (
902 name.as_str().into(),
903 hexpm::Dependency {
904 requirement: version.clone(),
905 optional: false,
906 app: None,
907 repository: None,
908 },
909 )
910 })
911 .collect();
912 let release = hexpm::Release {
913 version: self.version.clone(),
914 requirements,
915 retirement_status: None,
916 outer_checksum: vec![],
917 meta: (),
918 };
919 hexpm::Package {
920 name: name.as_str().into(),
921 repository: "local".into(),
922 releases: vec![release],
923 }
924 }
925
926 fn to_manifest_package(&self, name: &str) -> ManifestPackage {
927 let mut package = ManifestPackage {
928 name: name.into(),
929 version: self.version.clone(),
930 otp_app: None, // Note, this will probably need to be set to something eventually
931 build_tools: vec!["gleam".into()],
932 requirements: self.requirements.keys().cloned().collect(),
933 source: self.source.to_manifest_package_source(),
934 };
935 package.requirements.sort();
936 package
937 }
938}
939
940impl ProvidedPackageSource {
941 fn to_manifest_package_source(&self) -> ManifestPackageSource {
942 match self {
943 Self::Git { repo, commit, path } => ManifestPackageSource::Git {
944 repo: repo.clone(),
945 commit: commit.clone(),
946 path: path.clone(),
947 },
948 Self::Local { path } => ManifestPackageSource::Local { path: path.clone() },
949 }
950 }
951
952 fn to_toml(&self) -> String {
953 match self {
954 Self::Git { repo, commit, path } => match path {
955 Some(path) => {
956 format!(r#"{{ repo: "{repo}", commit: "{commit}", path: "{path}" }}"#)
957 }
958 None => format!(r#"{{ repo: "{repo}", commit: "{commit}" }}"#),
959 },
960 Self::Local { path } => {
961 format!(r#"{{ path: "{path}" }}"#)
962 }
963 }
964 }
965}
966
967impl PartialEq for ProvidedPackageSource {
968 fn eq(&self, other: &Self) -> bool {
969 match (self, other) {
970 (Self::Local { path: own_path }, Self::Local { path: other_path }) => {
971 is_same_file(own_path, other_path).unwrap_or(false)
972 }
973
974 (
975 Self::Git {
976 repo: own_repo,
977 commit: own_commit,
978 path: own_path,
979 },
980 Self::Git {
981 repo: other_repo,
982 commit: other_commit,
983 path: other_path,
984 },
985 ) => own_repo == other_repo && own_commit == other_commit && own_path == other_path,
986
987 (Self::Git { .. }, Self::Local { .. }) | (Self::Local { .. }, Self::Git { .. }) => {
988 false
989 }
990 }
991 }
992}
993
994/// Where a provided package came from. Git sources carry the on-disc repository
995/// root so path dependencies can be resolved relative to it.
996enum SourceContext<'a> {
997 Local {
998 path: Utf8PathBuf,
999 },
1000 Git {
1001 repo: EcoString,
1002 commit: EcoString,
1003 path: Option<Utf8PathBuf>,
1004 repo_root: &'a Utf8Path,
1005 },
1006}
1007
1008impl SourceContext<'_> {
1009 fn to_provided_source(&self) -> ProvidedPackageSource {
1010 match self {
1011 Self::Local { path } => ProvidedPackageSource::Local { path: path.clone() },
1012 Self::Git {
1013 repo, commit, path, ..
1014 } => ProvidedPackageSource::Git {
1015 repo: repo.clone(),
1016 commit: commit.clone(),
1017 path: path.clone(),
1018 },
1019 }
1020 }
1021}
1022
1023/// Provide a package from a local project
1024fn provide_local_package(
1025 package_name: EcoString,
1026 package_path: &Utf8Path,
1027 parent_path: &Utf8Path,
1028 project_paths: &ProjectPaths,
1029 provided: &mut HashMap<EcoString, ProvidedPackage>,
1030 parents: &mut Vec<EcoString>,
1031) -> Result<hexpm::version::Range> {
1032 let package_path = if package_path.is_absolute() {
1033 package_path.to_path_buf()
1034 } else {
1035 fs::canonicalise(&parent_path.join(package_path))?
1036 };
1037
1038 provide_package(
1039 package_name,
1040 package_path.clone(),
1041 SourceContext::Local { path: package_path },
1042 project_paths,
1043 provided,
1044 parents,
1045 )
1046}
1047
1048/// Resolve a path dependency of a git package to its canonical filesystem
1049/// location and its repository-relative path.
1050fn resolve_git_path_package(
1051 package_name: &EcoString,
1052 path: &Utf8Path,
1053 repo: &EcoString,
1054 parent_path: &Utf8Path,
1055 repo_root: &Utf8Path,
1056) -> Result<(Utf8PathBuf, Utf8PathBuf)> {
1057 let location = parent_path.join(path);
1058 if !location.is_dir() {
1059 return Err(Error::GitDependencyPathNotFound {
1060 package: package_name.to_string(),
1061 path: path.to_string(),
1062 repo: repo.to_string(),
1063 });
1064 }
1065
1066 // The path may name a symlink that points outside the repository
1067 // checkout, so resolve it and take the repository-relative path from the
1068 // canonical location.
1069 let package_path = fs::canonicalise(&location)?;
1070 let repo_path = package_path
1071 .strip_prefix(repo_root)
1072 .map_err(|_| Error::GitDependencyPathNotFound {
1073 package: package_name.to_string(),
1074 path: path.to_string(),
1075 repo: repo.to_string(),
1076 })?
1077 .to_path_buf();
1078
1079 Ok((package_path, repo_path))
1080}
1081
1082fn execute_command(command: &mut Command) -> Result<std::process::Output> {
1083 let result = command.output();
1084 match result {
1085 Ok(output) if output.status.success() => Ok(output),
1086 Ok(output) => {
1087 let reason = match String::from_utf8(output.stderr) {
1088 Ok(stderr) => ShellCommandFailureReason::ShellCommandError(stderr),
1089 Err(_) => ShellCommandFailureReason::Unknown,
1090 };
1091 Err(Error::ShellCommand {
1092 program: "git".into(),
1093 reason,
1094 })
1095 }
1096 Err(error) => Err(git_io_error(error)),
1097 }
1098}
1099
1100/// A `git` command with its working directory set to `dir`.
1101fn git_command(dir: &Utf8Path) -> Command {
1102 let mut command = Command::new("git");
1103 let _ = command.current_dir(dir);
1104 command
1105}
1106
1107/// Map an IO error from spawning `git` onto the appropriate `Error`.
1108fn git_io_error(error: std::io::Error) -> Error {
1109 match error.kind() {
1110 ErrorKind::NotFound => Error::ShellProgramNotFound {
1111 program: "git".into(),
1112 os: fs::get_os(),
1113 },
1114 other => Error::ShellCommand {
1115 program: "git".into(),
1116 reason: ShellCommandFailureReason::IoError(other),
1117 },
1118 }
1119}
1120
1121/// Parse the trimmed UTF-8 stdout of a `git` command.
1122fn git_stdout(output: std::process::Output) -> EcoString {
1123 String::from_utf8(output.stdout)
1124 .expect("Output should be UTF-8")
1125 .trim()
1126 .into()
1127}
1128
1129fn git_repo_dir_name(repo: &str) -> String {
1130 let name = repo
1131 .trim_end_matches('/')
1132 .trim_end_matches(".git")
1133 .rsplit(['/', ':', '\\'])
1134 .next()
1135 .filter(|name| !name.is_empty())
1136 .unwrap_or("repo");
1137 let hash = xxhash_rust::xxh3::xxh3_64(repo.as_bytes());
1138 format!("{name}-{hash:016x}")
1139}
1140
1141fn git_staging_path(project_paths: &ProjectPaths, repo: &str, package_name: &str) -> Utf8PathBuf {
1142 project_paths.build_git_repo(&format!(
1143 "{}-{package_name}-staging",
1144 git_repo_dir_name(repo)
1145 ))
1146}
1147
1148enum GitCheckout {
1149 InPlace {
1150 commit: EcoString,
1151 },
1152 Staged {
1153 commit: EcoString,
1154 staging_path: Utf8PathBuf,
1155 },
1156}
1157
1158impl GitCheckout {
1159 fn cleanup(self) -> Result<()> {
1160 // The clones dangling worktree registration is left for the next
1161 // download to prune before it re-adds the staging worktree.
1162 if let Self::Staged { staging_path, .. } = self {
1163 fs::delete_directory(&staging_path)?;
1164 }
1165 Ok(())
1166 }
1167}
1168
1169/// Downloads a git package from a remote repository.
1170///
1171/// For a package at the root of its repository the commands that are run look
1172/// like this, cloning directly into `build/packages/<name>`:
1173///
1174/// ```sh
1175/// git init
1176/// git remote remove origin
1177/// git remote add origin <repo>
1178/// git fetch origin
1179/// git checkout <ref>
1180/// git rev-parse HEAD
1181/// ```
1182///
1183/// For a package in a subdirectory of its repository the repository is
1184/// instead cloned into `build/git/<repo-name>-<hash>` as a bare repository
1185/// with no work tree, and the package is checked out into a transient staging
1186/// worktree which the subdirectory is hard-linked out of:
1187///
1188/// ```sh
1189/// git init --bare
1190/// git remote remove origin
1191/// git remote add origin <repo>
1192/// git fetch origin
1193/// git rev-parse --verify --quiet <ref>^{commit}
1194/// git worktree prune
1195/// git worktree add --force --detach <clone>-<pkg>-staging <commit>
1196/// ```
1197///
1198/// The staging worktree exists only for the duration of one package download.
1199/// It is deleted (and the clone's worktree registration pruned) by
1200/// `GitCheckout::cleanup` once the package has been hard-linked and read.
1201///
1202/// This is somewhat inefficient as we have to fetch the entire git history before
1203/// switching to the exact commit we want. There a few alternatives to this:
1204///
1205/// - `git clone --depth 1 --branch="<ref>"` This works, but only allows us to use
1206/// branch names as refs, however we want to allow commit hashes as well.
1207/// - `git fetch --depth 1 origin <ref>` Similarly, this imposes an unwanted
1208/// restriction. `git fetch` only allows branch names or full commit hashes,
1209/// but we want to allow partial hashes as well.
1210///
1211/// Since Git dependencies will be used quite rarely, this option was settled upon
1212/// because it allows branch names, full and partial commit hashes as refs.
1213///
1214/// In the future we can optimise this more, for example first checking if we
1215/// are already checked out to the commit stored in the manifest, or by only
1216/// fetching the history without the objects to resolve partial commit hashes.
1217/// For now though this is good enough until it become an actual performance
1218/// problem.
1219///
1220fn download_git_package(
1221 package_name: &str,
1222 repo: &str,
1223 ref_: &str,
1224 path: Option<&Utf8Path>,
1225 project_paths: &ProjectPaths,
1226) -> Result<GitCheckout> {
1227 match path {
1228 None => download_git_package_in_place(package_name, repo, ref_, project_paths),
1229 Some(subdir) => {
1230 download_git_package_to_staged_path(package_name, repo, ref_, project_paths, subdir)
1231 }
1232 }
1233}
1234
1235fn download_git_package_in_place(
1236 package_name: &str,
1237 repo: &str,
1238 ref_: &str,
1239 project_paths: &ProjectPaths,
1240) -> Result<GitCheckout, Error> {
1241 let clone_path = project_paths.build_packages_package(package_name);
1242 prepare_git_clone(&clone_path, repo, CloneFormat::WorkTree)?;
1243 let _ = execute_command(git_command(&clone_path).arg("checkout").arg(ref_))?;
1244 let output = execute_command(git_command(&clone_path).arg("rev-parse").arg("HEAD"))?;
1245 let commit = git_stdout(output);
1246
1247 Ok(GitCheckout::InPlace { commit })
1248}
1249
1250fn download_git_package_to_staged_path(
1251 package_name: &str,
1252 repo: &str,
1253 ref_: &str,
1254 project_paths: &ProjectPaths,
1255 subdir: &Utf8Path,
1256) -> Result<GitCheckout, Error> {
1257 let clone_path = project_paths.build_git_repo(&git_repo_dir_name(repo));
1258 prepare_git_clone(&clone_path, repo, CloneFormat::Bare)?;
1259
1260 let commit = resolve_git_ref(&clone_path, repo, ref_)?;
1261
1262 // Delete any staging worktree left behind by a previous crash, and prune
1263 // its registration from the clone so `git worktree add` can reuse the
1264 // path.
1265 let staging_path = git_staging_path(project_paths, repo, package_name);
1266 fs::delete_directory(&staging_path)?;
1267 let _ = git_command(&clone_path)
1268 .arg("worktree")
1269 .arg("prune")
1270 .output();
1271
1272 let _ = execute_command(
1273 git_command(&clone_path)
1274 .arg("worktree")
1275 .arg("add")
1276 .arg("--force")
1277 .arg("--detach")
1278 .arg(&staging_path)
1279 .arg(commit.as_str()),
1280 )?;
1281
1282 let Some(subdir_source) = resolve_git_subdir(&staging_path, subdir) else {
1283 return Err(Error::GitDependencyPathNotFound {
1284 package: package_name.into(),
1285 path: subdir.to_string(),
1286 repo: repo.into(),
1287 });
1288 };
1289
1290 let package_path = project_paths.build_packages_package(package_name);
1291 fs::delete_directory(&package_path)?;
1292 fs::mkdir(&package_path)?;
1293 fs::hardlink_dir(&subdir_source, &package_path)?;
1294
1295 Ok(GitCheckout::Staged {
1296 commit,
1297 staging_path,
1298 })
1299}
1300
1301#[derive(Clone, Copy)]
1302enum CloneFormat {
1303 /// A regular clone with a checked-out work tree.
1304 WorkTree,
1305 /// A bare repository with no work tree.
1306 Bare,
1307}
1308
1309/// Initialise (or reuse) a git clone at the given path and fetch from the
1310/// remote repository.
1311fn prepare_git_clone(clone_path: &Utf8Path, repo: &str, format: CloneFormat) -> Result<()> {
1312 // If the clone path exists but is not the kind of git repo we expect, we
1313 // need to remove the directory.
1314 let reusable = match format {
1315 CloneFormat::Bare => fs::is_bare_git_repo_root(clone_path),
1316 CloneFormat::WorkTree => fs::is_git_work_tree_root(clone_path),
1317 };
1318
1319 if !reusable {
1320 fs::delete_directory(clone_path)?;
1321 }
1322
1323 fs::mkdir(clone_path)?;
1324
1325 let mut init = git_command(clone_path);
1326 let _ = init.arg("init");
1327 if matches!(format, CloneFormat::Bare) {
1328 let _ = init.arg("--bare");
1329 }
1330 let _ = execute_command(&mut init)?;
1331
1332 // If this directory already exists, but the remote URL has been edited in
1333 // `gleam.toml` without a `gleam clean`, `git remote add` will fail, causing
1334 // the remote to be stuck as the original value. Here we remove the remote
1335 // first, which ensures that `git remote add` properly add the remote each
1336 // time. If this fails, that means we haven't set the remote in the first
1337 // place, so we can safely ignore the error.
1338 let _ = git_command(clone_path)
1339 .arg("remote")
1340 .arg("remove")
1341 .arg("origin")
1342 .output();
1343
1344 let _ = execute_command(
1345 git_command(clone_path)
1346 .arg("remote")
1347 .arg("add")
1348 .arg("origin")
1349 .arg(repo),
1350 )?;
1351
1352 let _ = execute_command(git_command(clone_path).arg("fetch").arg("origin"))?;
1353
1354 Ok(())
1355}
1356
1357/// Resolve a ref (a branch name, tag, or full or partial commit hash) to a
1358/// full commit hash using the objects fetched into the clone, without
1359/// checking anything out. Branch names only exist as remote-tracking refs in
1360/// the never-checked-out clone, so when the ref does not resolve directly we
1361/// fall back to `origin/<ref>`.
1362fn resolve_git_ref(clone_path: &Utf8Path, repo: &str, ref_: &str) -> Result<EcoString> {
1363 let revisions = [
1364 format!("{ref_}^{{commit}}"),
1365 format!("origin/{ref_}^{{commit}}"),
1366 ];
1367
1368 for revision in revisions {
1369 let result = git_command(clone_path)
1370 .arg("rev-parse")
1371 .arg("--verify")
1372 .arg("--quiet")
1373 .arg(&revision)
1374 .output();
1375
1376 match result {
1377 // A failed rev-parse is expected when the ref form doesn't match
1378 // this pattern, so try the next one.
1379 Ok(output) if !output.status.success() => (),
1380 Ok(output) => return Ok(git_stdout(output)),
1381 Err(error) => return Err(git_io_error(error)),
1382 }
1383 }
1384
1385 Err(Error::ShellCommand {
1386 program: "git".into(),
1387 reason: ShellCommandFailureReason::ShellCommandError(format!(
1388 "Unable to resolve git ref `{ref_}` for repository `{repo}`\n"
1389 )),
1390 })
1391}
1392
1393/// Resolves a subdirectory within a cloned git repository, ensuring that the
1394/// resolved directory (after following any symlinks) is still inside the
1395/// repository checkout. Returns `None` if the directory does not exist or
1396/// escapes the checkout.
1397fn resolve_git_subdir(clone_path: &Utf8Path, subdir: &Utf8Path) -> Option<Utf8PathBuf> {
1398 let subdir_source = clone_path.join(subdir);
1399 if !subdir_source.is_dir() {
1400 return None;
1401 }
1402
1403 let canonical_clone = fs::canonicalise(clone_path).ok()?;
1404 let canonical_subdir = fs::canonicalise(&subdir_source).ok()?;
1405 if !canonical_subdir.starts_with(&canonical_clone) {
1406 return None;
1407 }
1408
1409 Some(canonical_subdir)
1410}
1411
1412/// Provide a package from a git repository
1413fn provide_git_package(
1414 package_name: EcoString,
1415 repo: &str,
1416 // A git ref, such as a branch name, commit hash or tag name
1417 ref_: &str,
1418 path: Option<Utf8PathBuf>,
1419 project_paths: &ProjectPaths,
1420 provided: &mut HashMap<EcoString, ProvidedPackage>,
1421 parents: &mut Vec<EcoString>,
1422) -> Result<hexpm::version::Range> {
1423 let checkout = download_git_package(&package_name, repo, ref_, path.as_deref(), project_paths)?;
1424 let (commit, staging_path) = match &checkout {
1425 GitCheckout::InPlace { commit } => (commit, None),
1426 GitCheckout::Staged {
1427 commit,
1428 staging_path,
1429 } => (commit, Some(staging_path)),
1430 };
1431
1432 // Use the package's location in the staging worktree, where its gleam.toml
1433 // lives, not build/packages/. A `../sibling` path dep is resolved next to
1434 // that gleam.toml, and the sibling is only present in the worktree.
1435 let (package_path, repo_root) = match (&path, staging_path) {
1436 (Some(subdir), Some(staging_path)) => {
1437 let repo_root = fs::canonicalise(staging_path)?;
1438 (fs::canonicalise(&staging_path.join(subdir))?, repo_root)
1439 }
1440 _ => {
1441 let package_path =
1442 fs::canonicalise(&project_paths.build_packages_package(&package_name))?;
1443 (package_path.clone(), package_path)
1444 }
1445 };
1446
1447 let version = provide_package(
1448 package_name,
1449 package_path,
1450 SourceContext::Git {
1451 repo: repo.into(),
1452 commit: commit.clone(),
1453 path,
1454 repo_root: &repo_root,
1455 },
1456 project_paths,
1457 provided,
1458 parents,
1459 )?;
1460
1461 checkout.cleanup()?;
1462 Ok(version)
1463}
1464
1465/// Adds a gleam project located at a specific path to the list of "provided packages"
1466fn provide_package(
1467 package_name: EcoString,
1468 package_path: Utf8PathBuf,
1469 source: SourceContext<'_>,
1470 project_paths: &ProjectPaths,
1471 provided: &mut HashMap<EcoString, ProvidedPackage>,
1472 parents: &mut Vec<EcoString>,
1473) -> Result<hexpm::version::Range> {
1474 let package_source = source.to_provided_source();
1475
1476 // Return early if a package cycle is detected
1477 if parents.contains(&package_name) {
1478 let mut last_cycle = parents
1479 .split(|p| p == &package_name)
1480 .next_back()
1481 .unwrap_or_default()
1482 .to_vec();
1483 last_cycle.push(package_name);
1484 return Err(Error::PackageCycle {
1485 packages: last_cycle,
1486 });
1487 }
1488 // Check that we do not have a cached version of this package already
1489 match provided.get(&package_name) {
1490 Some(package) if package.source == package_source => {
1491 // This package has already been provided from this source, return the version
1492 let version = hexpm::version::Range::new(format!("== {}", package.version))
1493 .expect("== {version} should be a valid range");
1494 return Ok(version);
1495 }
1496 Some(package) => {
1497 // This package has already been provided from a different source which conflicts
1498 return Err(Error::ProvidedDependencyConflict {
1499 package: package_name.into(),
1500 source_1: package_source.to_toml(),
1501 source_2: package.source.to_toml(),
1502 });
1503 }
1504 None => (),
1505 }
1506 // Load the package
1507 let config = crate::config::read(package_path.join("gleam.toml"))?;
1508 // Check that we are loading the correct project
1509 if config.name != package_name {
1510 return Err(Error::WrongDependencyProvided {
1511 expected: package_name.into(),
1512 path: package_path.to_path_buf(),
1513 found: config.name.into(),
1514 });
1515 };
1516 // Walk the requirements of the package
1517 let mut requirements = HashMap::new();
1518 parents.push(package_name);
1519 for (name, requirement) in config.dependencies.into_iter() {
1520 let version = match requirement {
1521 Requirement::Hex { version } => version,
1522 Requirement::Path { path } => match &source {
1523 // A path dependency of a git package points to another
1524 // package within the same repository, so lock it as a git
1525 // source to keep the manifest portable.
1526 SourceContext::Git {
1527 repo,
1528 commit,
1529 repo_root,
1530 ..
1531 } => {
1532 let (child_path, child_repo_path) =
1533 resolve_git_path_package(&name, &path, repo, &package_path, repo_root)?;
1534 // A path dependency resolving to the repository root has an
1535 // empty repo-relative path. Normalise it to `None` so it
1536 // matches a package declared directly as a git dependency
1537 // with no path.
1538 let child_repo_path = if child_repo_path.as_str().is_empty() {
1539 None
1540 } else {
1541 Some(child_repo_path)
1542 };
1543
1544 provide_package(
1545 name.clone(),
1546 child_path,
1547 SourceContext::Git {
1548 repo: repo.clone(),
1549 commit: commit.clone(),
1550 path: child_repo_path,
1551 repo_root,
1552 },
1553 project_paths,
1554 provided,
1555 parents,
1556 )?
1557 }
1558 SourceContext::Local { .. } => {
1559 // Recursively walk local packages
1560 provide_local_package(
1561 name.clone(),
1562 &path,
1563 &package_path,
1564 project_paths,
1565 provided,
1566 parents,
1567 )?
1568 }
1569 },
1570 Requirement::Git { git, ref_, path } => provide_git_package(
1571 name.clone(),
1572 &git,
1573 &ref_,
1574 path,
1575 project_paths,
1576 provided,
1577 parents,
1578 )?,
1579 };
1580 let _ = requirements.insert(name, version);
1581 }
1582 let _ = parents.pop();
1583 // Add the package to the provided packages dictionary
1584 let version = hexpm::version::Range::new(format!("== {}", config.version))
1585 .expect("== {version} should be a valid range");
1586 let _ = provided.insert(
1587 config.name,
1588 ProvidedPackage {
1589 version: config.version,
1590 source: package_source,
1591 requirements,
1592 },
1593 );
1594 // Return the version
1595 Ok(version)
1596}
1597
1598/// Unlocks specified packages and their unique dependencies.
1599///
1600/// If a manifest is provided, it also unlocks indirect dependencies that are
1601/// not required by any other package or the root project.
1602pub fn unlock_packages(
1603 locked: &mut HashMap<EcoString, Version>,
1604 packages_to_unlock: &[EcoString],
1605 manifest: Option<&Manifest>,
1606) -> Result<()> {
1607 if let Some(manifest) = manifest {
1608 let mut packages_to_unlock: Vec<EcoString> = packages_to_unlock.to_vec();
1609
1610 while let Some(package_name) = packages_to_unlock.pop() {
1611 if locked.remove(&package_name).is_some()
1612 && let Some(package) = manifest.packages.iter().find(|p| p.name == package_name)
1613 {
1614 let deps_to_unlock = find_deps_to_unlock(package, locked, manifest);
1615 packages_to_unlock.extend(deps_to_unlock);
1616 }
1617 }
1618 } else {
1619 for package_name in packages_to_unlock {
1620 let _ = locked.remove(package_name);
1621 }
1622 }
1623
1624 Ok(())
1625}
1626
1627/// Identifies which dependencies of a package should be unlocked.
1628///
1629/// A dependency is eligible for unlocking if it is currently locked,
1630/// is not a root dependency, and is not required by any locked package.
1631fn find_deps_to_unlock(
1632 package: &ManifestPackage,
1633 locked: &HashMap<EcoString, Version>,
1634 manifest: &Manifest,
1635) -> Vec<EcoString> {
1636 package
1637 .requirements
1638 .iter()
1639 .filter(|&dep| {
1640 locked.contains_key(dep)
1641 && !manifest.requirements.contains_key(dep)
1642 && manifest
1643 .packages
1644 .iter()
1645 .all(|p| !locked.contains_key(&p.name) || !p.requirements.contains(dep))
1646 })
1647 .cloned()
1648 .collect()
1649}
1650
1651/// Determine the information to add to the manifest for a specific package
1652async fn lookup_package(
1653 name: String,
1654 version: Version,
1655 provided: &HashMap<EcoString, ProvidedPackage>,
1656 credentials: Option<&hexpm::Credentials>,
1657) -> Result<ManifestPackage> {
1658 match provided.get(name.as_str()) {
1659 Some(provided_package) => Ok(provided_package.to_manifest_package(name.as_str())),
1660 None => {
1661 let config = hexpm::Config::new();
1662 let release =
1663 hex::get_package_release(&name, &version, credentials, &config, &HttpClient::new())
1664 .await?;
1665 let build_tools = release
1666 .meta
1667 .build_tools
1668 .iter()
1669 .map(|s| EcoString::from(s.as_str()))
1670 .collect_vec();
1671 let requirements = release
1672 .requirements
1673 .keys()
1674 .map(|s| EcoString::from(s.as_str()))
1675 .collect_vec();
1676 Ok(ManifestPackage {
1677 name: name.into(),
1678 version,
1679 otp_app: Some(release.meta.app.into()),
1680 build_tools,
1681 requirements,
1682 source: ManifestPackageSource::Hex {
1683 outer_checksum: Base16Checksum(release.outer_checksum),
1684 },
1685 })
1686 }
1687 }
1688}
1689
1690struct PackageFetcher {
1691 runtime_cache: RefCell<HashMap<String, Rc<hexpm::Package>>>,
1692 runtime: tokio::runtime::Handle,
1693 http: HttpClient,
1694 credentials: Option<hexpm::Credentials>,
1695}
1696
1697impl PackageFetcher {
1698 pub fn new(runtime: tokio::runtime::Handle) -> Self {
1699 Self {
1700 runtime_cache: RefCell::new(HashMap::new()),
1701 runtime,
1702 http: HttpClient::new(),
1703 credentials: crate::hex::read_env_readonly_api_key(),
1704 }
1705 }
1706
1707 /// Caches the result of `get_dependencies` so that we don't need to make a network request.
1708 /// Currently dependencies are fetched during initial version resolution, and then during check
1709 /// for major version availability.
1710 fn cache_package(&self, package: &str, result: Rc<hexpm::Package>) {
1711 let mut runtime_cache = self.runtime_cache.borrow_mut();
1712 let _ = runtime_cache.insert(package.to_string(), result);
1713 }
1714}
1715
1716#[derive(Debug)]
1717pub struct Untar;
1718
1719impl Untar {
1720 pub fn boxed() -> Box<Self> {
1721 Box::new(Self)
1722 }
1723}
1724
1725impl TarUnpacker for Untar {
1726 fn io_result_entries<'a>(
1727 &self,
1728 archive: &'a mut tar::Archive<WrappedReader>,
1729 ) -> std::io::Result<tar::Entries<'a, WrappedReader>> {
1730 archive.entries()
1731 }
1732
1733 fn io_result_unpack(
1734 &self,
1735 path: &Utf8Path,
1736 mut archive: tar::Archive<GzDecoder<tar::Entry<'_, WrappedReader>>>,
1737 ) -> std::io::Result<()> {
1738 archive.unpack(path)
1739 }
1740}
1741
1742impl dependency::PackageFetcher for PackageFetcher {
1743 fn get_dependencies(&self, package: &str) -> Result<Rc<hexpm::Package>, PackageFetchError> {
1744 {
1745 let runtime_cache = self.runtime_cache.borrow();
1746 let result = runtime_cache.get(package);
1747
1748 if let Some(result) = result {
1749 return Ok(result.clone());
1750 }
1751 }
1752
1753 tracing::debug!(package = package, "looking_up_hex_package");
1754 let config = hexpm::Config::new();
1755 let request =
1756 hexpm::repository_v2_get_package_request(package, self.credentials.as_ref(), &config);
1757 let response = self
1758 .runtime
1759 .block_on(self.http.send(request))
1760 .map_err(PackageFetchError::fetch_error)?;
1761
1762 let pkg = hexpm::repository_v2_get_package_response(response, HEXPM_PUBLIC_KEY)
1763 .map_err(|e| PackageFetchError::from_api_error(e, package))?;
1764 let pkg = Rc::new(pkg);
1765 let pkg_ref = Rc::clone(&pkg);
1766 self.cache_package(package, pkg);
1767 Ok(pkg_ref)
1768 }
1769}