Skip to content

Commit 048fe8d

Browse files
committed
feat(fs): add cp helper
1 parent a88b827 commit 048fe8d

3 files changed

Lines changed: 317 additions & 11 deletions

File tree

spec/fs_spec.lua

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ local path = mods.path
99
local isdir = mods.is.dir
1010
local make_tmp_dir = helpers.make_tmp_dir
1111
local tmpname = helpers.tmpname
12-
local join = mods.path.join
12+
local join = path.join
13+
local dirname = path.dirname
14+
1315
local fmt = string.format
1416

1517
describe("mods.fs", function()
1618
local is_unix = not mods.runtime.is_windows
1719
local cwd = path.cwd() --[[@as string]]
18-
local readme_file = path.join(cwd, "README.md")
19-
local spec_file = path.join(cwd, "spec", "fs_spec.lua")
20+
local readme_file = join(cwd, "README.md")
21+
local spec_file = join(cwd, "spec", "fs_spec.lua")
2022

2123
for _, fname in ipairs({ "getsize", "getatime", "getmtime", "getctime" }) do
2224
it(fmt("%s() returns a number for an existing path", fname), function()
@@ -285,6 +287,181 @@ describe("mods.fs", function()
285287
end)
286288
end)
287289

290+
describe("cp()", function()
291+
it("copies binary files", function()
292+
local root = make_tmp_dir()
293+
local src = join(root, "src.bin")
294+
local dst = join(root, "dst.bin")
295+
local body = "a\0b\1c\255z"
296+
297+
assert.is_true(fs.write_bytes(src, body))
298+
assert.is_true(fs.cp(src, dst))
299+
assert.are_equal(body, fs.read_bytes(dst))
300+
301+
assert.is_true(fs.rm(root, true))
302+
end)
303+
304+
it("copies files", function()
305+
local root = make_tmp_dir()
306+
local src = join(root, "src.txt")
307+
local dst = join(root, "dst.txt")
308+
309+
assert.is_true(fs.write_text(src, "abc"))
310+
assert.is_true(fs.cp(src, dst))
311+
assert.are_equal("abc", fs.read_text(dst))
312+
313+
assert.is_true(fs.rm(root, true))
314+
end)
315+
316+
it("overwrites an existing destination file", function()
317+
local root = make_tmp_dir()
318+
local src = join(root, "src.txt")
319+
local dst = join(root, "dst.txt")
320+
321+
assert.is_true(fs.write_text(src, "new"))
322+
assert.is_true(fs.write_text(dst, "old"))
323+
324+
assert.is_true(fs.cp(src, dst))
325+
assert.are_equal("new", fs.read_text(dst))
326+
327+
assert.is_true(fs.rm(root, true))
328+
end)
329+
330+
it("copies empty directories", function()
331+
local root = make_tmp_dir()
332+
local src = join(root, "src")
333+
local empty = join(src, "empty")
334+
local dst = join(root, "copy")
335+
336+
assert.is_true(fs.mkdir(empty, true))
337+
338+
assert.is_true(fs.cp(src, dst))
339+
assert.is_true(isdir(join(dst, "empty")))
340+
341+
assert.is_true(fs.rm(root, true))
342+
end)
343+
344+
it("copies directories recursively", function()
345+
local root = make_tmp_dir()
346+
local src = join(root, "src")
347+
local top_level = join(src, "top.txt")
348+
local nested_dir = join(src, "deep")
349+
local nested = join(nested_dir, "nested.txt")
350+
local dst = join(root, "copy")
351+
352+
assert.is_true(fs.mkdir(nested_dir, true))
353+
assert.is_true(fs.write_text(top_level, "abc"))
354+
assert.is_true(fs.write_text(nested, "xyz"))
355+
356+
assert.is_true(fs.cp(src, dst))
357+
assert.are_equal("abc", fs.read_text(join(dst, "top.txt")))
358+
assert.is_true(fs.exists(join(dst, "deep")))
359+
assert.are_equal("xyz", fs.read_text(join(dst, "deep", "nested.txt")))
360+
361+
assert.is_true(fs.rm(root, true))
362+
end)
363+
364+
it("merges into an existing destination directory", function()
365+
local root = make_tmp_dir()
366+
local src = join(root, "src")
367+
local nested_dir = join(src, "deep")
368+
local src_file = join(src, "top.txt")
369+
local nested = join(nested_dir, "nested.txt")
370+
local dst = join(root, "copy")
371+
local keep = join(dst, "keep.txt")
372+
373+
assert.is_true(fs.mkdir(nested_dir, true))
374+
assert.is_true(fs.mkdir(dst, true))
375+
assert.is_true(fs.write_text(src_file, "abc"))
376+
assert.is_true(fs.write_text(nested, "xyz"))
377+
assert.is_true(fs.write_text(keep, "keep"))
378+
379+
assert.is_true(fs.cp(src, dst))
380+
assert.are_equal("keep", fs.read_text(keep))
381+
assert.are_equal("abc", fs.read_text(join(dst, "top.txt")))
382+
assert.are_equal("xyz", fs.read_text(join(dst, "deep", "nested.txt")))
383+
384+
assert.is_true(fs.rm(root, true))
385+
end)
386+
387+
it("fails when the source is missing", function()
388+
local root = make_tmp_dir()
389+
local missing = join(root, "missing.txt")
390+
local dst = join(root, "dst.txt")
391+
392+
local ok, errmsg, errcode = fs.cp(missing, dst)
393+
assert.are_same({ "nil", "string", "number" }, { type(ok), type(errmsg), type(errcode) })
394+
395+
assert.is_true(fs.rm(root, true))
396+
end)
397+
398+
it("fails when the destination parent is missing", function()
399+
local root = make_tmp_dir()
400+
local src = join(root, "src.txt")
401+
local dst = join(root, "missing", "dst.txt")
402+
403+
assert.is_true(fs.write_text(src, "abc"))
404+
405+
local ok, errmsg, errcode = fs.cp(src, dst)
406+
assert.are_same({ "nil", "string", "number" }, { type(ok), type(errmsg), type(errcode) })
407+
408+
assert.is_true(fs.rm(root, true))
409+
end)
410+
411+
it("fails when a nested destination path conflicts with a file", function()
412+
local root = make_tmp_dir()
413+
local src = join(root, "src")
414+
local nested_dir = join(src, "deep")
415+
local nested = join(nested_dir, "nested.txt")
416+
local dst = join(root, "copy")
417+
local conflicting = join(dst, "deep")
418+
419+
assert.is_true(fs.mkdir(nested_dir, true))
420+
assert.is_true(fs.mkdir(dst, true))
421+
assert.is_true(fs.write_text(nested, "xyz"))
422+
assert.is_true(fs.write_text(conflicting, "not a dir"))
423+
424+
local ok, errmsg, errcode = fs.cp(src, dst)
425+
assert.are_same({ "nil", "string", "number" }, { type(ok), type(errmsg), type(errcode) })
426+
427+
assert.is_true(fs.rm(root, true))
428+
end)
429+
430+
it("fails when copying a directory onto itself", function()
431+
local root = make_tmp_dir()
432+
local src = join(root, "src")
433+
local nested = join(src, "nested.txt")
434+
435+
assert.is_true(fs.mkdir(src, true))
436+
assert.is_true(fs.write_text(nested, "xyz"))
437+
438+
local ok, errmsg, errcode = fs.cp(src, src)
439+
local msg = "cannot copy a directory into itself or its descendant"
440+
assert.are_same({ "nil", msg, "nil" }, { type(ok), errmsg, type(errcode) })
441+
442+
assert.are_equal("xyz", fs.read_text(nested))
443+
assert.is_true(fs.rm(root, true))
444+
end)
445+
446+
it("fails when copying a directory into its descendant", function()
447+
local root = make_tmp_dir()
448+
local src = join(root, "src")
449+
local nested = join(src, "nested.txt")
450+
local dst = join(src, "child", "copy")
451+
452+
assert.is_true(fs.mkdir(src, true))
453+
assert.is_true(fs.write_text(nested, "xyz"))
454+
455+
local ok, errmsg, errcode = fs.cp(src, dst)
456+
local msg = "cannot copy a directory into itself or its descendant"
457+
assert.are_same({ "nil", msg, "nil" }, { type(ok), errmsg, type(errcode) })
458+
459+
assert.is_false(fs.exists(dst))
460+
assert.are_equal("xyz", fs.read_text(nested))
461+
assert.is_true(fs.rm(root, true))
462+
end)
463+
end)
464+
288465
describe("rm()", function()
289466
it("removes a file without recursive mode", function()
290467
local target = tmpname()
@@ -485,6 +662,7 @@ describe("mods.fs", function()
485662
---@diagnostic disable: param-type-mismatch, discard-returns, missing-parameter, assign-type-mismatch
486663
it("errors on invalid argument types", function()
487664
-- Argument #1 validation.
665+
assert.has_error(function() fs.cp(false) end, "bad argument #1 to 'cp' (string expected, got boolean)")
488666
assert.has_error(function() fs.exists(true) end, "bad argument #1 to 'exists' (string expected, got boolean)")
489667
assert.has_error(function() fs.getatime(false) end, "bad argument #1 to 'getatime' (string expected, got boolean)")
490668
assert.has_error(function() fs.getctime(0) end, "bad argument #1 to 'getctime' (string expected, got number)")
@@ -500,6 +678,7 @@ describe("mods.fs", function()
500678
assert.has_error(function() fs.write_text({}) end, "bad argument #1 to 'write_text' (string expected, got table)")
501679

502680
-- Argument #2 validation.
681+
assert.has_error(function() fs.cp("a") end, "bad argument #2 to 'cp' (string expected, got no value)")
503682
assert.has_error(function() fs.mkdir("tmp", 1) end, "bad argument #2 to 'mkdir' (boolean expected, got number)")
504683
assert.has_error(function() fs.rm("tmp", 1) end, "bad argument #2 to 'rm' (boolean expected, got number)")
505684
assert.has_error(function() fs.samefile(readme_file, 123) end, "bad argument #2 to 'samefile' (string expected, got number)")

src/mods/fs.lua

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ local path = mods.path
55
local utils = mods.utils
66
local lfs = mods.utils.lazy_module("lfs") ---@module "lfs"
77

8-
local assert_arg = utils.assert_arg
9-
local isdir = is.dir
108
local islink = is.link
11-
local join = path.join
12-
local path_parents = path.parents
9+
local parents = path.parents
1310
local normpath = path.normpath
11+
local is_relative_to = path.is_relative_to
12+
local basename = path.basename
13+
local join = path.join
14+
local assert_arg = utils.assert_arg
15+
local isdir = is.dir
1416

1517
local open = io.open
1618
local remove = os.remove
@@ -21,11 +23,20 @@ local M = {}
2123

2224
local CURDIR = "."
2325
local PARDIR = ".."
26+
local entry_types = {
27+
["block device"] = "block",
28+
["char device"] = "char",
29+
["named pipe"] = "fifo",
30+
}
2431

2532
local function is_dir_marker(entry)
2633
return entry == CURDIR or entry == PARDIR
2734
end
2835

36+
local function is_hidden(entry)
37+
return entry:sub(1, 1) == "."
38+
end
39+
2940
-- `lfs.dir` throws on failure, so use `pcall` to preserve its error text as `false, err`.
3041
local function open_dir(p)
3142
local ok, iter, dir_obj = pcall(lfs.dir, p)
@@ -100,6 +111,85 @@ local function scan_dir(root, ls, follow_symlinks)
100111
return true
101112
end
102113

114+
local function collect_dir_items(root, opts, items, fullpath)
115+
local iter, dir_obj = open_dir(root)
116+
if not iter then
117+
return false, dir_obj
118+
end
119+
120+
local stat = lfs.attributes
121+
local lstat = lfs.symlinkattributes
122+
local follow = opts.follow_links
123+
local hidden = opts.hidden
124+
local recursive = opts.recursive
125+
local type_filter = opts.type
126+
127+
for entry in iter, dir_obj do
128+
if not is_dir_marker(entry) and (hidden or not is_hidden(entry)) then
129+
local child = join(root, entry)
130+
local link_mode = lstat(child, "mode")
131+
local type = entry_types[link_mode] or link_mode or "unknown"
132+
local child_is_dir = type == "directory"
133+
if follow and type == "link" then
134+
child_is_dir = stat(child, "mode") == "directory"
135+
end
136+
if not type_filter or type_filter == type then
137+
items[#items + 1] = { fullpath and child or entry, type }
138+
end
139+
if recursive and child_is_dir and (follow or type ~= "link") then
140+
local ok, err = collect_dir_items(child, opts, items, fullpath)
141+
if not ok then
142+
return false, err
143+
end
144+
end
145+
end
146+
end
147+
return true
148+
end
149+
150+
local function copy_tree(src, dst)
151+
local normed_src = normpath(src)
152+
local normed_dst = normpath(dst)
153+
if is_relative_to(normed_dst, normed_src) then
154+
return nil, "cannot copy a directory into itself or its descendant"
155+
end
156+
157+
local ok, errmsg, errcode = M.mkdir(dst, true)
158+
if not ok then
159+
return nil, errmsg, errcode
160+
end
161+
162+
local items = {}
163+
local collected, collect_err = collect_dir_items(src, { recursive = false }, items, true)
164+
if not collected then
165+
return nil, collect_err
166+
end
167+
168+
for i = 1, #items do
169+
local child, type_ = items[i][1], items[i][2]
170+
local target = join(dst, basename(child))
171+
if type_ == "directory" then
172+
local copied, errmsg, errcode = copy_tree(child, target)
173+
if not copied then
174+
return nil, errmsg, errcode
175+
end
176+
else
177+
local body
178+
body, errmsg, errcode = M.read_bytes(child)
179+
if not body then
180+
return nil, errmsg, errcode
181+
end
182+
183+
ok, errmsg, errcode = M.write_bytes(target, body)
184+
if not ok then
185+
return nil, errmsg, errcode
186+
end
187+
end
188+
end
189+
190+
return true
191+
end
192+
103193
function M.getsize(p)
104194
assert_arg(1, p, "string")
105195
return get_attr(p, "size")
@@ -231,17 +321,17 @@ function M.rm(p, recursive)
231321
return remove(p)
232322
end
233323

234-
function M.mkdir(p, parents)
324+
function M.mkdir(p, parents_)
235325
assert_arg(1, p, "string")
236-
assert_arg(2, parents, "boolean", true)
326+
assert_arg(2, parents_, "boolean", true)
237327

238328
local mkdir = lfs.mkdir
239-
if not parents then
329+
if not parents_ then
240330
return mkdir(p)
241331
end
242332

243333
local normed = normpath(p)
244-
local parents_dirs = path_parents(normed)
334+
local parents_dirs = parents(normed)
245335
for i = #parents_dirs, 1, -1 do
246336
local dir = parents_dirs[i]
247337
if dir ~= CURDIR and not isdir(dir) then
@@ -259,6 +349,28 @@ function M.mkdir(p, parents)
259349
return true
260350
end
261351

352+
function M.cp(src, dst)
353+
assert_arg(1, src, "string")
354+
assert_arg(2, dst, "string")
355+
356+
if isdir(src) then
357+
return copy_tree(src, dst)
358+
end
359+
360+
local body, errmsg, errcode = M.read_bytes(src)
361+
if not body then
362+
return nil, errmsg, errcode
363+
end
364+
365+
local ok
366+
ok, errmsg, errcode = M.write_bytes(dst, body)
367+
if not ok then
368+
return nil, errmsg, errcode
369+
end
370+
371+
return true
372+
end
373+
262374
M.rename = rename
263375

264376
return M

0 commit comments

Comments
 (0)