use anyhow::Result;
use std::path::{Path, PathBuf};
const RTP_DIRS: &[&str] = &[
"after", "autoload", "colors", "compiler",
"denops", "doc", "ftdetect", "ftplugin", "indent", "keymap", "lang", "lua", "pack", "parser", "plugin",
"queries", "rplugin", "spell", "syntax",
"tutor", ];
fn hard_link_or_copy(src: &Path, dst: &Path) -> Result<()> {
if std::fs::hard_link(src, dst).is_err() {
std::fs::copy(src, dst)?;
}
Ok(())
}
fn is_helptags_file(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower == "tags" || (lower.starts_with("tags-") && !lower.contains('.'))
}
#[derive(Debug, Default)]
pub struct MergeResult {
pub conflicts: Vec<MergeConflict>,
pub placed: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct MergeConflict {
pub relative: PathBuf,
}
pub fn merge_plugin(src: &Path, dst_root: &Path) -> Result<MergeResult> {
let mut result = MergeResult::default();
if !dst_root.exists() {
std::fs::create_dir_all(dst_root)?;
}
walk(src, src, dst_root, &mut result)?;
Ok(result)
}
fn walk(plugin_root: &Path, dir: &Path, dst_root: &Path, result: &mut MergeResult) -> Result<()> {
let at_plugin_root = dir == plugin_root;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
let src_path = entry.path();
if name_str.starts_with('.') {
continue;
}
if at_plugin_root {
if src_path.is_file() {
continue;
}
if !RTP_DIRS.contains(&name_str.as_ref()) {
continue;
}
}
let rel = src_path
.strip_prefix(plugin_root)
.expect("entry is under plugin_root")
.to_path_buf();
let dst_path = dst_root.join(&rel);
if !src_path.is_dir()
&& rel
.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == "doc")
&& is_helptags_file(&name_str)
{
continue;
}
if src_path.is_dir() {
if dst_path.is_file() {
result.conflicts.push(MergeConflict { relative: rel });
continue;
}
if !dst_path.exists() {
std::fs::create_dir_all(&dst_path)?;
}
walk(plugin_root, &src_path, dst_root, result)?;
} else if dst_path.exists() {
result.conflicts.push(MergeConflict { relative: rel });
} else {
hard_link_or_copy(&src_path, &dst_path)?;
result.placed.push(rel);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn test_merge_no_conflict() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("lua/plug_a/init.lua"), "print('a')");
write(&b.join("plugin/b.vim"), "echo 'b'");
let r1 = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert!(merged.join("lua/plug_a/init.lua").exists());
assert!(merged.join("plugin/b.vim").exists());
assert!(r1.conflicts.is_empty());
assert!(r2.conflicts.is_empty());
}
#[test]
fn test_merge_conflict_first_wins() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("lua/shared/init.lua"), "from a");
write(&b.join("lua/shared/init.lua"), "from b");
let _ = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
let content = fs::read_to_string(merged.join("lua/shared/init.lua")).unwrap();
assert_eq!(content, "from a");
assert_eq!(r2.conflicts.len(), 1);
assert_eq!(
r2.conflicts[0].relative,
PathBuf::from("lua").join("shared").join("init.lua")
);
let _ = b; }
#[test]
fn test_merge_same_dir_different_files_coexist() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("lua/cmp/a.lua"), "a");
write(&b.join("lua/cmp/b.lua"), "b");
let r1 = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert!(merged.join("lua/cmp/a.lua").exists());
assert!(merged.join("lua/cmp/b.lua").exists());
assert!(r1.conflicts.is_empty());
assert!(r2.conflicts.is_empty());
}
#[test]
fn test_merge_skips_root_level_dotfiles() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join(".git/config"), "[core]");
write(&p.join(".github/workflows/ci.yml"), "name: CI");
write(&p.join("plugin/foo.vim"), "echo 'foo'");
let r = merge_plugin(&p, &merged).unwrap();
assert!(!merged.join(".git").exists());
assert!(!merged.join(".github").exists());
assert!(merged.join("plugin/foo.vim").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_skips_root_level_meta_files() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("README.md"), "# plug");
write(&p.join("LICENSE"), "MIT");
write(&p.join("Makefile"), "all:");
write(&p.join("package.json"), "{}");
write(&p.join("stylua.toml"), "");
write(&p.join("plugin/foo.vim"), "echo 'foo'");
write(&p.join("doc/foo.txt"), "*foo*");
let r = merge_plugin(&p, &merged).unwrap();
assert!(!merged.join("README.md").exists());
assert!(!merged.join("LICENSE").exists());
assert!(!merged.join("Makefile").exists());
assert!(!merged.join("package.json").exists());
assert!(!merged.join("stylua.toml").exists());
assert!(merged.join("plugin/foo.vim").exists());
assert!(merged.join("doc/foo.txt").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_skips_committed_doc_tags() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("doc/foo.txt"), "*foo*");
write(&p.join("doc/tags"), "stale-tags");
write(&p.join("doc/tags-ja"), "stale-tags-ja");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("doc/foo.txt").exists());
assert!(
!merged.join("doc/tags").exists(),
"doc/tags should be skipped"
);
assert!(
!merged.join("doc/tags-ja").exists(),
"doc/tags-ja should be skipped"
);
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_keeps_doc_tags_named_files_with_extension() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("doc/foo.txt"), "*foo*");
write(&p.join("doc/tags.bak"), "backup");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("doc/tags.bak").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_includes_tutor_dir() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("tutor/intro.tutor"), "# tutor");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("tutor/intro.tutor").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_dir_vs_file_collision_is_recorded_as_conflict() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("lua/foo"), "i am a file from a");
write(&b.join("lua/foo/bar.lua"), "from b");
let _ = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert!(merged.join("lua/foo").is_file());
assert_eq!(r2.conflicts.len(), 1);
assert_eq!(r2.conflicts[0].relative, PathBuf::from("lua").join("foo"));
}
#[test]
fn test_merge_file_vs_dir_collision_is_recorded_as_conflict() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("lua/foo/bar.lua"), "from a");
write(&b.join("lua/foo"), "i am a file from b");
let _ = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert!(merged.join("lua/foo").is_dir());
assert!(merged.join("lua/foo/bar.lua").exists());
assert_eq!(r2.conflicts.len(), 1);
assert_eq!(r2.conflicts[0].relative, PathBuf::from("lua").join("foo"));
}
#[test]
fn test_merge_placed_lists_newly_linked_files() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("plugin/init.lua"), "return {}");
write(&p.join("lua/foo/bar.lua"), "return {}");
let r = merge_plugin(&p, &merged).unwrap();
assert!(r.conflicts.is_empty());
let mut placed: Vec<_> = r
.placed
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
placed.sort();
assert_eq!(placed, vec!["lua/foo/bar.lua", "plugin/init.lua"]);
}
#[test]
fn test_merge_placed_excludes_skipped_conflicts() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("plug_a");
let b = root.path().join("plug_b");
write(&a.join("plugin/init.lua"), "from a");
write(&b.join("plugin/init.lua"), "from b");
let r1 = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert_eq!(r1.placed.len(), 1);
assert_eq!(
r1.placed[0].to_string_lossy().replace('\\', "/"),
"plugin/init.lua"
);
assert!(r2.placed.is_empty());
assert_eq!(r2.conflicts.len(), 1);
}
#[test]
fn test_merge_includes_denops_dir() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(
&p.join("denops/myplug/main.ts"),
"export async function main() {}",
);
write(&p.join("denops/myplug/util.ts"), "export const x = 1;");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("denops/myplug/main.ts").exists());
assert!(merged.join("denops/myplug/util.ts").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_skips_non_rtp_dirs() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("tests/spec.lua"), "test");
write(&p.join("scripts/build.sh"), "#!/bin/sh");
write(&p.join("examples/demo.lua"), "demo");
write(&p.join("src/main.rs"), "fn main() {}");
write(&p.join("plugin/foo.vim"), "echo 'foo'");
write(&p.join("lua/foo/init.lua"), "return {}");
let r = merge_plugin(&p, &merged).unwrap();
assert!(!merged.join("tests").exists());
assert!(!merged.join("scripts").exists());
assert!(!merged.join("examples").exists());
assert!(!merged.join("src").exists());
assert!(merged.join("plugin/foo.vim").exists());
assert!(merged.join("lua/foo/init.lua").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_includes_all_rtp_dirs() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
for dir in RTP_DIRS {
write(&p.join(dir).join("file.txt"), dir);
}
let r = merge_plugin(&p, &merged).unwrap();
assert!(r.conflicts.is_empty());
for dir in RTP_DIRS {
assert!(
merged.join(dir).join("file.txt").exists(),
"missing rtp dir in merged: {}",
dir
);
}
}
#[test]
fn test_merge_no_conflict_for_meta_files_across_plugins() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
for name in ["a", "b", "c"] {
let p = root.path().join(name);
write(&p.join("README.md"), name);
write(&p.join("LICENSE"), "MIT");
write(&p.join(format!("plugin/{}.vim", name)), "");
let r = merge_plugin(&p, &merged).unwrap();
assert!(
r.conflicts.is_empty(),
"expected no conflicts for {}, got: {:?}",
name,
r.conflicts
);
}
}
#[test]
fn test_merge_preserves_nested_dirs() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("lua/foo/bar/baz/deep.lua"), "deep");
write(&p.join("lua/foo/bar/baz/extra.lua"), "extra");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("lua/foo/bar/baz/deep.lua").exists());
assert!(merged.join("lua/foo/bar/baz/extra.lua").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_merge_skips_dotfiles_at_any_depth() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("doc/foo.txt"), "*foo*");
write(&p.join("doc/.gitignore"), "tags");
write(&p.join("lua/foo/.luarc.json"), "{}");
write(&p.join("lua/foo/init.lua"), "return {}");
let r = merge_plugin(&p, &merged).unwrap();
assert!(merged.join("doc/foo.txt").exists());
assert!(!merged.join("doc/.gitignore").exists());
assert!(merged.join("lua/foo/init.lua").exists());
assert!(!merged.join("lua/foo/.luarc.json").exists());
assert!(r.conflicts.is_empty());
}
#[test]
fn test_hard_link_shares_content_with_source() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let p = root.path().join("plug");
write(&p.join("plugin/hello.vim"), "initial");
let _ = merge_plugin(&p, &merged).unwrap();
fs::write(p.join("plugin/hello.vim"), "updated").unwrap();
let merged_content = fs::read_to_string(merged.join("plugin/hello.vim")).unwrap();
assert!(
merged_content == "updated" || merged_content == "initial",
"unexpected content: {}",
merged_content
);
}
#[test]
fn test_merge_returns_multiple_conflicts() {
let root = tempdir().unwrap();
let merged = root.path().join("merged");
let a = root.path().join("a");
let b = root.path().join("b");
write(&a.join("lua/x.lua"), "a-x");
write(&a.join("lua/y.lua"), "a-y");
write(&b.join("lua/x.lua"), "b-x");
write(&b.join("lua/y.lua"), "b-y");
write(&b.join("lua/z.lua"), "b-z");
let _ = merge_plugin(&a, &merged).unwrap();
let r2 = merge_plugin(&b, &merged).unwrap();
assert_eq!(r2.conflicts.len(), 2);
assert!(merged.join("lua/z.lua").exists());
}
}