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