Skip to content

Commit 2d053bc

Browse files
committed
feat(fs): add listdir
1 parent efb3509 commit 2d053bc

3 files changed

Lines changed: 256 additions & 1 deletion

File tree

spec/fs_spec.lua

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,198 @@ describe("mods.fs", function()
209209
end
210210
end)
211211

212+
describe("listdir()", function()
213+
it("returns an empty list for an empty directory", function()
214+
local root = make_tmp_dir()
215+
assert.is_true(fs.listdir(root):isempty())
216+
assert.is_true(fs.rm(root, true))
217+
end)
218+
219+
it("lists direct children by default", function()
220+
local root = make_tmp_dir()
221+
local subdir = join(root, "sub")
222+
local hidden_dir = join(root, ".hidden")
223+
local target = join(root, "data.txt")
224+
local hidden_target = join(root, ".secret")
225+
local nested = join(subdir, "nested.txt")
226+
local hidden_nested = join(hidden_dir, "nested.txt")
227+
228+
assert.is_true(fs.mkdir(subdir))
229+
assert.is_true(fs.mkdir(hidden_dir))
230+
assert.is_true(fs.write_text(target, "abc"))
231+
assert.is_true(fs.write_text(hidden_target, "zzz"))
232+
assert.is_true(fs.write_text(nested, "xyz"))
233+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
234+
235+
assert.same({ hidden_dir, hidden_target, target, subdir }, fs.listdir(root):sort())
236+
237+
assert.is_true(fs.rm(root, true))
238+
end)
239+
240+
it("supports recursive listing", function()
241+
local root = make_tmp_dir()
242+
local subdir = join(root, "sub")
243+
local hidden_dir = join(root, ".hidden")
244+
local target = join(root, "data.txt")
245+
local hidden_target = join(root, ".secret")
246+
local nested = join(subdir, "nested.txt")
247+
local hidden_nested = join(hidden_dir, "nested.txt")
248+
249+
assert.is_true(fs.mkdir(subdir))
250+
assert.is_true(fs.mkdir(hidden_dir))
251+
assert.is_true(fs.write_text(target, "abc"))
252+
assert.is_true(fs.write_text(hidden_target, "zzz"))
253+
assert.is_true(fs.write_text(nested, "xyz"))
254+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
255+
256+
assert.same(
257+
{ hidden_dir, hidden_nested, hidden_target, target, subdir, nested },
258+
fs.listdir(root, { recursive = true }):sort()
259+
)
260+
261+
assert.is_true(fs.rm(root, true))
262+
end)
263+
264+
it("supports hidden filtering", function()
265+
local root = make_tmp_dir()
266+
local subdir = join(root, "sub")
267+
local hidden_dir = join(root, ".hidden")
268+
local target = join(root, "data.txt")
269+
local hidden_target = join(root, ".secret")
270+
local nested = join(subdir, "nested.txt")
271+
local hidden_nested = join(hidden_dir, "nested.txt")
272+
273+
assert.is_true(fs.mkdir(subdir))
274+
assert.is_true(fs.mkdir(hidden_dir))
275+
assert.is_true(fs.write_text(target, "abc"))
276+
assert.is_true(fs.write_text(hidden_target, "zzz"))
277+
assert.is_true(fs.write_text(nested, "xyz"))
278+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
279+
280+
assert.same({ target, subdir }, fs.listdir(root, { hidden = false }):sort())
281+
assert.same({ target, subdir, nested }, fs.listdir(root, { hidden = false, recursive = true }):sort())
282+
283+
assert.is_true(fs.rm(root, true))
284+
end)
285+
286+
it("supports file type filtering", function()
287+
local root = make_tmp_dir()
288+
local subdir = join(root, "sub")
289+
local hidden_dir = join(root, ".hidden")
290+
local target = join(root, "data.txt")
291+
local hidden_target = join(root, ".secret")
292+
local nested = join(subdir, "nested.txt")
293+
local hidden_nested = join(hidden_dir, "nested.txt")
294+
295+
assert.is_true(fs.mkdir(subdir))
296+
assert.is_true(fs.mkdir(hidden_dir))
297+
assert.is_true(fs.write_text(target, "abc"))
298+
assert.is_true(fs.write_text(hidden_target, "zzz"))
299+
assert.is_true(fs.write_text(nested, "xyz"))
300+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
301+
302+
assert.same({ hidden_target, target }, fs.listdir(root, { type = "file" }):sort())
303+
304+
assert.is_true(fs.rm(root, true))
305+
end)
306+
307+
it("supports directory type filtering", function()
308+
local root = make_tmp_dir()
309+
local subdir = join(root, "sub")
310+
local hidden_dir = join(root, ".hidden")
311+
local target = join(root, "data.txt")
312+
local hidden_target = join(root, ".secret")
313+
local nested = join(subdir, "nested.txt")
314+
local hidden_nested = join(hidden_dir, "nested.txt")
315+
316+
assert.is_true(fs.mkdir(subdir))
317+
assert.is_true(fs.mkdir(hidden_dir))
318+
assert.is_true(fs.write_text(target, "abc"))
319+
assert.is_true(fs.write_text(hidden_target, "zzz"))
320+
assert.is_true(fs.write_text(nested, "xyz"))
321+
assert.is_true(fs.write_text(hidden_nested, "qqq"))
322+
323+
assert.same({ hidden_dir, subdir }, fs.listdir(root, { type = "directory" }):sort())
324+
325+
assert.is_true(fs.rm(root, true))
326+
end)
327+
328+
it("fails for a non-directory path", function()
329+
local root = make_tmp_dir()
330+
local target = join(root, "data.txt")
331+
332+
assert.is_true(fs.write_text(target, "abc"))
333+
334+
local items, errmsg = fs.listdir(target)
335+
assert.is_nil(items)
336+
assert.is_string(errmsg)
337+
338+
assert.is_true(fs.rm(root, true))
339+
end)
340+
341+
it("fails for a missing path", function()
342+
local root = make_tmp_dir()
343+
local missing = join(root, "missing")
344+
345+
local items, errmsg = fs.listdir(missing)
346+
assert.is_nil(items)
347+
assert.is_string(errmsg)
348+
349+
assert.is_true(fs.rm(root, true))
350+
end)
351+
352+
if is_unix then
353+
it("follows symlinked directories when requested", function()
354+
local root = make_tmp_dir()
355+
local target_dir = join(root, "target")
356+
local nested = join(target_dir, "nested.txt")
357+
local link_dir = join(root, "linked")
358+
359+
assert.is_true(fs.mkdir(target_dir, true))
360+
assert.is_true(fs.write_text(nested, "abc"))
361+
362+
local ok = lfs.link(target_dir, link_dir, true)
363+
if not ok then
364+
assert.is_true(fs.rm(root, true))
365+
return
366+
end
367+
368+
assert.same({ target_dir }, fs.listdir(root, { type = "directory" }):sort())
369+
assert.same({ link_dir }, fs.listdir(root, { type = "link" }):sort())
370+
assert.same(
371+
{ link_dir, join(link_dir, "nested.txt"), target_dir, nested },
372+
fs.listdir(root, {
373+
recursive = true,
374+
follow_links = true,
375+
}):sort()
376+
)
377+
378+
assert.is_true(fs.rm(root, true))
379+
end)
380+
381+
it("includes broken symlinks and does not traverse them", function()
382+
local root = make_tmp_dir()
383+
local target = join(root, "missing")
384+
local link = join(root, "broken")
385+
386+
assert.is_true(lfs.link(target, link, true))
387+
388+
assert.same({ link }, fs.listdir(root))
389+
assert.same({ link }, fs.listdir(root, { recursive = true }))
390+
assert.same({ link }, fs.listdir(root, { type = "link" }))
391+
392+
assert.is_true(fs.rm(root, true))
393+
end)
394+
end
395+
396+
it("labels option validation errors with the function name", function()
397+
assert.has_error(function()
398+
---@diagnostic disable-next-line: assign-type-mismatch
399+
_ = fs.listdir(cwd, { recursive = "yes" })
400+
end, "listdir.opts.recursive: boolean expected, got string")
401+
end)
402+
end)
403+
212404
describe("mkdir()", function()
213405
it("creates a directory without parent mode", function()
214406
local root = make_tmp_dir()
@@ -669,6 +861,7 @@ describe("mods.fs", function()
669861
assert.has_error(function() fs.getmtime() end, "bad argument #1 to 'getmtime' (string expected, got no value)")
670862
assert.has_error(function() fs.getsize() end, "bad argument #1 to 'getsize' (string expected, got no value)")
671863
assert.has_error(function() fs.lexists({}) end, "bad argument #1 to 'lexists' (string expected, got table)")
864+
assert.has_error(function() fs.listdir(false) end, "bad argument #1 to 'listdir' (string expected, got boolean)")
672865
assert.has_error(function() fs.mkdir() end, "bad argument #1 to 'mkdir' (string expected, got no value)")
673866
assert.has_error(function() fs.read_bytes({}) end, "bad argument #1 to 'read_bytes' (string expected, got table)")
674867
assert.has_error(function() fs.read_text({}) end, "bad argument #1 to 'read_text' (string expected, got table)")
@@ -679,10 +872,23 @@ describe("mods.fs", function()
679872

680873
-- Argument #2 validation.
681874
assert.has_error(function() fs.cp("a") end, "bad argument #2 to 'cp' (string expected, got no value)")
875+
assert.has_error(function() fs.listdir("src", false) end, "bad argument #2 to 'listdir' (table expected, got boolean)")
682876
assert.has_error(function() fs.mkdir("tmp", 1) end, "bad argument #2 to 'mkdir' (boolean expected, got number)")
683877
assert.has_error(function() fs.rm("tmp", 1) end, "bad argument #2 to 'rm' (boolean expected, got number)")
684878
assert.has_error(function() fs.samefile(readme_file, 123) end, "bad argument #2 to 'samefile' (string expected, got number)")
685879
assert.has_error(function() fs.write_bytes(readme_file, {}) end, "bad argument #2 to 'write_bytes' (string expected, got table)")
686880
assert.has_error(function() fs.write_text(readme_file) end, "bad argument #2 to 'write_text' (string expected, got no value)")
881+
882+
-- Option validation.
883+
884+
local hidden = { hidden = 1 }
885+
local rec = { recursive = 1 }
886+
local follow = { follow_links = 1 }
887+
local tp = { type = 1 }
888+
889+
assert.has_error(function() fs.listdir("src", follow) end, "listdir.opts.follow_links: boolean expected, got number")
890+
assert.has_error(function() fs.listdir("src", hidden) end, "listdir.opts.hidden: boolean expected, got number")
891+
assert.has_error(function() fs.listdir("src", rec) end, "listdir.opts.recursive: boolean expected, got number")
892+
assert.has_error(function() fs.listdir("src", tp) end, "listdir.opts.type: string expected, got number")
687893
end)
688894
end)

src/mods/fs.lua

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
local mods = require "mods"
22

3+
local List = mods.List
34
local is = mods.is
45
local path = mods.path
56
local utils = mods.utils
67
local lfs = mods.utils.lazy_module("lfs") ---@module "lfs"
78

8-
local islink = is.link
99
local parents = path.parents
1010
local normpath = path.normpath
1111
local is_relative_to = path.is_relative_to
1212
local basename = path.basename
1313
local join = path.join
1414
local assert_arg = utils.assert_arg
15+
local validate = utils.validate
1516
local isdir = is.dir
17+
local islink = is.link
1618

1719
local open = io.open
1820
local remove = os.remove
@@ -190,6 +192,21 @@ local function copy_tree(src, dst)
190192
return true
191193
end
192194

195+
local function normalize_dir_opts(fname, opts)
196+
opts = opts or {}
197+
validate(fname .. ".opts.hidden", opts.hidden, "boolean", true)
198+
validate(fname .. ".opts.recursive", opts.recursive, "boolean", true)
199+
validate(fname .. ".opts.follow_links", opts.follow_links, "boolean", true)
200+
validate(fname .. ".opts.type", opts.type, "string", true)
201+
202+
return {
203+
follow_links = opts.follow_links == true,
204+
hidden = opts.hidden ~= false,
205+
recursive = opts.recursive == true,
206+
type = opts.type,
207+
}
208+
end
209+
193210
function M.getsize(p)
194211
assert_arg(1, p, "string")
195212
return get_attr(p, "size")
@@ -371,6 +388,24 @@ function M.cp(src, dst)
371388
return true
372389
end
373390

391+
function M.listdir(p, opts)
392+
assert_arg(1, p, "string")
393+
assert_arg(2, opts, "table", true)
394+
opts = normalize_dir_opts("listdir", opts)
395+
396+
local items = {}
397+
local ok, err = collect_dir_items(p, opts, items, true)
398+
if not ok then
399+
return nil, err
400+
end
401+
402+
local out = List()
403+
for i = 1, #items do
404+
out[#out + 1] = items[i][1]
405+
end
406+
return out
407+
end
408+
374409
M.rename = rename
375410

376411
return M

types/fs.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ function M.read_bytes(path) end
3838
---@nodiscard
3939
function M.read_text(path) end
4040

41+
---
42+
---Return direct children of a directory.
43+
---
44+
---```lua
45+
---fs.listdir("src")
46+
---```
47+
---
48+
---@param path string Input path.
49+
---@param opts? {hidden?:boolean, recursive?:boolean, follow_links?:boolean, type?:string} Optional traversal options.
50+
---@return mods.List<string>? paths Direct child paths.
51+
---@return string? err Error message when traversal setup fails.
52+
---@nodiscard
53+
function M.listdir(path, opts) end
54+
4155
--------------------------------------------------------------------------------
4256
----------------------------------- Writing ------------------------------------
4357
--------------------------------------------------------------------------------

0 commit comments

Comments
 (0)