Skip to main content

lux_lib/pipeline/
resolve.rs

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
32/// The build dependencies of a rockspec that still need to be installed, with the
33/// LuaRocks build backend (if any) first.
34pub(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
56/// The name of the luarocks build backend rock required to build this rockspec (if any).
57pub(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/// Identifies a single discovery request. Two requests with the same key resolve to the
145/// same package, so the diamond problem is solved by only discovering each key once.
146#[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
165/// A discovered node in the dependency graph.
166struct 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            // Mark the section as requested even if it resolves to nothing.
233            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    /// Registers a request for discovery, unless it has already been seen.
254    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    /// Spawns queued discoveries up to the configured concurrency limit.
261    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    /// Records a discovered package and queues any children it introduces.
280    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(&section)?
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    /// Computes the final specs and assembles the resolved packages now that discovery is
368    /// complete and every child id is known.
369    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
437/// Adds a child request unless an equivalent request is already present.
438fn 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
458/// Graph colouring algorithm for cycle detection.
459fn detect_cycles(nodes: &HashMap<RequestKey, Node>) -> Result<(), ResolveError> {
460    #[derive(Clone, Copy, PartialEq, Eq)]
461    enum Colour {
462        /// Not yet visited
463        White,
464        /// On path: revisiting it indicates a cycle
465        Grey,
466        /// Fully explored
467        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}