Skip to main content

lux_lib/tree/
layout.rs

1use crate::{
2    fs::{self, FsError},
3    lockfile::{LocalPackage, 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: &LocalPackage) -> fs::Result<()>;
12
13    fn remove_symlinks(&self, tree: &Tree, package: &LocalPackage) -> 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: &LocalPackage) -> PathBuf {
22        let subdir = match package.spec.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: &LocalPackage) -> 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: &LocalPackage) -> 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::{LocalPackage, LocalPackageHashes, LockConstraint},
97        lua_version::LuaVersion,
98        package::PackageSpec,
99        remote_package_source::RemotePackageSource,
100        rockspec::RockBinaries,
101        tree::{EntryType, InstallTree, NvimLayout},
102    };
103
104    fn mock_hashes() -> LocalPackageHashes {
105        LocalPackageHashes {
106            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
107                .parse()
108                .unwrap(),
109            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
110                .parse()
111                .unwrap(),
112        }
113    }
114
115    fn sample_tree() -> (assert_fs::TempDir, PathBuf, crate::tree::Tree) {
116        let tree_path =
117            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
118        let temp = assert_fs::TempDir::new().unwrap();
119        temp.copy_from(&tree_path, &["**"]).unwrap();
120        let tree_path = temp.to_path_buf();
121        let config = ConfigBuilder::new()
122            .unwrap()
123            .user_tree(Some(tree_path.clone()))
124            .entrypoint_layout(NvimLayout)
125            .build()
126            .unwrap();
127        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
128        (temp, tree_path, tree)
129    }
130
131    fn sample_package() -> LocalPackage {
132        LocalPackage::from(
133            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
134            LockConstraint::Unconstrained,
135            RockBinaries::default(),
136            RemotePackageSource::Test,
137            None,
138            mock_hashes(),
139        )
140    }
141
142    #[test]
143    fn nvim_layout_creates_symlinks_for_entrypoints() {
144        let (_temp, tree_path, tree) = sample_tree();
145        let package = sample_package();
146
147        tree.prepare(&package).unwrap();
148        tree.finalize(&package, EntryType::Entrypoint).unwrap();
149
150        let custom_dir = tree_path
151            .join("5.1/site/pack/lux/start")
152            .join(package.name().to_string());
153        assert!(custom_dir.join("lua").symlink_metadata().is_ok());
154        assert!(custom_dir.join("lib").symlink_metadata().is_ok());
155        assert!(custom_dir.join("conf").symlink_metadata().is_err());
156        assert!(custom_dir.join("doc").symlink_metadata().is_err());
157    }
158
159    #[test]
160    fn nvim_layout_links_etc_entries_after_finalize() {
161        let (_temp, tree_path, tree) = sample_tree();
162        let package = sample_package();
163
164        tree.prepare(&package).unwrap();
165
166        let etc = tree.layout_for(&package).etc;
167        std::fs::create_dir_all(etc.join("plugin")).unwrap();
168        std::fs::write(etc.join("plugin/foo.vim"), "lua _G.foo = 1\n").unwrap();
169
170        tree.finalize(&package, EntryType::Entrypoint).unwrap();
171
172        let custom_dir = tree_path
173            .join("5.1/site/pack/lux/start")
174            .join(package.name().to_string());
175        assert!(custom_dir.join("plugin").symlink_metadata().is_ok());
176    }
177
178    #[test]
179    fn nvim_layout_skips_dependencies() {
180        let (_temp, tree_path, tree) = sample_tree();
181        let package = sample_package();
182
183        tree.prepare(&package).unwrap();
184        tree.finalize(&package, EntryType::DependencyOnly).unwrap();
185
186        let custom_dir = tree_path
187            .join("5.1/site/pack/lux/start")
188            .join(package.name().to_string());
189        assert!(!custom_dir.exists());
190    }
191
192    #[test]
193    fn nvim_layout_removes_symlinks() {
194        let (_temp, tree_path, tree) = sample_tree();
195        let package = sample_package();
196
197        tree.prepare(&package).unwrap();
198        tree.finalize(&package, EntryType::Entrypoint).unwrap();
199        let custom_dir = tree_path
200            .join("5.1/site/pack/lux/start")
201            .join(package.name().to_string());
202        assert!(custom_dir.exists());
203
204        tree.cleanup(&package, EntryType::Entrypoint).unwrap();
205        assert!(!custom_dir.exists());
206    }
207}