1use std::collections::{HashMap, HashSet, VecDeque};
2use std::sync::Arc;
3
4use bon::Builder;
5use itertools::Itertools;
6use miette::Diagnostic;
7use thiserror::Error;
8use tokio::task::JoinSet;
9
10use crate::{
11 build::BuildBehaviour,
12 config::Config,
13 lockfile::{
14 LockedPackage, LockedPackageId, LockedPackageLockType, LockedPackageSpec, OptState,
15 PinnedState, RemotePackageSourceUrl,
16 },
17 lua_rockspec::{BuildBackendSpec, RemoteLuaRockspec},
18 operations::PackageInstallSpec,
19 package::{PackageName, PackageReq},
20 package_db::PackageDB,
21 remote_package_source::RemotePackageSource,
22 rockspec::Rockspec,
23 tree::EntryType,
24};
25
26use super::{
27 discover::{Discover, DiscoverError, FoundPackage},
28 materialise::PackageSource,
29 Artifacts,
30};
31
32pub(crate) fn build_dependencies_to_install<R: Rockspec>(rockspec: &R) -> Vec<PackageName> {
35 let mut names = rockspec
36 .build_dependencies()
37 .current_platform()
38 .iter()
39 .filter(|dep| {
40 !matches!(
41 dep.name().to_string().as_str(),
42 "luarocks-build-rust-mlua"
43 | "luarocks-build-rust-binary"
44 | "luarocks-build-treesitter-parser"
45 )
46 })
47 .map(|dep| dep.name().clone())
48 .collect_vec();
49
50 if let Some(backend) = luarocks_build_backend_name(rockspec) {
51 names.insert(0, backend);
52 }
53 names
54}
55
56pub(crate) fn luarocks_build_backend_name<R: Rockspec>(rockspec: &R) -> Option<PackageName> {
58 match &rockspec.build().current_platform().build_backend {
59 Some(BuildBackendSpec::LuaRock(backend)) => {
60 Some(PackageName::new(format!("luarocks-build-{backend}")))
61 }
62 _ => None,
63 }
64}
65
66pub(crate) struct ResolvedPackage {
67 pub(crate) spec: LockedPackageSpec,
68 pub(crate) rockspec: RemoteLuaRockspec,
69 pub(crate) source: RemotePackageSource,
70 pub(crate) source_url: Option<RemotePackageSourceUrl>,
71 pub(crate) entry_type: EntryType,
72 pub(crate) build_behaviour: crate::build::BuildBehaviour,
73 pub(crate) artifact: Option<PackageSource>,
74}
75
76pub(crate) type ResolvedArtifacts = Artifacts<HashMap<LockedPackageId, ResolvedPackage>>;
77
78#[derive(Error, Debug, Diagnostic)]
79#[non_exhaustive]
80pub enum ResolveError {
81 #[error(transparent)]
82 #[diagnostic(transparent)]
83 Discover(#[from] Box<DiscoverError>),
84 #[error("cyclic dependency detected:\n{0}")]
85 CyclicDependency(String),
86 #[error(transparent)]
87 Join(#[from] tokio::task::JoinError),
88}
89
90impl From<DiscoverError> for ResolveError {
91 fn from(err: DiscoverError) -> Self {
92 Self::Discover(Box::new(err))
93 }
94}
95
96#[derive(Builder)]
97#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
98pub(crate) struct ResolvePackageDependencies<'a> {
99 #[builder(start_fn)]
100 pub(crate) package_db: PackageDB,
101 #[builder(start_fn)]
102 pub(crate) config: &'a Config,
103 #[builder(field)]
104 pub(crate) packages: Artifacts<Vec<PackageInstallSpec>>,
105 #[builder(default)]
106 pub(crate) installed: HashMap<LockedPackageLockType, Vec<LockedPackage>>,
107}
108
109impl<State> ResolvePackageDependenciesBuilder<'_, State>
110where
111 State: resolve_package_dependencies_builder::State,
112{
113 fn set(mut self, section: LockedPackageLockType, packages: Vec<PackageInstallSpec>) -> Self {
114 *self.packages.get_mut(section) = Some(packages);
115 self
116 }
117
118 pub(crate) fn packages(self, packages: Vec<PackageInstallSpec>) -> Self {
119 self.set(LockedPackageLockType::Regular, packages)
120 }
121
122 pub(crate) fn build_packages(self, packages: Vec<PackageInstallSpec>) -> Self {
123 self.set(LockedPackageLockType::Build, packages)
124 }
125
126 pub(crate) fn test_packages(self, packages: Vec<PackageInstallSpec>) -> Self {
127 self.set(LockedPackageLockType::Test, packages)
128 }
129}
130
131impl<State> ResolvePackageDependenciesBuilder<'_, State>
132where
133 State: resolve_package_dependencies_builder::State
134 + resolve_package_dependencies_builder::IsComplete,
135{
136 pub(crate) async fn resolve(self) -> Result<ResolvedArtifacts, ResolveError> {
137 let args = self._build();
138 Resolver::new(args.package_db, args.config, args.installed)
139 .run(args.packages)
140 .await
141 }
142}
143
144#[derive(Clone, PartialEq, Eq, Hash)]
147struct RequestKey {
148 section: LockedPackageLockType,
149 package: PackageReq,
150 pin: PinnedState,
151 opt: OptState,
152}
153
154impl RequestKey {
155 fn from_install_spec(section: LockedPackageLockType, spec: &PackageInstallSpec) -> Self {
156 Self {
157 section,
158 package: spec.package.clone(),
159 pin: spec.pin,
160 opt: spec.opt,
161 }
162 }
163}
164
165struct Node {
167 install_spec: PackageInstallSpec,
168 id: LockedPackageId,
169 found: FoundPackage,
170 children: Vec<(bool, RequestKey)>,
171}
172
173struct Resolver {
174 package_db: PackageDB,
175 config: Arc<Config>,
176 max_concurrent: usize,
177 joinset: JoinSet<(
178 RequestKey,
179 PackageInstallSpec,
180 Result<FoundPackage, ResolveError>,
181 )>,
182 pending: VecDeque<(RequestKey, PackageInstallSpec)>,
183 seen: HashSet<RequestKey>,
184 nodes: HashMap<RequestKey, Node>,
185 resolved: ResolvedArtifacts,
186 installed: HashMap<LockedPackageLockType, Vec<LockedPackage>>,
187 installed_ids: HashMap<RequestKey, LockedPackageId>,
188}
189
190impl Resolver {
191 fn new(
192 package_db: PackageDB,
193 config: &Config,
194 installed: HashMap<LockedPackageLockType, Vec<LockedPackage>>,
195 ) -> Self {
196 Self::with_max_concurrent(
197 package_db,
198 Arc::new(config.clone()),
199 config.max_jobs(),
200 installed,
201 )
202 }
203
204 fn with_max_concurrent(
205 package_db: PackageDB,
206 config: Arc<Config>,
207 max_concurrent: usize,
208 installed: HashMap<LockedPackageLockType, Vec<LockedPackage>>,
209 ) -> Self {
210 Self {
211 package_db,
212 config,
213 max_concurrent,
214 joinset: JoinSet::new(),
215 pending: VecDeque::new(),
216 seen: HashSet::new(),
217 nodes: HashMap::new(),
218 resolved: ResolvedArtifacts::default(),
219 installed,
220 installed_ids: HashMap::new(),
221 }
222 }
223
224 async fn run(
225 mut self,
226 packages: Artifacts<Vec<PackageInstallSpec>>,
227 ) -> Result<ResolvedArtifacts, ResolveError> {
228 for (section, specs) in packages {
229 let Some(specs) = specs else {
230 continue;
231 };
232 self.resolved
234 .get_mut(section)
235 .get_or_insert_with(HashMap::new);
236 for spec in specs {
237 let key = RequestKey::from_install_spec(section, &spec);
238 self.enqueue(key, spec);
239 }
240 }
241
242 self.spawn_ready();
243 while let Some(joined) = self.joinset.join_next().await {
244 let (key, spec, result) = joined.map_err(ResolveError::Join)?;
245 self.on_discovered(key, spec, result?);
246 self.spawn_ready();
247 }
248
249 detect_cycles(&self.nodes)?;
250 Ok(self.assemble())
251 }
252
253 fn enqueue(&mut self, key: RequestKey, spec: PackageInstallSpec) {
255 if self.seen.insert(key.clone()) {
256 self.pending.push_back((key, spec));
257 }
258 }
259
260 fn spawn_ready(&mut self) {
262 while self.joinset.len() < self.max_concurrent {
263 let Some((key, spec)) = self.pending.pop_front() else {
264 break;
265 };
266 let package_db = self.package_db.clone();
267 let config = Arc::clone(&self.config);
268 let task_spec = spec.clone();
269 self.joinset.spawn(async move {
270 let result = Discover::new(package_db, &config)
271 .discover(&task_spec)
272 .await
273 .map_err(|err| err.into());
274 (key, spec, result)
275 });
276 }
277 }
278
279 fn on_discovered(&mut self, key: RequestKey, spec: PackageInstallSpec, found: FoundPackage) {
281 let section = key.section;
282 let is_binary = matches!(
283 found.remote.source,
284 RemotePackageSource::LuarocksBinaryRock(_)
285 );
286 let constraint = spec
287 .constraint
288 .clone()
289 .unwrap_or_else(|| spec.package.version_req().clone().into());
290 let id = LockedPackageId::new(
291 found.remote.spec.name(),
292 found.remote.spec.version(),
293 spec.pin,
294 spec.opt,
295 constraint,
296 );
297
298 let mut child_specs: Vec<(bool, RequestKey, PackageInstallSpec)> = Vec::new();
299 if !is_binary {
300 for child_spec in build_dependency_specs(&found.rockspec, &spec) {
301 push_child(&mut child_specs, true, section, child_spec);
302 }
303 }
304 for dependency in found.rockspec.dependencies().current_platform() {
305 let child_spec = PackageInstallSpec::new(
306 dependency.package_req().clone(),
307 EntryType::DependencyOnly,
308 )
309 .build_behaviour(BuildBehaviour::Ignore)
310 .pin(spec.pin)
311 .opt(spec.opt)
312 .maybe_source(dependency.source().clone())
313 .build();
314 push_child(&mut child_specs, false, section, child_spec);
315 }
316
317 let children = child_specs
318 .iter()
319 .map(|(is_build, child_key, _)| (*is_build, child_key.clone()))
320 .collect();
321 self.resolved
322 .get_mut(section)
323 .get_or_insert_with(HashMap::new);
324 self.nodes.insert(
325 key,
326 Node {
327 install_spec: spec,
328 id,
329 found,
330 children,
331 },
332 );
333
334 for (is_build, child_key, child_spec) in child_specs {
335 let child_section = if is_build {
336 LockedPackageLockType::Build
337 } else {
338 section
339 };
340 if let Some(installed_id) = self.installed_match(child_section, &child_spec) {
341 self.installed_ids.insert(child_key, installed_id);
342 } else {
343 self.enqueue(child_key, child_spec);
344 }
345 }
346 }
347
348 fn installed_match(
349 &self,
350 section: LockedPackageLockType,
351 spec: &PackageInstallSpec,
352 ) -> Option<LockedPackageId> {
353 if !matches!(spec.build_behaviour, BuildBehaviour::Ignore) || spec.source.is_some() {
354 return None;
355 }
356 self.installed
357 .get(§ion)?
358 .iter()
359 .rev()
360 .find(|package| {
361 package.name() == spec.package.name()
362 && spec.package.version_req().matches(package.version())
363 })
364 .map(|package| package.id())
365 }
366
367 fn assemble(self) -> ResolvedArtifacts {
370 let Resolver {
371 nodes,
372 mut resolved,
373 installed_ids,
374 ..
375 } = self;
376
377 let mut ids: HashMap<RequestKey, LockedPackageId> = nodes
378 .iter()
379 .map(|(key, node)| (key.clone(), node.id.clone()))
380 .collect();
381 for (key, id) in installed_ids {
382 ids.entry(key).or_insert(id);
383 }
384
385 for (key, node) in nodes {
386 let Node {
387 install_spec,
388 id,
389 found,
390 children,
391 } = node;
392 let dependencies = children
393 .iter()
394 .filter(|(is_build, _)| !is_build)
395 .map(|(_, child)| ids[child].clone())
396 .collect_vec();
397 let build_dependencies = children
398 .iter()
399 .filter(|(is_build, _)| *is_build)
400 .map(|(_, child)| ids[child].clone())
401 .collect_vec();
402 let constraint = install_spec
403 .constraint
404 .clone()
405 .unwrap_or_else(|| install_spec.package.version_req().clone().into());
406
407 let spec = LockedPackageSpec::new(
408 found.remote.spec.name(),
409 found.remote.spec.version(),
410 constraint,
411 dependencies,
412 build_dependencies,
413 &install_spec.pin,
414 &install_spec.opt,
415 );
416 let package = ResolvedPackage {
417 spec,
418 rockspec: found.rockspec,
419 source: found.remote.source,
420 source_url: found.remote.source_url,
421 entry_type: install_spec.entry_type,
422 build_behaviour: install_spec.build_behaviour,
423 artifact: found.artifact,
424 };
425
426 resolved
427 .get_mut(key.section)
428 .get_or_insert_with(HashMap::new)
429 .entry(id)
430 .or_insert(package);
431 }
432
433 resolved
434 }
435}
436
437fn push_child(
439 children: &mut Vec<(bool, RequestKey, PackageInstallSpec)>,
440 is_build: bool,
441 section: LockedPackageLockType,
442 spec: PackageInstallSpec,
443) {
444 let child_section = if is_build {
445 LockedPackageLockType::Build
446 } else {
447 section
448 };
449 let child_key = RequestKey::from_install_spec(child_section, &spec);
450 if !children
451 .iter()
452 .any(|(_, existing, _)| existing == &child_key)
453 {
454 children.push((is_build, child_key, spec));
455 }
456}
457
458fn detect_cycles(nodes: &HashMap<RequestKey, Node>) -> Result<(), ResolveError> {
460 #[derive(Clone, Copy, PartialEq, Eq)]
461 enum Colour {
462 White,
464 Grey,
466 Black,
468 }
469
470 let mut colors: HashMap<&RequestKey, Colour> = HashMap::with_capacity(nodes.len());
471
472 for start in nodes.keys() {
473 if colors.get(start).copied().unwrap_or(Colour::White) != Colour::White {
474 continue;
475 }
476 colors.insert(start, Colour::Grey);
477 let mut stack: Vec<(&RequestKey, usize)> = vec![(start, 0)];
478 let mut path: Vec<&RequestKey> = vec![start];
479
480 while let Some(&(node, index)) = stack.last() {
481 let children = &nodes[node].children;
482 if index < children.len() {
483 if let Some(top) = stack.last_mut() {
484 top.1 += 1;
485 }
486 let child = &children[index].1;
487 if !nodes.contains_key(child) {
488 continue;
489 }
490 match colors.get(child).copied().unwrap_or(Colour::White) {
491 Colour::White => {
492 colors.insert(child, Colour::Grey);
493 path.push(child);
494 stack.push((child, 0));
495 }
496 Colour::Grey => {
497 let Some(position) = path.iter().position(|key| *key == child) else {
498 continue;
499 };
500 let chain = path[position..]
501 .iter()
502 .map(|key| key.package.name().to_string())
503 .chain(std::iter::once(child.package.name().to_string()))
504 .collect_vec()
505 .join(" -> ");
506 return Err(ResolveError::CyclicDependency(chain));
507 }
508 Colour::Black => {}
509 }
510 } else {
511 colors.insert(node, Colour::Black);
512 stack.pop();
513 path.pop();
514 }
515 }
516 }
517
518 Ok(())
519}
520
521fn build_dependency_specs(
522 rockspec: &RemoteLuaRockspec,
523 parent: &PackageInstallSpec,
524) -> Vec<PackageInstallSpec> {
525 build_dependencies_to_install(rockspec)
526 .into_iter()
527 .filter_map(|name| {
528 rockspec
529 .build_dependencies()
530 .current_platform()
531 .iter()
532 .find(|dep| dep.name() == &name)
533 .map(|dep| {
534 PackageInstallSpec::new(dep.package_req().clone(), EntryType::Entrypoint)
535 .build_behaviour(BuildBehaviour::Ignore)
536 .pin(parent.pin)
537 .opt(parent.opt)
538 .maybe_source(dep.source().clone())
539 .build()
540 })
541 .or_else(|| {
542 Some(
543 PackageInstallSpec::new(PackageReq::from(name), EntryType::Entrypoint)
544 .build_behaviour(BuildBehaviour::Ignore)
545 .pin(parent.pin)
546 .opt(parent.opt)
547 .build(),
548 )
549 })
550 })
551 .collect()
552}
553
554#[cfg(test)]
555mod tests {
556 use std::sync::Arc;
557
558 use super::*;
559 use crate::config::ConfigBuilder;
560 use crate::lockfile::{LockConstraint, LockedPackageHashes};
561 use crate::package::{PackageSpec, RemotePackage};
562
563 fn rockspec(package: &str, dependencies: &[&str]) -> String {
564 let dependencies = if dependencies.is_empty() {
565 String::new()
566 } else {
567 let list = dependencies
568 .iter()
569 .map(|dependency| format!("'{dependency}'"))
570 .collect::<Vec<_>>()
571 .join(", ");
572 format!("\ndependencies = {{ {list} }}")
573 };
574 format!(
575 "rockspec_format = '1.0'\npackage = '{package}'\nversion = '1.0.0-1'\nsource = {{ url = 'https://example.com/{package}.zip' }}{dependencies}"
576 )
577 }
578
579 fn local_packages(rockspecs: &[String]) -> PackageDB {
580 PackageDB::from_local(
581 rockspecs
582 .iter()
583 .map(|content| {
584 let rockspec = match RemoteLuaRockspec::new(content) {
585 Ok(rockspec) => rockspec,
586 Err(err) => unreachable!("invalid test rockspec: {err}"),
587 };
588 FoundPackage {
589 remote: RemotePackage::new(
590 PackageSpec::new(
591 rockspec.package().clone(),
592 rockspec.version().clone(),
593 ),
594 RemotePackageSource::RockspecContent(content.clone()),
595 None,
596 ),
597 rockspec,
598 artifact: None,
599 }
600 })
601 .collect(),
602 )
603 }
604
605 fn install_spec(name: &str) -> PackageInstallSpec {
606 let package = match name.parse::<PackageReq>() {
607 Ok(package) => package,
608 Err(err) => unreachable!("invalid package req: {err}"),
609 };
610 PackageInstallSpec::new(package, EntryType::Entrypoint)
611 .build_behaviour(BuildBehaviour::Ignore)
612 .build()
613 }
614
615 fn installed_package(name: &str) -> LockedPackage {
616 let spec = PackageSpec::parse(name.to_string(), "1.0.0-1".to_string()).unwrap();
617 LockedPackage::from(
618 &spec,
619 LockConstraint::Unconstrained,
620 RemotePackageSource::Test,
621 None,
622 LockedPackageHashes {
623 rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
624 .parse()
625 .unwrap(),
626 source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
627 .parse()
628 .unwrap(),
629 },
630 )
631 }
632
633 fn roots(spec: PackageInstallSpec) -> Artifacts<Vec<PackageInstallSpec>> {
634 Artifacts {
635 regular: Some(vec![spec]),
636 build: None,
637 test: None,
638 }
639 }
640
641 fn test_config() -> Arc<Config> {
642 let builder = match ConfigBuilder::new() {
643 Ok(builder) => builder,
644 Err(err) => unreachable!("invalid test config: {err}"),
645 };
646 let config = match builder.build() {
647 Ok(config) => config,
648 Err(err) => unreachable!("invalid test config: {err}"),
649 };
650 Arc::new(config)
651 }
652
653 #[tokio::test]
654 async fn diamond_problem() {
655 let packages = local_packages(&[
656 rockspec("a", &["b", "c"]),
657 rockspec("b", &["d"]),
658 rockspec("c", &["d"]),
659 rockspec("d", &[]),
660 ]);
661
662 let resolved =
663 match Resolver::with_max_concurrent(packages, test_config(), 8, HashMap::new())
664 .run(roots(install_spec("a")))
665 .await
666 {
667 Ok(resolved) => resolved,
668 Err(err) => panic!("resolution failed: {err}"),
669 };
670
671 assert_eq!(resolved.regular.as_ref().map(|rocks| rocks.len()), Some(4));
672 }
673
674 #[tokio::test]
675 async fn installed_dependency_is_reused() {
676 let packages = local_packages(&[rockspec("a", &["d"])]);
677 let installed =
678 HashMap::from([(LockedPackageLockType::Regular, vec![installed_package("d")])]);
679
680 let resolved = Resolver::with_max_concurrent(packages, test_config(), 8, installed)
681 .run(roots(install_spec("a")))
682 .await
683 .unwrap();
684
685 assert_eq!(resolved.regular.as_ref().map(|rocks| rocks.len()), Some(1));
686 }
687
688 #[tokio::test]
689 async fn cyclic_dependencies_are_rejected() {
690 let packages = local_packages(&[rockspec("a", &["b"]), rockspec("b", &["a"])]);
691
692 let result = Resolver::with_max_concurrent(packages, test_config(), 8, HashMap::new())
693 .run(roots(install_spec("a")))
694 .await;
695
696 assert!(matches!(result, Err(ResolveError::CyclicDependency(_))));
697 }
698}