Skip to main content

lux_lib/tree/
layout.rs

1use crate::{
2    fs::{self, FsError},
3    lockfile::{LockedPackageSpec, OptState},
4    tree::{InstallTree, Tree},
5};
6use std::path::{Path, PathBuf};
7
8/// A custom layout for entrypoint packages.
9/// Implementations arrange symlinks so external tools can find files at the locations they expect.
10pub trait CustomRockLayout: std::fmt::Debug + Send + Sync {
11    fn make_symlinks(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
12
13    fn remove_symlinks(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
14}
15
16/// A [`CustomRockLayout`] for Neovim plugins. Packages are exposed under `<tree>/site/pack/lux/{start,opt}/<package>`
17#[derive(Clone, Debug, Default)]
18pub struct NvimLayout;
19
20impl NvimLayout {
21    fn target_for(tree: &Tree, package: &LockedPackageSpec) -> PathBuf {
22        let subdir = match package.opt() {
23            OptState::Required => "start",
24            OptState::Optional => "opt",
25        };
26        tree.root()
27            .join("site/pack/lux")
28            .join(subdir)
29            .join(package.name().to_string())
30    }
31}
32
33impl CustomRockLayout for NvimLayout {
34    fn make_symlinks(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
35        let custom_dir = Self::target_for(tree, package);
36        fs::sync::create_dir_all(&custom_dir)?;
37
38        let layout = tree.layout_for(package);
39        try_create_symlink(&layout.src, &custom_dir.join("lua"))?;
40        try_create_symlink(&layout.lib, &custom_dir.join("lib"))?;
41
42        if layout.etc.is_dir() {
43            for entry in fs::sync::read_dir(&layout.etc)? {
44                let entry = entry?;
45                try_create_symlink(&entry.path(), &custom_dir.join(entry.file_name()))?;
46            }
47        }
48
49        Ok(())
50    }
51
52    fn remove_symlinks(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
53        let target = Self::target_for(tree, package);
54        if target.is_dir() {
55            // SAFETY: does not follow symlinks, only removes them
56            fs::sync::remove_dir_all(target)?;
57        }
58        Ok(())
59    }
60}
61
62fn try_create_symlink(target: &Path, link: &Path) -> fs::Result<()> {
63    if link.symlink_metadata().is_ok() {
64        return Ok(());
65    }
66
67    let relative = pathdiff::diff_paths(
68        target,
69        link.parent()
70            .ok_or_else(|| unreachable!("missing parent directory"))?,
71    )
72    .ok_or_else(|| {
73        FsError::Other(format!(
74            "failed to compute relative path for symlink from {} to {}",
75            link.display(),
76            target.display()
77        ))
78    })?;
79
80    #[cfg(unix)]
81    fs::unix::symlink(relative, link)?;
82
83    #[cfg(windows)]
84    fs::windows::symlink_dir(relative, link)?;
85
86    Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91    use assert_fs::prelude::PathCopy;
92    use std::path::PathBuf;
93
94    use crate::{
95        config::ConfigBuilder,
96        lockfile::{LockConstraint, LockedPackage, LockedPackageHashes},
97        lua_version::LuaVersion,
98        package::PackageSpec,
99        remote_package_source::RemotePackageSource,
100        tree::{EntryType, InstallTree, NvimLayout},
101    };
102
103    fn mock_hashes() -> LockedPackageHashes {
104        LockedPackageHashes {
105            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
106                .parse()
107                .unwrap(),
108            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
109                .parse()
110                .unwrap(),
111        }
112    }
113
114    fn sample_tree() -> (assert_fs::TempDir, PathBuf, crate::tree::Tree) {
115        let tree_path =
116            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
117        let temp = assert_fs::TempDir::new().unwrap();
118        temp.copy_from(&tree_path, &["**"]).unwrap();
119        let tree_path = temp.to_path_buf();
120        let config = ConfigBuilder::new()
121            .unwrap()
122            .user_tree(Some(tree_path.clone()))
123            .entrypoint_layout(NvimLayout)
124            .build()
125            .unwrap();
126        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
127        (temp, tree_path, tree)
128    }
129
130    fn sample_package() -> LockedPackage {
131        LockedPackage::from(
132            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
133            LockConstraint::Unconstrained,
134            RemotePackageSource::Test,
135            None,
136            mock_hashes(),
137        )
138    }
139
140    #[test]
141    fn nvim_layout_creates_symlinks_for_entrypoints() {
142        let (_temp, tree_path, tree) = sample_tree();
143        let package = sample_package();
144
145        tree.prepare(&package.spec).unwrap();
146        tree.finalize(&package, EntryType::Entrypoint).unwrap();
147
148        let custom_dir = tree_path
149            .join("5.1/site/pack/lux/start")
150            .join(package.name().to_string());
151        assert!(custom_dir.join("lua").symlink_metadata().is_ok());
152        assert!(custom_dir.join("lib").symlink_metadata().is_ok());
153        assert!(custom_dir.join("conf").symlink_metadata().is_err());
154        assert!(custom_dir.join("doc").symlink_metadata().is_err());
155    }
156
157    #[test]
158    fn nvim_layout_links_etc_entries_after_finalize() {
159        let (_temp, tree_path, tree) = sample_tree();
160        let package = sample_package();
161
162        tree.prepare(&package.spec).unwrap();
163
164        let etc = tree.layout_for(&package.spec).etc;
165        std::fs::create_dir_all(etc.join("plugin")).unwrap();
166        std::fs::write(etc.join("plugin/foo.vim"), "lua _G.foo = 1\n").unwrap();
167
168        tree.finalize(&package, EntryType::Entrypoint).unwrap();
169
170        let custom_dir = tree_path
171            .join("5.1/site/pack/lux/start")
172            .join(package.name().to_string());
173        assert!(custom_dir.join("plugin").symlink_metadata().is_ok());
174    }
175
176    #[test]
177    fn nvim_layout_skips_dependencies() {
178        let (_temp, tree_path, tree) = sample_tree();
179        let package = sample_package();
180
181        tree.prepare(&package.spec).unwrap();
182        tree.finalize(&package, EntryType::DependencyOnly).unwrap();
183
184        let custom_dir = tree_path
185            .join("5.1/site/pack/lux/start")
186            .join(package.name().to_string());
187        assert!(!custom_dir.exists());
188    }
189
190    #[test]
191    fn nvim_layout_removes_symlinks() {
192        let (_temp, tree_path, tree) = sample_tree();
193        let package = sample_package();
194
195        tree.prepare(&package.spec).unwrap();
196        tree.finalize(&package, EntryType::Entrypoint).unwrap();
197        let custom_dir = tree_path
198            .join("5.1/site/pack/lux/start")
199            .join(package.name().to_string());
200        assert!(custom_dir.exists());
201
202        tree.cleanup(&package, EntryType::Entrypoint).unwrap();
203        assert!(!custom_dir.exists());
204    }
205}