Skip to content

Commit e6e070b

Browse files
committed
feat(fs): add dir
1 parent 2d053bc commit e6e070b

3 files changed

Lines changed: 270 additions & 4 deletions

File tree

spec/fs_spec.lua

Lines changed: 223 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local helpers = require "spec.helpers"
22
local lfs = require "lfs"
33
local mods = require "mods"
44

5+
local List = mods.List
56
local Tree = helpers.Tree
67
local fs = mods.fs
78
local path = mods.path
@@ -401,6 +402,218 @@ describe("mods.fs", function()
401402
end)
402403
end)
403404

405+
describe("dir()", function()
406+
it("yields no items for an empty directory", function()
407+
local root = make_tmp_dir()
408+
local ls = List()
409+
410+
for name, tp in fs.dir(root) do
411+
ls:append(name .. ":" .. tp)
412+
end
413+
414+
assert.is_true(ls:isempty())
415+
assert.is_true(fs.rm(root, true))
416+
end)
417+
418+
it("yields direct child names and types", function()
419+
local root = make_tmp_dir()
420+
local subdir = join(root, "sub")
421+
local nested_dir = join(subdir, "deep")
422+
local target = join(root, "data.txt")
423+
local nested = join(nested_dir, "nested.txt")
424+
local ls = List()
425+
426+
assert.is_true(fs.mkdir(nested_dir, true))
427+
assert.is_true(fs.write_text(target, "abc"))
428+
assert.is_true(fs.write_text(nested, "xyz"))
429+
430+
for name, tp in fs.dir(root) do
431+
ls:append(name .. ":" .. tp)
432+
end
433+
434+
ls:sort()
435+
assert.same({ "data.txt:file", "sub:directory" }, ls)
436+
assert.is_true(fs.rm(root, true))
437+
end)
438+
439+
it("supports hidden filtering", function()
440+
local root = make_tmp_dir()
441+
local subdir = join(root, "sub")
442+
local hidden_dir = join(root, ".hidden")
443+
local nested = join(subdir, "nested.txt")
444+
local hidden_nested = join(hidden_dir, "nested.txt")
445+
local ls = List()
446+
447+
assert.is_true(fs.mkdir(subdir))
448+
assert.is_true(fs.mkdir(hidden_dir))
449+
assert.is_true(fs.write_text(join(root, "data.txt"), "abc"))
450+
assert.is_true(fs.write_text(join(root, ".secret"), "zzz"))
451+
assert.is_true(fs.write_text(nested, "xyz"))
452+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
453+
454+
local opts = { hidden = false, recursive = true }
455+
for name, tp in fs.dir(root, opts) do
456+
ls:append(name .. ":" .. tp)
457+
end
458+
459+
ls:sort()
460+
assert.same({ "data.txt:file", "nested.txt:file", "sub:directory" }, ls)
461+
assert.is_true(fs.rm(root, true))
462+
end)
463+
464+
it("supports file type filtering", function()
465+
local root = make_tmp_dir()
466+
local subdir = join(root, "sub")
467+
local nested_dir = join(subdir, "deep")
468+
local target = join(root, "data.txt")
469+
local nested = join(nested_dir, "nested.txt")
470+
local files = List()
471+
472+
assert.is_true(fs.mkdir(nested_dir, true))
473+
assert.is_true(fs.write_text(target, "abc"))
474+
assert.is_true(fs.write_text(nested, "xyz"))
475+
476+
local opts = { recursive = true, type = "file" }
477+
for name, tp in fs.dir(root, opts) do
478+
files:append(name .. ":" .. tp)
479+
end
480+
481+
files:sort()
482+
assert.same({ "data.txt:file", "nested.txt:file" }, files)
483+
assert.is_true(fs.rm(root, true))
484+
end)
485+
486+
it("supports directory type filtering", function()
487+
local root = make_tmp_dir()
488+
local subdir = join(root, "sub")
489+
local nested_dir = join(subdir, "deep")
490+
local target = join(root, "data.txt")
491+
local nested = join(nested_dir, "nested.txt")
492+
local dirs = List()
493+
494+
assert.is_true(fs.mkdir(nested_dir, true))
495+
assert.is_true(fs.write_text(target, "abc"))
496+
assert.is_true(fs.write_text(nested, "xyz"))
497+
498+
local opts = { recursive = true, type = "directory" }
499+
for name, tp in fs.dir(root, opts) do
500+
dirs:append(name .. ":" .. tp)
501+
end
502+
503+
dirs:sort()
504+
assert.same({ "deep:directory", "sub:directory" }, dirs)
505+
assert.is_true(fs.rm(root, true))
506+
end)
507+
508+
it("fails for a missing path", function()
509+
local root = make_tmp_dir()
510+
local missing = join(root, "missing")
511+
512+
local iter, errmsg = fs.dir(missing)
513+
assert.is_nil(iter)
514+
assert.is_string(errmsg)
515+
assert.is_true(fs.rm(root, true))
516+
end)
517+
518+
it("fails for a non-directory path", function()
519+
local root = make_tmp_dir()
520+
local target = join(root, "data.txt")
521+
522+
assert.is_true(fs.write_text(target, "abc"))
523+
524+
local iter, errmsg = fs.dir(target)
525+
assert.is_nil(iter)
526+
assert.is_string(errmsg)
527+
assert.is_true(fs.rm(root, true))
528+
end)
529+
530+
if is_unix then
531+
it("supports link type filtering", function()
532+
local root = make_tmp_dir()
533+
local target = join(root, "target.txt")
534+
local link = join(root, "linked.txt")
535+
local links = List()
536+
537+
assert.is_true(fs.write_text(target, "abc"))
538+
539+
local ok = lfs.link(target, link, true)
540+
if not ok then
541+
assert.is_true(fs.rm(root, true))
542+
return
543+
end
544+
545+
local opts = { type = "link" }
546+
for name, tp in fs.dir(root, opts) do
547+
links:append(name .. ":" .. tp)
548+
end
549+
550+
assert.same({ "linked.txt:link" }, links)
551+
assert.is_true(fs.rm(root, true))
552+
end)
553+
554+
it("follows symlinked directories when requested", function()
555+
local root = make_tmp_dir()
556+
local target_dir = join(root, "target")
557+
local nested = join(target_dir, "nested.txt")
558+
local link_dir = join(root, "linked")
559+
local ls = List()
560+
561+
assert.is_true(fs.mkdir(target_dir, true))
562+
assert.is_true(fs.write_text(nested, "abc"))
563+
564+
local ok = lfs.link(target_dir, link_dir, true)
565+
if not ok then
566+
assert.is_true(fs.rm(root, true))
567+
return
568+
end
569+
570+
local opts = { recursive = true, follow_links = true }
571+
for name, tp in fs.dir(root, opts) do
572+
ls:append(name .. ":" .. tp)
573+
end
574+
575+
local function contains(xs, value)
576+
for i = 1, #xs do
577+
if xs[i] == value then
578+
return true
579+
end
580+
end
581+
return false
582+
end
583+
584+
ls:sort()
585+
assert.is_true(#ls >= 3)
586+
assert.is_true(contains(ls, "linked:link"))
587+
assert.is_true(contains(ls, "nested.txt:file"))
588+
assert.is_true(fs.rm(root, true))
589+
end)
590+
591+
it("includes broken symlinks and does not traverse them", function()
592+
local root = make_tmp_dir()
593+
local target = join(root, "missing")
594+
local link = join(root, "broken")
595+
local ls = List()
596+
597+
assert.is_true(lfs.link(target, link, true))
598+
599+
local opts = { recursive = true }
600+
for name, tp in fs.dir(root, opts) do
601+
ls:append(name .. ":" .. tp)
602+
end
603+
604+
assert.same({ "broken:link" }, ls)
605+
assert.is_true(fs.rm(root, true))
606+
end)
607+
end
608+
609+
it("labels option validation errors with the function name", function()
610+
assert.has_error(function()
611+
---@diagnostic disable-next-line: assign-type-mismatch
612+
_ = fs.dir(cwd, { recursive = "yes" })
613+
end, "dir.opts.recursive: boolean expected, got string")
614+
end)
615+
end)
616+
404617
describe("mkdir()", function()
405618
it("creates a directory without parent mode", function()
406619
local root = make_tmp_dir()
@@ -855,6 +1068,7 @@ describe("mods.fs", function()
8551068
it("errors on invalid argument types", function()
8561069
-- Argument #1 validation.
8571070
assert.has_error(function() fs.cp(false) end, "bad argument #1 to 'cp' (string expected, got boolean)")
1071+
assert.has_error(function() fs.dir(false) end, "bad argument #1 to 'dir' (string expected, got boolean)")
8581072
assert.has_error(function() fs.exists(true) end, "bad argument #1 to 'exists' (string expected, got boolean)")
8591073
assert.has_error(function() fs.getatime(false) end, "bad argument #1 to 'getatime' (string expected, got boolean)")
8601074
assert.has_error(function() fs.getctime(0) end, "bad argument #1 to 'getctime' (string expected, got number)")
@@ -872,6 +1086,7 @@ describe("mods.fs", function()
8721086

8731087
-- Argument #2 validation.
8741088
assert.has_error(function() fs.cp("a") end, "bad argument #2 to 'cp' (string expected, got no value)")
1089+
assert.has_error(function() fs.dir("src", false) end, "bad argument #2 to 'dir' (table expected, got boolean)")
8751090
assert.has_error(function() fs.listdir("src", false) end, "bad argument #2 to 'listdir' (table expected, got boolean)")
8761091
assert.has_error(function() fs.mkdir("tmp", 1) end, "bad argument #2 to 'mkdir' (boolean expected, got number)")
8771092
assert.has_error(function() fs.rm("tmp", 1) end, "bad argument #2 to 'rm' (boolean expected, got number)")
@@ -881,11 +1096,15 @@ describe("mods.fs", function()
8811096

8821097
-- Option validation.
8831098

884-
local hidden = { hidden = 1 }
885-
local rec = { recursive = 1 }
886-
local follow = { follow_links = 1 }
887-
local tp = { type = 1 }
1099+
local hidden = { hidden = 1 }
1100+
local rec = { recursive = 1 }
1101+
local follow = { follow_links = 1 }
1102+
local tp = { type = 1 }
8881103

1104+
assert.has_error(function() fs.dir("src", follow) end, "dir.opts.follow_links: boolean expected, got number")
1105+
assert.has_error(function() fs.dir("src", hidden) end, "dir.opts.hidden: boolean expected, got number")
1106+
assert.has_error(function() fs.dir("src", rec) end, "dir.opts.recursive: boolean expected, got number")
1107+
assert.has_error(function() fs.dir("src", tp) end, "dir.opts.type: string expected, got number")
8891108
assert.has_error(function() fs.listdir("src", follow) end, "listdir.opts.follow_links: boolean expected, got number")
8901109
assert.has_error(function() fs.listdir("src", hidden) end, "listdir.opts.hidden: boolean expected, got number")
8911110
assert.has_error(function() fs.listdir("src", rec) end, "listdir.opts.recursive: boolean expected, got number")

src/mods/fs.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ local function normalize_dir_opts(fname, opts)
207207
}
208208
end
209209

210+
local function dir_items_iter(state)
211+
local item = state.items[state.index]
212+
if item == nil then
213+
return nil
214+
end
215+
state.index = state.index + 1
216+
return item[1], item[2]
217+
end
218+
210219
function M.getsize(p)
211220
assert_arg(1, p, "string")
212221
return get_attr(p, "size")
@@ -406,6 +415,19 @@ function M.listdir(p, opts)
406415
return out
407416
end
408417

418+
function M.dir(p, opts)
419+
assert_arg(1, p, "string")
420+
assert_arg(2, opts, "table", true)
421+
opts = normalize_dir_opts("dir", opts)
422+
423+
local items = {}
424+
local ok, err = collect_dir_items(p, opts, items, false)
425+
if not ok then
426+
return nil, err
427+
end
428+
return dir_items_iter, { index = 1, items = items }
429+
end
430+
409431
M.rename = rename
410432

411433
return M

types/fs.lua

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
---
44
---Filesystem, environment, and cwd-dependent path operations.
55
---
6+
---## Usage
7+
---
8+
---```lua
9+
---fs = require "mods.fs"
10+
---
11+
---fs.mkdir("tmp/cache/app", true)
12+
---fs.write_text("tmp/cache/app/data.txt", "hello")
13+
---print(fs.read_text("tmp/cache/app/data.txt")) --> "hello"
14+
---```
15+
---
616
---@class mods.fs
717
local M = {}
818

@@ -38,6 +48,21 @@ function M.read_bytes(path) end
3848
---@nodiscard
3949
function M.read_text(path) end
4050

51+
---
52+
---Iterator over items in `path`.
53+
---
54+
---```lua
55+
---for name, type in fs.dir(path.cwd(), { recursive = true }) do
56+
--- print(name, type)
57+
---end
58+
---```
59+
---
60+
---@param path string Input path.
61+
---@param opts? {hidden?:boolean, recursive?:boolean, follow_links?:boolean, type?:string} Optional traversal options.
62+
---@return (fun(state:table, prev?:string):basename:string?, type:"file"|"directory"|"link"|"fifo"|"socket"|"char"|"block"|"unknown"?)? iterator Iterator, or `nil` on failure.
63+
---@return table|string state Iterator state on success, or error message on failure.
64+
function M.dir(path, opts) end
65+
4166
---
4267
---Return direct children of a directory.
4368
---

0 commit comments

Comments
 (0)