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] 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/// Identifies a single discovery request. Two requests with the same key resolve to the
139/// same package, so the diamond problem is solved by only discovering each key once.
140#[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
159/// A discovered node in the dependency graph.
160struct 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            // Mark the section as requested even if it resolves to nothing.
227            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    /// Registers a request for discovery, unless it has already been seen.
248    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    /// Spawns queued discoveries up to the configured concurrency limit.
255    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    /// Records a discovered package and queues any children it introduces.
274    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(&section)?
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    /// Computes the final specs and assembles the resolved packages now that discovery is
362    /// complete and every child id is known.
363    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
431/// Adds a child request unless an equivalent request is already present.
432fn 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
452/// Graph colouring algorithm for cycle detection.
453fn detect_cycles(nodes: &HashMap<RequestKey, Node>) -> Result<(), ResolveError> {
454    #[derive(Clone, Copy, PartialEq, Eq)]
455    enum Colour {
456        /// Not yet visited
457        White,
458        /// On path: revisiting it indicates a cycle
459        Grey,
460        /// Fully explored
461        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}