Skip to content

Commit a672e11

Browse files
authored
feat: copy directories recursively (#241)
1 parent d90956b commit a672e11

File tree

2 files changed

+175
-12
lines changed

2 files changed

+175
-12
lines changed

lua/plenary/path.lua

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,13 @@ function Path:mkdir(opts)
429429
local parents = F.if_nil(opts.parents, false, opts.parents)
430430
local exists_ok = F.if_nil(opts.exists_ok, true, opts.exists_ok)
431431

432-
if not exists_ok and self:exists() then
432+
local exists = self:exists()
433+
if not exists_ok and exists then
433434
error("FileExistsError:" .. self:absolute())
434435
end
435436

436-
if not uv.fs_mkdir(self:_fs_filename(), mode) then
437+
-- fs_mkdir returns nil if folder exists
438+
if not uv.fs_mkdir(self:_fs_filename(), mode) and not exists then
437439
if parents then
438440
local dirs = self:_split()
439441
local processed = ""
@@ -500,20 +502,77 @@ function Path:rename(opts)
500502
return status
501503
end
502504

505+
--- Copy files or folders with defaults akin to GNU's `cp`.
506+
---@param opts table: options to pass to toggling registered actions
507+
---@field destination string|Path: target file path to copy to
508+
---@field recursive bool: whether to copy folders recursively (default: false)
509+
---@field override bool: whether to override files (default: true)
510+
---@field interactive bool: confirm if copy would override; precedes `override` (default: false)
511+
---@field respect_gitignore bool: skip folders ignored by all detected `gitignore`s (default: false)
512+
---@field hidden bool: whether to add hidden files in recursively copying folders (default: true)
513+
---@field parents bool: whether to create possibly non-existing parent dirs of `opts.destination` (default: false)
514+
---@field exists_ok bool: whether ok if `opts.destination` exists, if so folders are merged (default: true)
515+
---@return table {[Path of destination]: bool} indicating success of copy; nested tables constitute sub dirs
503516
function Path:copy(opts)
504517
opts = opts or {}
518+
opts.recursive = F.if_nil(opts.recursive, false, opts.recursive)
519+
opts.override = F.if_nil(opts.override, true, opts.override)
505520

521+
local dest = opts.destination
506522
-- handles `.`, `..`, `./`, and `../`
507-
if opts.destination:match "^%.%.?/?\\?.+" then
508-
opts.destination = {
509-
uv.fs_realpath(opts.destination:sub(1, 3)),
510-
opts.destination:sub(4, #opts.destination),
523+
if not Path.is_path(dest) then
524+
if type(dest) == "string" and dest:match "^%.%.?/?\\?.+" then
525+
dest = {
526+
uv.fs_realpath(dest:sub(1, 3)),
527+
dest:sub(4, #dest),
528+
}
529+
end
530+
dest = Path:new(dest)
531+
end
532+
-- success is true in case file is copied, false otherwise
533+
local success = {}
534+
if not self:is_dir() then
535+
if opts.interactive and dest:exists() then
536+
vim.ui.select(
537+
{ "Yes", "No" },
538+
{ prompt = string.format("Overwrite existing %s?", dest:absolute()) },
539+
function(_, idx)
540+
success[dest] = uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = not (idx == 1) }) or false
541+
end
542+
)
543+
else
544+
-- nil: not overriden if `override = false`
545+
success[dest] = uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = not opts.override }) or false
546+
end
547+
return success
548+
end
549+
-- dir
550+
if opts.recursive then
551+
dest:mkdir {
552+
parents = F.if_nil(opts.parents, false, opts.parents),
553+
exists_ok = F.if_nil(opts.exists_ok, true, opts.exists_ok),
511554
}
555+
local scan = require "plenary.scandir"
556+
local data = scan.scan_dir(self.filename, {
557+
respect_gitignore = F.if_nil(opts.respect_gitignore, false, opts.respect_gitignore),
558+
hidden = F.if_nil(opts.hidden, true, opts.hidden),
559+
depth = 1,
560+
add_dirs = true,
561+
})
562+
for _, entry in ipairs(data) do
563+
local entry_path = Path:new(entry)
564+
local suffix = table.remove(entry_path:_split())
565+
local new_dest = dest:joinpath(suffix)
566+
-- clear destination as it might be Path table otherwise failing w/ extend
567+
opts.destination = nil
568+
local new_opts = vim.tbl_deep_extend("force", opts, { destination = new_dest })
569+
-- nil: not overriden if `override = false`
570+
success[new_dest] = entry_path:copy(new_opts) or false
571+
end
572+
return success
573+
else
574+
error(string.format("Warning: %s was not copied as `recursive=false`", self:absolute()))
512575
end
513-
514-
local dest = Path:new(opts.destination)
515-
516-
return uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = true })
517576
end
518577

