1use crate::{
2 fs::{self, FsError},
3 lockfile::{LockedPackageSpec, OptState},
4 tree::{InstallTree, Tree},
5};
6use std::path::{Path, PathBuf};
7
8pub trait CustomRockLayout: std::fmt::Debug + Send + Sync {
10 fn apply_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
12
13 fn remove_layout(&self, tree: &Tree, package: &LockedPackageSpec) -> fs::Result<()>;
15
16 fn layout_dependencies(&self) -> bool {
18 false
19 }
20}
21
22#[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 fs::sync::remove_dir_all(target)?;
63 }
64 Ok(())
65 }
66}
67
68#[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 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}