Skip to main content

lux_lib/tree/
mod.rs

1use crate::{
2    build::utils::format_path,
3    config::{tree::RockLayoutConfig, Config, LuaVersion},
4    lockfile::{LocalPackage, LocalPackageId, Lockfile, LockfileError, OptState, ReadOnly},
5    package::PackageReq,
6    variables::{GetVariableError, HasVariables},
7};
8use std::{io, path::PathBuf};
9
10use itertools::Itertools;
11use mlua::{ExternalResult, IntoLua};
12use nonempty::NonEmpty;
13use thiserror::Error;
14
15mod list;
16
17const LOCKFILE_NAME: &str = "lux.lock";
18
19/// A tree is a collection of files where installed rocks are located.
20///
21/// `lux` diverges from the traditional hierarchy employed by luarocks.
22/// Instead, we opt for a much simpler approach:
23///
24/// - /rocks/<lua-version> - contains rocks
25/// - /rocks/<lua-version>/<rock>/etc - documentation and supplementary files for the rock
26/// - /rocks/<lua-version>/<rock>/lib - shared libraries (.so files)
27/// - /rocks/<lua-version>/<rock>/src - library code for the rock
28/// - /bin - binary files produced by various rocks
29
30#[derive(Clone, Debug)]
31pub struct Tree {
32    /// The Lua version of the tree.
33    version: LuaVersion,
34    /// The parent of this tree's root directory.
35    root_parent: PathBuf,
36    /// The rock layout config for this tree
37    entrypoint_layout: RockLayoutConfig,
38    /// The root of this tree's test dependency tree.
39    test_tree_dir: PathBuf,
40    /// The root of this tree's build dependency tree.
41    build_tree_dir: PathBuf,
42}
43
44#[derive(Debug, Error)]
45pub enum TreeError {
46    #[error("unable to create directory {0}:\n{1}")]
47    CreateDir(String, io::Error),
48    #[error("unable to write to {0}:\n{1}")]
49    WriteFile(String, io::Error),
50    #[error(transparent)]
51    Lockfile(#[from] LockfileError),
52}
53
54/// Change-agnostic way of referencing various paths for a rock.
55#[derive(Debug, PartialEq)]
56pub struct RockLayout {
57    /// The local installation directory.
58    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
59    /// using `$(PREFIX)`.
60    pub rock_path: PathBuf,
61    /// The `etc` directory, containing resources.
62    pub etc: PathBuf,
63    /// The `lib` directory, containing native libraries.
64    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
65    /// using `$(LIBDIR)`.
66    pub lib: PathBuf,
67    /// The `src` directory, containing Lua sources.
68    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
69    /// using `$(LUADIR)`.
70    pub src: PathBuf,
71    /// The `bin` directory, containing executables.
72    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
73    /// using `$(BINDIR)`.
74    /// This points to a global binary path at the root of the current tree by default.
75    pub bin: PathBuf,
76    /// The `etc/conf` directory, containing configuration files.
77    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
78    /// using `$(CONFDIR)`.
79    pub conf: PathBuf,
80    /// The `etc/doc` directory, containing documentation files.
81    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
82    /// using `$(DOCDIR)`.
83    pub doc: PathBuf,
84}
85
86impl RockLayout {
87    pub fn rockspec_path(&self) -> PathBuf {
88        self.rock_path.join("package.rockspec")
89    }
90}
91
92impl HasVariables for RockLayout {
93    fn get_variable(&self, var: &str) -> Result<Option<String>, GetVariableError> {
94        Ok(match var {
95            "PREFIX" => Some(format_path(&self.rock_path)),
96            "LIBDIR" => Some(format_path(&self.lib)),
97            "LUADIR" => Some(format_path(&self.src)),
98            "BINDIR" => Some(format_path(&self.bin)),
99            "CONFDIR" => Some(format_path(&self.conf)),
100            "DOCDIR" => Some(format_path(&self.doc)),
101            _ => None,
102        })
103    }
104}
105
106impl mlua::UserData for RockLayout {
107    fn add_fields<F: mlua::UserDataFields<Self>>(fields: &mut F) {
108        fields.add_field_method_get("rock_path", |_, this| Ok(this.rock_path.clone()));
109        fields.add_field_method_get("etc", |_, this| Ok(this.etc.clone()));
110        fields.add_field_method_get("lib", |_, this| Ok(this.lib.clone()));
111        fields.add_field_method_get("src", |_, this| Ok(this.src.clone()));
112        fields.add_field_method_get("bin", |_, this| Ok(this.bin.clone()));
113        fields.add_field_method_get("conf", |_, this| Ok(this.conf.clone()));
114        fields.add_field_method_get("doc", |_, this| Ok(this.doc.clone()));
115    }
116}
117
118impl Tree {
119    /// NOTE: This is exposed for use by the config module.
120    /// Use `Config::tree()`
121    pub(crate) fn new(
122        root: PathBuf,
123        version: LuaVersion,
124        config: &Config,
125    ) -> Result<Self, TreeError> {
126        let version_dir = root.join(version.to_string());
127        let test_tree_dir = version_dir.join("test_dependencies");
128        let build_tree_dir = version_dir.join("build_dependencies");
129        Self::new_with_paths(root, test_tree_dir, build_tree_dir, version, config)
130    }
131
132    fn new_with_paths(
133        root: PathBuf,
134        test_tree_dir: PathBuf,
135        build_tree_dir: PathBuf,
136        version: LuaVersion,
137        config: &Config,
138    ) -> Result<Self, TreeError> {
139        let path_with_version = root.join(version.to_string());
140
141        // Ensure that the root and the version directory exist.
142        std::fs::create_dir_all(&path_with_version).map_err(|err| {
143            TreeError::CreateDir(path_with_version.to_string_lossy().to_string(), err)
144        })?;
145
146        // In case the tree is in a git repository, we tell git to ignore it.
147        let gitignore_file = root.join(".gitignore");
148        std::fs::write(&gitignore_file, "*").map_err(|err| {
149            TreeError::WriteFile(gitignore_file.to_string_lossy().to_string(), err)
150        })?;
151
152        // Ensure that the bin directory exists.
153        let bin_dir = path_with_version.join("bin");
154        std::fs::create_dir_all(&bin_dir)
155            .map_err(|err| TreeError::CreateDir(bin_dir.to_string_lossy().to_string(), err))?;
156
157        let lockfile_path = root.join(LOCKFILE_NAME);
158        let rock_layout_config = if lockfile_path.is_file() {
159            let lockfile = Lockfile::load(lockfile_path, None)?;
160            lockfile.entrypoint_layout
161        } else {
162            config.entrypoint_layout().clone()
163        };
164        Ok(Self {
165            root_parent: root,
166            version,
167            entrypoint_layout: rock_layout_config,
168            test_tree_dir,
169            build_tree_dir,
170        })
171    }
172
173    /// The root of the tree
174    pub fn root(&self) -> PathBuf {
175        self.root_parent.join(self.version.to_string())
176    }
177
178    pub fn version(&self) -> &LuaVersion {
179        &self.version
180    }
181
182    pub fn root_for(&self, package: &LocalPackage) -> PathBuf {
183        self.root().join(format!(
184            "{}-{}@{}",
185            package.id(),
186            package.name(),
187            package.version()
188        ))
189    }
190
191    pub fn bin(&self) -> PathBuf {
192        self.root().join("bin")
193    }
194
195    /// Directory containing unwrapped Lua scripts
196    /// The wrapped scripts are in `Self::bin()`
197    pub(crate) fn unwrapped_bin(&self) -> PathBuf {
198        self.bin().join("unwrapped")
199    }
200
201    pub fn match_rocks(&self, req: &PackageReq) -> Result<RockMatches, TreeError> {
202        let found_packages = self.lockfile()?.find_rocks(req);
203        Ok(match NonEmpty::try_from(found_packages) {
204            Ok(found_packages) => {
205                if found_packages.len() == 1 {
206                    RockMatches::Single(found_packages.last().clone())
207                } else {
208                    RockMatches::Many(found_packages)
209                }
210            }
211            Err(_) => RockMatches::NotFound(req.clone()),
212        })
213    }
214
215    pub fn match_rocks_and<F>(&self, req: &PackageReq, filter: F) -> Result<RockMatches, TreeError>
216    where
217        F: Fn(&LocalPackage) -> bool,
218    {
219        match self.list()?.get(req.name()) {
220            Some(packages) => {
221                let found_packages = packages
222                    .iter()
223                    .rev()
224                    .filter(|package| {
225                        req.version_req().matches(package.version()) && filter(package)
226                    })
227                    .map(|package| package.id())
228                    .collect_vec();
229
230                Ok(match NonEmpty::try_from(found_packages) {
231                    Ok(found_packages) => {
232                        if found_packages.len() == 1 {
233                            RockMatches::Single(found_packages.last().clone())
234                        } else {
235                            RockMatches::Many(found_packages)
236                        }
237                    }
238                    Err(_) => RockMatches::NotFound(req.clone()),
239                })
240            }
241            None => Ok(RockMatches::NotFound(req.clone())),
242        }
243    }
244
245    /// Get the `RockLayout` for an installed package.
246    pub fn installed_rock_layout(&self, package: &LocalPackage) -> Result<RockLayout, TreeError> {
247        let lockfile = self.lockfile()?;
248        if lockfile.is_entrypoint(&package.id()) {
249            Ok(self.entrypoint_layout(package))
250        } else {
251            Ok(self.dependency_layout(package))
252        }
253    }
254
255    /// Create a `RockLayout` for an entrypoint
256    pub fn entrypoint_layout(&self, package: &LocalPackage) -> RockLayout {
257        self.mk_rock_layout(package, &self.entrypoint_layout)
258    }
259
260    /// Create a `RockLayout` for a dependency
261    pub fn dependency_layout(&self, package: &LocalPackage) -> RockLayout {
262        self.mk_rock_layout(package, &RockLayoutConfig::default())
263    }
264
265    /// Create a `RockLayout` for a package.
266    fn mk_rock_layout(
267        &self,
268        package: &LocalPackage,
269        layout_config: &RockLayoutConfig,
270    ) -> RockLayout {
271        let rock_path = self.root_for(package);
272        let bin = self.bin();
273        let etc_root = match layout_config.etc_root {
274            Some(ref etc_root) => self.root().join(etc_root),
275            None => rock_path.clone(),
276        };
277        let mut etc = match package.spec.opt {
278            OptState::Required => etc_root.join(&layout_config.etc),
279            OptState::Optional => etc_root.join(&layout_config.opt_etc),
280        };
281        if layout_config.etc_root.is_some() {
282            etc = etc.join(format!("{}", package.name()));
283        }
284        let lib = rock_path.join("lib");
285        let src = rock_path.join("src");
286        let conf = etc.join(&layout_config.conf);
287        let doc = etc.join(&layout_config.doc);
288
289        RockLayout {
290            rock_path,
291            etc,
292            lib,
293            src,
294            bin,
295            conf,
296            doc,
297        }
298    }
299
300    /// Create a `RockLayout` for an entrypoint package, creating the `lib` and `src` directories.
301    pub fn entrypoint(&self, package: &LocalPackage) -> io::Result<RockLayout> {
302        let rock_layout = self.entrypoint_layout(package);
303        std::fs::create_dir_all(&rock_layout.lib)?;
304        std::fs::create_dir_all(&rock_layout.src)?;
305        Ok(rock_layout)
306    }
307
308    /// Create a `RockLayout` for a dependency package, creating the `lib` and `src` directories.
309    pub fn dependency(&self, package: &LocalPackage) -> io::Result<RockLayout> {
310        let rock_layout = self.dependency_layout(package);
311        std::fs::create_dir_all(&rock_layout.lib)?;
312        std::fs::create_dir_all(&rock_layout.src)?;
313        Ok(rock_layout)
314    }
315
316    pub fn lockfile(&self) -> Result<Lockfile<ReadOnly>, TreeError> {
317        Ok(Lockfile::new(
318            self.lockfile_path(),
319            self.entrypoint_layout.clone(),
320        )?)
321    }
322
323    /// Get this tree's lockfile path.
324    pub fn lockfile_path(&self) -> PathBuf {
325        self.root().join(LOCKFILE_NAME)
326    }
327
328    /// The tree in which to install test dependencies
329    pub fn test_tree(&self, config: &Config) -> Result<Self, TreeError> {
330        let test_tree_dir = self.test_tree_dir.clone();
331        let build_tree_dir = self.build_tree_dir.clone();
332        Self::new_with_paths(
333            test_tree_dir.clone(),
334            test_tree_dir,
335            build_tree_dir,
336            self.version.clone(),
337            config,
338        )
339    }
340
341    /// The tree in which to install build dependencies
342    pub fn build_tree(&self, config: &Config) -> Result<Self, TreeError> {
343        let test_tree_dir = self.test_tree_dir.clone();
344        let build_tree_dir = self.build_tree_dir.clone();
345        Self::new_with_paths(
346            build_tree_dir.clone(),
347            test_tree_dir,
348            build_tree_dir,
349            self.version.clone(),
350            config,
351        )
352    }
353}
354
355impl mlua::UserData for Tree {
356    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
357        methods.add_method("root", |_, this, ()| Ok(this.root()));
358        methods.add_method("root_for", |_, this, package: LocalPackage| {
359            Ok(this.root_for(&package))
360        });
361        methods.add_method("bin", |_, this, ()| Ok(this.bin()));
362        methods.add_method("match_rocks", |_, this, req: PackageReq| {
363            this.match_rocks(&req)
364                .map_err(|err| mlua::Error::RuntimeError(err.to_string()))
365        });
366        methods.add_method(
367            "match_rock_and",
368            |_, this, (req, callback): (PackageReq, mlua::Function)| {
369                this.match_rocks_and(&req, |package: &LocalPackage| {
370                    callback.call(package.clone()).unwrap_or(false)
371                })
372                .into_lua_err()
373            },
374        );
375        methods.add_method("rock_layout", |_, this, package: LocalPackage| {
376            this.installed_rock_layout(&package)
377                .map_err(|err| mlua::Error::RuntimeError(err.to_string()))
378        });
379        methods.add_method("rock", |_, this, package: LocalPackage| {
380            this.dependency(&package).into_lua_err()
381        });
382        methods.add_method("lockfile", |_, this, ()| this.lockfile().into_lua_err());
383    }
384}
385
386#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
387pub enum EntryType {
388    Entrypoint,
389    DependencyOnly,
390}
391
392impl EntryType {
393    pub fn is_entrypoint(&self) -> bool {
394        matches!(self, Self::Entrypoint)
395    }
396}
397
398#[derive(Clone, Debug)]
399pub enum RockMatches {
400    NotFound(PackageReq),
401    Single(LocalPackageId),
402    Many(NonEmpty<LocalPackageId>),
403}
404
405// Loosely mimic the Option<T> functions.
406impl RockMatches {
407    pub fn is_found(&self) -> bool {
408        matches!(self, Self::Single(_) | Self::Many(_))
409    }
410}
411
412impl IntoLua for RockMatches {
413    fn into_lua(self, lua: &mlua::Lua) -> mlua::Result<mlua::Value> {
414        let table = lua.create_table()?;
415        let is_found = self.is_found();
416
417        table.set("is_found", lua.create_function(move |_, ()| Ok(is_found))?)?;
418
419        match self {
420            RockMatches::NotFound(package_req) => table.set("not_found", package_req)?,
421            RockMatches::Single(local_package_id) => table.set("single", local_package_id)?,
422            RockMatches::Many(local_package_ids) => {
423                table.set("many", local_package_ids.into_iter().collect_vec())?
424            }
425        }
426
427        Ok(mlua::Value::Table(table))
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use assert_fs::prelude::PathCopy;
434    use itertools::Itertools;
435    use std::path::PathBuf;
436
437    use insta::assert_yaml_snapshot;
438
439    use crate::{
440        config::{ConfigBuilder, LuaVersion},
441        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
442        package::{PackageName, PackageSpec, PackageVersion},
443        remote_package_source::RemotePackageSource,
444        rockspec::RockBinaries,
445        tree::RockLayout,
446        variables,
447    };
448
449    #[test]
450    fn rock_layout() {
451        let tree_path =
452            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
453
454        let temp = assert_fs::TempDir::new().unwrap();
455        temp.copy_from(&tree_path, &["**"]).unwrap();
456        let tree_path = temp.to_path_buf();
457
458        let config = ConfigBuilder::new()
459            .unwrap()
460            .user_tree(Some(tree_path.clone()))
461            .build()
462            .unwrap();
463        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
464
465        let mock_hashes = LocalPackageHashes {
466            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
467                .parse()
468                .unwrap(),
469            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
470                .parse()
471                .unwrap(),
472        };
473
474        let package = LocalPackage::from(
475            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
476            LockConstraint::Unconstrained,
477            RockBinaries::default(),
478            RemotePackageSource::Test,
479            None,
480            mock_hashes.clone(),
481        );
482
483        let id = package.id();
484
485        let neorg = tree.dependency(&package).unwrap();
486
487        assert_eq!(
488            neorg,
489            RockLayout {
490                bin: tree_path.join("5.1/bin"),
491                rock_path: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1")),
492                etc: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc")),
493                lib: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/lib")),
494                src: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/src")),
495                conf: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc/conf")),
496                doc: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc/doc")),
497            }
498        );
499
500        let package = LocalPackage::from(
501            &PackageSpec::parse("lua-cjson".into(), "2.1.0-1".into()).unwrap(),
502            LockConstraint::Unconstrained,
503            RockBinaries::default(),
504            RemotePackageSource::Test,
505            None,
506            mock_hashes.clone(),
507        );
508
509        let id = package.id();
510
511        let lua_cjson = tree.dependency(&package).unwrap();
512
513        assert_eq!(
514            lua_cjson,
515            RockLayout {
516                bin: tree_path.join("5.1/bin"),
517                rock_path: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1")),
518                etc: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc")),
519                lib: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/lib")),
520                src: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/src")),
521                conf: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc/conf")),
522                doc: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc/doc")),
523            }
524        );
525    }
526
527    #[test]
528    fn tree_list() {
529        let tree_path =
530            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
531
532        let temp = assert_fs::TempDir::new().unwrap();
533        temp.copy_from(&tree_path, &["**"]).unwrap();
534        let tree_path = temp.to_path_buf();
535
536        let config = ConfigBuilder::new()
537            .unwrap()
538            .user_tree(Some(tree_path.clone()))
539            .build()
540            .unwrap();
541        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
542        let result = tree.list().unwrap();
543        // note: sorted_redaction doesn't work because we have a nested Vec
544        let sorted_result: Vec<(PackageName, Vec<PackageVersion>)> = result
545            .into_iter()
546            .sorted()
547            .map(|(name, package)| {
548                (
549                    name,
550                    package
551                        .into_iter()
552                        .map(|package| package.spec.version)
553                        .sorted()
554                        .collect_vec(),
555                )
556            })
557            .collect_vec();
558
559        assert_yaml_snapshot!(sorted_result)
560    }
561
562    #[test]
563    fn rock_layout_substitute() {
564        let tree_path =
565            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
566
567        let temp = assert_fs::TempDir::new().unwrap();
568        temp.copy_from(&tree_path, &["**"]).unwrap();
569        let tree_path = temp.to_path_buf();
570
571        let config = ConfigBuilder::new()
572            .unwrap()
573            .user_tree(Some(tree_path.clone()))
574            .build()
575            .unwrap();
576        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
577
578        let mock_hashes = LocalPackageHashes {
579            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
580                .parse()
581                .unwrap(),
582            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
583                .parse()
584                .unwrap(),
585        };
586
587        let neorg = tree
588            .dependency(&LocalPackage::from(
589                &PackageSpec::parse("neorg".into(), "8.0.0-1-1".into()).unwrap(),
590                LockConstraint::Unconstrained,
591                RockBinaries::default(),
592                RemotePackageSource::Test,
593                None,
594                mock_hashes.clone(),
595            ))
596            .unwrap();
597        let build_variables = vec![
598            "$(PREFIX)",
599            "$(LIBDIR)",
600            "$(LUADIR)",
601            "$(BINDIR)",
602            "$(CONFDIR)",
603            "$(DOCDIR)",
604        ];
605        let result: Vec<String> = build_variables
606            .into_iter()
607            .map(|var| variables::substitute(&[&neorg], var))
608            .try_collect()
609            .unwrap();
610        assert_eq!(
611            result,
612            vec![
613                neorg.rock_path.to_string_lossy().to_string(),
614                neorg.lib.to_string_lossy().to_string(),
615                neorg.src.to_string_lossy().to_string(),
616                neorg.bin.to_string_lossy().to_string(),
617                neorg.conf.to_string_lossy().to_string(),
618                neorg.doc.to_string_lossy().to_string(),
619            ]
620        );
621    }
622}