519578
function Path:touch(opts)

tests/plenary/path_spec.lua

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,21 +447,125 @@ describe("Path", function()
447447
Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm()
448448
end)
449449

450-
it("cannot copy a file if it's already exists", function()
450+
it("cannot copy an existing file if override false", function()
451451
local p1 = Path:new "a_random_filename.rs"
452452
local p2 = Path:new "not_a_random_filename.rs"
453453
assert(pcall(p1.touch, p1))
454454
assert(pcall(p2.touch, p2))
455455
assert(p1:exists())
456456
assert(p2:exists())
457457

458-
assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs" }))
458+
assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false }))
459459
assert.are.same(p1.filename, "a_random_filename.rs")
460460
assert.are.same(p2.filename, "not_a_random_filename.rs")
461461

462462
p1:rm()
463463
p2:rm()
464464
end)
465+
466+
it("fails when copying folders non-recursively", function()
467+
local src_dir = Path:new "src"
468+
src_dir:mkdir()
469+
src_dir:joinpath("file1.lua"):touch()
470+
471+
local trg_dir = Path:new "trg"
472+
local status = xpcall(function()
473+
src_dir:copy { destination = trg_dir, recursive = false }
474+
end, function() end)
475+
-- failed as intended
476+
assert(status == false)
477+
478+
src_dir:rm { recursive = true }
479+
end)
480+
481+
it("can copy directories recursively", function()
482+
-- vim.tbl_flatten doesn't work here as copy doesn't return a list
483+
local flatten
484+
flatten = function(ret, t)
485+
for _, v in pairs(t) do
486+
if type(v) == "table" then
487+
flatten(ret, v)
488+
else
489+
table.insert(ret, v)
490+
end
491+
end
492+
end
493+
494+
-- setup directories
495+
local src_dir = Path:new "src"
496+
local trg_dir = Path:new "trg"
497+
src_dir:mkdir()
498+
499+
-- set up sub directory paths for creation and testing
500+
local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" }
501+
local src_dirs = { src_dir }
502+
local trg_dirs = { trg_dir }
503+
-- {src, trg}_dirs is a table with all directory levels by {src, trg}
504+
for _, dir in ipairs(sub_dirs) do
505+
table.insert(src_dirs, src_dir:joinpath(dir))
506+
table.insert(trg_dirs, trg_dir:joinpath(dir))
507+
end
508+
509+
-- generate {file}_{level}.lua on every directory level in src
510+
-- src
511+
-- ├── file1_1.lua
512+
-- ├── file2_1.lua
513+
-- ├── .file3_1.lua
514+
-- └── sub_dir1
515+
-- ├── file1_2.lua
516+
-- ├── file2_2.lua
517+
-- ├── .file3_2.lua
518+
-- └── sub_dir2
519+
-- ├── file1_3.lua
520+
-- ├── file2_3.lua
521+
-- └── .file3_3.lua
522+
local files = { "file1", "file2", ".file3" }
523+
for _, file in ipairs(files) do
524+
for level, dir in ipairs(src_dirs) do
525+
local p = dir:joinpath(file .. "_" .. level .. ".lua")
526+
assert(pcall(p.touch, p, { parents = true, exists_ok = true }))
527+
assert(p:exists())
528+
end
529+
end
530+
531+
for _, hidden in ipairs { true, false } do
532+
-- override = `false` should NOT copy as it was copied beforehand
533+
for _, override in ipairs { true, false } do
534+
local success = src_dir:copy { destination = trg_dir, recursive = true, override = override, hidden = hidden }
535+
-- the files are already created because we iterate first with `override=true`
536+
-- hence, we test here that no file ops have been committed: any value in tbl of tbls should be false
537+
if not override then
538+
local file_ops = {}
539+
flatten(file_ops, success)
540+
-- 3 layers with at at least 2 and at most 3 files (`hidden = true`)
541+
local num_files = not hidden and 6 or 9
542+
assert(#file_ops == num_files)
543+
for _, op in ipairs(file_ops) do
544+
assert(op == false)
545+
end
546+
else
547+
for _, file in ipairs(files) do
548+
for level, dir in ipairs(trg_dirs) do
549+
local p = dir:joinpath(file .. "_" .. level .. ".lua")
550+
-- file 3 is hidden
551+
if not (file == files[3]) then
552+
assert(p:exists())
553+
else
554+
assert(p:exists() == hidden)
555+
end
556+
end
557+
end
558+
end
559+
-- only clean up once we tested that we dont want to copy
560+
-- if `override=true`
561+
if not override then
562+
trg_dir:rm { recursive = true }
563+
end
564+
end
565+
end
566+
567+
src_dir:rm { recursive = true }
568+
end)
465569
end)
466570

467571
describe("parents", function()

0 commit comments

Comments
 (0)