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 installed packages, applied in addition to the standard tree layout.
9pub trait CustomRockLayout: std::fmt::Debug + Send + Sync {
10    /// Arrange the package's files where external tools expect them.
11    fn apply_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
12
13    /// Remove the files arranged by [`Self::apply_layout`].
14    fn remove_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
15
16    /// Whether the layout applies to dependency packages, not just entrypoints.
17    fn layout_dependencies(&self) -> bool {
18        false
19    }
20}
21
22/// A [`CustomRockLayout`] for Neovim plugins. Packages are exposed under `<tree>/site/pack/lux/{start,opt}/<package>`
23#[derive(Clone, Debug, Default)]
24pub struct NvimLayout;
25
26impl NvimLayout {
27    fn target_for(tree: &Tree, package: &LockedPackageSpec) -> PathBuf {
28        let subdir = match package.opt() {
29            OptState::Required => "start",
30            OptState::Optional => "opt",
31        };
32        tree.root()
33            .join("site/pack/lux")
34            .join(subdir)
35            .join(package.name().to_string())
36    }
37}
38
39impl CustomRockLayout for NvimLayout {
40    fn apply_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
41        let custom_dir = Self::target_for(tree, package);
42        fs::sync::create_dir_all(&custom_dir)?;
43
44        let layout = tree.layout_for(package);
45        try_create_symlink(&layout.src, &custom_dir.join("lua"))?;
46        try_create_symlink(&layout.lib, &custom_dir.join("lib"))?;
47
48        if layout.etc.is_dir() {
49            for entry in fs::sync::read_dir(&layout.etc)? {
50                let entry = entry?;
51                try_create_symlink(&entry.path(), &custom_dir.join(entry.file_name()))?;
52            }
53        }
54
55        Ok(())
56    }
57
58    fn remove_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
59        let target = Self::target_for(tree, package);
60        if target.is_dir() {
61            // SAFETY: does not follow symlinks, only removes them
62            fs::sync::remove_dir_all(target)?;
63        }
64        Ok(())
65    }
66}
67
68/// A [`CustomRockLayout`] for [Rojo](https://rojo.space/).
69/// Packages are copied into a `Packages` directory at the workspace root
70/// so `rojo serve` can pick them up.
71#[derive(Clone, Debug, Default)]
72pub struct RojoLayout;
73
74impl RojoLayout {
75    fn packages_dir(tree: &Tree) -> PathBuf {
76        let root = tree.root();
77        root.parent()
78            .and_then(Path::parent)
79            .unwrap_or_else(|| unreachable!("tree root is two levels below the workspace root"))
80            .join("Packages")
81    }
82
83    fn index_dir(tree: &Tree, package: &LockedPackageSpec) -> PathBuf {
84        Self::packages_dir(tree).join(format!(
85            "_Index/{}@{}",
86            package.name(),
87            package.version().to_version_string()
88        ))
89    }
90
91    fn content_dir(tree: &Tree, package: &LockedPackageSpec) -> PathBuf {
92        Self::index_dir(tree, package).join(package.name().to_string())
93    }
94
95    fn stub_path(tree: &Tree, package: &LockedPackageSpec) -> PathBuf {
96        Self::packages_dir(tree).join(format!("{}.lua", package.name()))
97    }
98}
99
100impl CustomRockLayout for RojoLayout {
101    fn apply_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
102        if !tree.version().is_luau() {
103            return Ok(());
104        }
105
106        // NOTE: Rojo does not follow symlinks (https://github.com/rojo-rbx/rojo/issues/392),
107        // so we hard link the package sources.
108        let layout = tree.layout_for(package);
109        let content_dir = Self::content_dir(tree, package);
110        if content_dir.is_dir() {
111            fs::sync::remove_dir_all(&content_dir)?;
112        }
113        fs::sync::link_dir_all(&layout.src, &content_dir)?;
114
115        let name = package.name().to_string();
116        let has_init = ["init.luau", "init.lua"]
117            .iter()
118            .map(|file| content_dir.join(file))
119            .any(|path| path.is_file());
120        if !has_init {
121            let entrypoint = ["luau", "lua"].iter().find_map(|ext| {
122                let path = content_dir.join(format!("{name}.{ext}"));
123                path.is_file().then_some((*ext, path))
124            });
125            match entrypoint {
126                Some((ext, entrypoint)) => {
127                    fs::sync::hard_link(&entrypoint, content_dir.join(format!("init.{ext}")))?;
128                }
129                None => {
130                    return Err(FsError::Other(format!(
131                        "no `init.luau`, `init.lua`, `{name}.luau`, or `{name}.lua` entrypoint found in `{}`",
132                        layout.src.display()
133                    )));
134                }
135            }
136        }
137
138        let stub = Self::stub_path(tree, package);
139        if let Some(parent) = stub.parent() {
140            fs::sync::create_dir_all(parent)?;
141        }
142        fs::sync::write(
143            &stub,
144            format!(
145                "return require(script.Parent._Index[\"{name}@{version}\"][\"{name}\"])\n",
146                name = package.name(),
147                version = package.version().to_version_string(),
148            ),
149        )?;
150
151        Ok(())
152    }
153
154    fn remove_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()> {
155        if !tree.version().is_luau() {
156            return Ok(());
157        }
158
159        let index_dir = Self::index_dir(tree, package);
160        if index_dir.is_dir() {
161            fs::sync::remove_dir_all(&index_dir)?;
162        }
163
164        let stub = Self::stub_path(tree, package);
165        if stub.is_file() {
166            fs::sync::remove_file(&stub)?;
167        }
168
169        Ok(())
170    }
171
172    fn layout_dependencies(&self) -> bool {
173        true
174    }
175}
176
177fn try_create_symlink(target: &Path, link: &Path) -> fs::Result<()> {
178    if link.symlink_metadata().is_ok() {
179        return Ok(());
180    }
181
182    let relative = pathdiff::diff_paths(
183        target,
184        link.parent()
185            .ok_or_else(|| unreachable!("missing parent directory"))?,
186    )
187    .ok_or_else(|| {
188        FsError::Other(format!(
189            "failed to compute relative path for symlink from {} to {}",
190            link.display(),
191            target.display()
192        ))
193    })?;
194
195    #[cfg(unix)]
196    fs::unix::symlink(relative, link)?;
197
198    #[cfg(windows)]
199    fs::windows::symlink_dir(relative, link)?;
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use assert_fs::prelude::PathCopy;
207    use std::path::PathBuf;
208
209    use crate::{
210        config::ConfigBuilder,
211        lockfile::{LockConstraint, LockedPackage, LockedPackageHashes},
212        lua_version::LuaVersion,
213        package::PackageSpec,
214        remote_package_source::RemotePackageSource,
215        tree::{EntryType, InstallTree, NvimLayout, RojoLayout},
216    };
217
218    fn mock_hashes() -> LockedPackageHashes {
219        LockedPackageHashes {
220            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
221                .parse()
222                .unwrap(),
223            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
224                .parse()
225                .unwrap(),
226        }
227    }
228
229    fn sample_tree() -> (assert_fs::TempDir, PathBuf, crate::tree::Tree) {
230        let tree_path =
231            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
232        let temp = assert_fs::TempDir::new().unwrap();
233        temp.copy_from(&tree_path, &["**"]).unwrap();
234        let tree_path = temp.to_path_buf();
235        let config = ConfigBuilder::new()
236            .unwrap()
237            .user_tree(Some(tree_path.clone()))
238            .entrypoint_layout(NvimLayout)
239            .build()
240            .unwrap();
241        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
242        (temp, tree_path, tree)
243    }
244
245    fn sample_package() -> LockedPackage {
246        LockedPackage::from(
247            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
248            LockConstraint::Unconstrained,
249            RemotePackageSource::Test,
250            None,
251            mock_hashes(),
252        )
253    }
254
255    #[test]
256    fn nvim_layout_creates_symlinks_for_entrypoints() {
257        let (_temp, tree_path, tree) = sample_tree();
258        let package = sample_package();
259
260        tree.prepare(&package.spec).unwrap();
261        tree.finalize(&package, EntryType::Entrypoint).unwrap();
262
263        let custom_dir = tree_path
264            .join("5.1/site/pack/lux/start")
265            .join(package.name().to_string());
266        assert!(custom_dir.join("lua").symlink_metadata().is_ok());
267        assert!(custom_dir.join("lib").symlink_metadata().is_ok());
268        assert!(custom_dir.join("conf").symlink_metadata().is_err());
269        assert!(custom_dir.join("doc").symlink_metadata().is_err());
270    }
271
272    #[test]
273    fn nvim_layout_links_etc_entries_after_finalize() {
274        let (_temp, tree_path, tree) = sample_tree();
275        let package = sample_package();
276
277        tree.prepare(&package.spec).unwrap();
278
279        let etc = tree.layout_for(&package.spec).etc;
280        std::fs::create_dir_all(etc.join("plugin")).unwrap();
281        std::fs::write(etc.join("plugin/foo.vim"), "lua _G.foo = 1\n").unwrap();
282
283        tree.finalize(&package, EntryType::Entrypoint).unwrap();
284
285        let custom_dir = tree_path
286            .join("5.1/site/pack/lux/start")
287            .join(package.name().to_string());
288        assert!(custom_dir.join("plugin").symlink_metadata().is_ok());
289    }
290
291    #[test]
292    fn nvim_layout_skips_dependencies() {
293        let (_temp, tree_path, tree) = sample_tree();
294        let package = sample_package();
295
296        tree.prepare(&package.spec).unwrap();
297        tree.finalize(&package, EntryType::DependencyOnly).unwrap();
298
299        let custom_dir = tree_path
300            .join("5.1/site/pack/lux/start")
301            .join(package.name().to_string());
302        assert!(!custom_dir.exists());
303    }
304
305    #[test]
306    fn nvim_layout_removes_symlinks() {
307        let (_temp, tree_path, tree) = sample_tree();
308        let package = sample_package();
309
310        tree.prepare(&package.spec).unwrap();
311        tree.finalize(&package, EntryType::Entrypoint).unwrap();
312        let custom_dir = tree_path
313            .join("5.1/site/pack/lux/start")
314            .join(package.name().to_string());
315        assert!(custom_dir.exists());
316
317        tree.cleanup(&package, EntryType::Entrypoint).unwrap();
318        assert!(!custom_dir.exists());
319    }
320
321    fn luau_tree() -> (assert_fs::TempDir, crate::tree::Tree) {
322        let temp = assert_fs::TempDir::new().unwrap();
323        let config = ConfigBuilder::new()
324            .unwrap()
325            .user_tree(Some(temp.path().join(".lux")))
326            .entrypoint_layout(RojoLayout)
327            .build()
328            .unwrap();
329        let tree = config.user_tree(LuaVersion::Luau).unwrap();
330        (temp, tree)
331    }
332
333    #[test]
334    fn rojo_layout_renames_entrypoint_and_writes_stub() {
335        let (temp, tree) = luau_tree();
336        let package = sample_package();
337
338        tree.prepare(&package.spec).unwrap();
339        let src = tree.layout_for(&package.spec).src;
340        std::fs::write(src.join("neorg.luau"), "return {}\n").unwrap();
341
342        tree.finalize(&package, EntryType::Entrypoint).unwrap();
343
344        let packages = temp.path().join("Packages");
345        let content = packages.join("_Index/neorg@8.0.0/neorg");
346        assert!(content.join("init.luau").is_file());
347        assert!(content.join("neorg.luau").is_file());
348        assert_eq!(
349            std::fs::read_to_string(packages.join("neorg.lua")).unwrap(),
350            "return require(script.Parent._Index[\"neorg@8.0.0\"][\"neorg\"])\n"
351        );
352    }
353
354    #[test]
355    fn rojo_layout_handles_lua_entrypoint() {
356        let (temp, tree) = luau_tree();
357        let package = sample_package();
358
359        tree.prepare(&package.spec).unwrap();
360        let src = tree.layout_for(&package.spec).src;
361        std::fs::write(src.join("neorg.lua"), "return {}\n").unwrap();
362
363        tree.finalize(&package, EntryType::Entrypoint).unwrap();
364
365        let content = temp.path().join("Packages/_Index/neorg@8.0.0/neorg");
366        assert!(content.join("init.lua").is_file());
367        assert!(content.join("neorg.lua").is_file());
368    }
369
370    #[test]
371    fn rojo_layout_applies_to_dependencies() {
372        let (temp, tree) = luau_tree();
373        let package = sample_package();
374
375        tree.prepare(&package.spec).unwrap();
376        let src = tree.layout_for(&package.spec).src;
377        std::fs::write(src.join("init.luau"), "return {}\n").unwrap();
378
379        tree.finalize(&package, EntryType::DependencyOnly).unwrap();
380
381        assert!(temp
382            .path()
383            .join("Packages/_Index/neorg@8.0.0/neorg/init.luau")
384            .is_file());
385    }
386
387    #[test]
388    fn rojo_layout_skips_non_luau() {
389        let temp = assert_fs::TempDir::new().unwrap();
390        let config = ConfigBuilder::new()
391            .unwrap()
392            .user_tree(Some(temp.path().join(".lux")))
393            .entrypoint_layout(RojoLayout)
394            .build()
395            .unwrap();
396        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
397        let package = sample_package();
398
399        tree.prepare(&package.spec).unwrap();
400        tree.finalize(&package, EntryType::Entrypoint).unwrap();
401
402        assert!(!temp.path().join("Packages").exists());
403    }
404
405    #[test]
406    fn rojo_layout_removes_artifacts() {
407        let (temp, tree) = luau_tree();
408        let package = sample_package();
409
410        tree.prepare(&package.spec).unwrap();
411        let src = tree.layout_for(&package.spec).src;
412        std::fs::write(src.join("init.luau"), "return {}\n").unwrap();
413        tree.finalize(&package, EntryType::Entrypoint).unwrap();
414
415        let packages = temp.path().join("Packages");
416        assert!(packages.join("neorg.lua").is_file());
417
418        tree.cleanup(&package, EntryType::Entrypoint).unwrap();
419        assert!(!packages.join("neorg.lua").exists());
420        assert!(!packages.join("_Index/neorg@8.0.0").exists());
421    }
422}