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