• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lunarmodules / Penlight / 29825081125

21 Jul 2026 11:10AM UTC coverage: 90.05% (-0.1%) from 90.174%
29825081125

push

github

web-flow
Merge b975859a6 into c317508c9

5 of 7 new or added lines in 1 file covered. (71.43%)

7 existing lines in 2 files now uncovered.

5539 of 6151 relevant lines covered (90.05%)

824.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.84
/lua/pl/dir.lua
1
--- Listing files in directories and creating/removing directory paths.
2
--
3
-- Dependencies: `pl.utils`, `pl.path`
4
--
5
-- Soft Dependencies: `alien`, `ffi` (either are used on Windows for copying/moving files)
6
-- @module pl.dir
7

8
local utils = require 'pl.utils'
38✔
9
local path = require 'pl.path'
38✔
10
local is_windows = path.is_windows
38✔
11
local ldir = path.dir
38✔
12
local mkdir = path.mkdir
38✔
13
local rmdir = path.rmdir
38✔
14
local sub = string.sub
38✔
15
local os,pcall,ipairs,pairs,require,setmetatable = os,pcall,ipairs,pairs,require,setmetatable
38✔
16
local remove = os.remove
38✔
17
local append = table.insert
38✔
18
local assert_arg,assert_string,raise = utils.assert_arg,utils.assert_string,utils.raise
38✔
19

20
local exists, isdir = path.exists, path.isdir
38✔
21
local sep = path.sep
38✔
22

23
local dir = {}
38✔
24

25
local function makelist(l)
26
    return setmetatable(l, require('pl.List'))
418✔
27
end
28

29
local function assert_dir (n,val)
30
    assert_arg(n,val,'string',path.isdir,'not a directory',4)
304✔
31
end
32

33
local function filemask(mask)
34
    mask = utils.escape(path.normcase(mask))
2,929✔
35
    return '^'..mask:gsub('%%%*','.*'):gsub('%%%?','.')..'$'
1,919✔
36
end
37

38
--- Test whether a file name matches a shell pattern.
39
-- Both parameters are case-normalized if operating system is
40
-- case-insensitive.
41
-- @string filename A file name.
42
-- @string pattern A shell pattern. The only special characters are
43
-- `'*'` and `'?'`: `'*'` matches any sequence of characters and
44
-- `'?'` matches any single character.
45
-- @treturn bool
46
-- @raise dir and mask must be strings
47
function dir.fnmatch(filename,pattern)
38✔
48
    assert_string(1,filename)
171✔
49
    assert_string(2,pattern)
171✔
50
    return path.normcase(filename):find(filemask(pattern)) ~= nil
261✔
51
end
52

53
--- Return a list of all file names within an array which match a pattern.
54
-- @tab filenames An array containing file names.
55
-- @string pattern A shell pattern (see `fnmatch`).
56
-- @treturn List(string) List of matching file names.
57
-- @raise dir and mask must be strings
58
function dir.filter(filenames,pattern)
38✔
59
    assert_arg(1,filenames,'table')
19✔
60
    assert_string(2,pattern)
19✔
61
    local res = {}
19✔
62
    local mask = filemask(pattern)
19✔
63
    for i,f in ipairs(filenames) do
95✔
64
        if path.normcase(f):find(mask) then append(res,f) end
96✔
65
    end
66
    return makelist(res)
19✔
67
end
68

69
local function _listfiles(dirname,filemode,match)
70
    local res = {}
76✔
71
    local check = utils.choose(filemode,path.isfile,path.isdir)
76✔
72
    if not dirname then dirname = '.' end
76✔
73
    for f in ldir(dirname) do
1,330✔
74
        if f ~= '.' and f ~= '..' then
1,254✔
75
            local p = path.join(dirname,f)
1,102✔
76
            if check(p) and (not match or match(f)) then
1,402✔
77
                append(res,p)
445✔
78
            end
79
        end
80
    end
81
    return makelist(res)
76✔
82
end
83

84
--- return a list of all files in a directory which match a shell pattern.
85
-- @string[opt='.'] dirname A directory.
86
-- @string[opt] mask A shell pattern (see `fnmatch`). If not given, all files are returned.
87
-- @treturn {string} list of files
88
-- @raise dirname and mask must be strings
89
function dir.getfiles(dirname,mask)
38✔
90
    dirname = dirname or '.'
57✔
91
    assert_dir(1,dirname)
57✔
92
    if mask then assert_string(2,mask) end
57✔
93
    local match
94
    if mask then
57✔
95
        mask = filemask(mask)
24✔
96
        match = function(f)
97
            return path.normcase(f):find(mask)
48✔
98
        end
99
    end
100
    return _listfiles(dirname,true,match)
57✔
101
end
102

103
--- return a list of all subdirectories of the directory.
104
-- @string[opt='.'] dirname A directory.
105
-- @treturn {string} a list of directories
106
-- @raise dir must be a valid directory
107
function dir.getdirectories(dirname)
38✔
108
    dirname = dirname or '.'
19✔
109
    assert_dir(1,dirname)
19✔
110
    return _listfiles(dirname,false)
19✔
111
end
112

113
local alien,ffi,ffi_checked,CopyFile,MoveFile,GetLastError,win32_errors,cmd_tmpfile
114

115
local function execute_command(cmd,parms)
116
   if not cmd_tmpfile then cmd_tmpfile = path.tmpname () end
98✔
117
   local err = path.is_windows and ' > ' or ' 2> '
94✔
118
    cmd = cmd..' '..parms..err..utils.quote_arg(cmd_tmpfile)
114✔
119
    local ret = utils.execute(cmd)
94✔
120
    if not ret then
94✔
121
        local err = (utils.readfile(cmd_tmpfile):gsub('\n(.*)',''))
66✔
122
        remove(cmd_tmpfile)
54✔
123
        return false,err
54✔
124
    else
125
        remove(cmd_tmpfile)
40✔
126
        return true
40✔
127
    end
128
end
129

130
local function find_ffi_copyfile ()
131
    if not ffi_checked then
25✔
132
        ffi_checked = true
5✔
133
        local res
134
        res,alien = pcall(require,'alien')
5✔
135
        if not res then
5✔
136
            alien = nil
5✔
137
            res, ffi = pcall(require,'ffi')
5✔
138
        end
139
        if not res then
5✔
140
            ffi = nil
4✔
141
            return
4✔
142
        end
143
    else
144
        return
20✔
145
    end
146
    if alien then
1✔
147
        -- register the Win32 CopyFile and MoveFile functions
148
        local kernel = alien.load('kernel32.dll')
×
149
        CopyFile = kernel.CopyFileA
×
150
        CopyFile:types{'string','string','int',ret='int',abi='stdcall'}
×
151
        MoveFile = kernel.MoveFileA
×
152
        MoveFile:types{'string','string',ret='int',abi='stdcall'}
×
153
        GetLastError = kernel.GetLastError
×
154
        GetLastError:types{ret ='int', abi='stdcall'}
×
155
    elseif ffi then
1✔
156
        ffi.cdef [[
1✔
157
            int CopyFileA(const char *src, const char *dest, int iovr);
158
            int MoveFileA(const char *src, const char *dest);
159
            int GetLastError();
160
        ]]
1✔
161
        CopyFile = ffi.C.CopyFileA
1✔
162
        MoveFile = ffi.C.MoveFileA
1✔
163
        GetLastError = ffi.C.GetLastError
1✔
164
    end
165
    win32_errors = {
1✔
166
        ERROR_FILE_NOT_FOUND    =         2,
167
        ERROR_PATH_NOT_FOUND    =         3,
168
        ERROR_ACCESS_DENIED    =          5,
169
        ERROR_WRITE_PROTECT    =          19,
170
        ERROR_BAD_UNIT         =          20,
171
        ERROR_NOT_READY        =          21,
172
        ERROR_WRITE_FAULT      =          29,
173
        ERROR_READ_FAULT       =          30,
174
        ERROR_SHARING_VIOLATION =         32,
175
        ERROR_LOCK_VIOLATION    =         33,
176
        ERROR_HANDLE_DISK_FULL  =         39,
177
        ERROR_BAD_NETPATH       =         53,
178
        ERROR_NETWORK_BUSY      =         54,
179
        ERROR_DEV_NOT_EXIST     =         55,
180
        ERROR_FILE_EXISTS       =         80,
181
        ERROR_OPEN_FAILED       =         110,
182
        ERROR_INVALID_NAME      =         123,
183
        ERROR_BAD_PATHNAME      =         161,
184
        ERROR_ALREADY_EXISTS    =         183,
185
    }
1✔
186
end
187

188
local function two_arguments (f1,f2)
189
    return utils.quote_arg(f1)..' '..utils.quote_arg(f2)
130✔
190
end
191

192
local function file_op (is_copy,src,dest,flag)
193
    if flag == 1 and path.exists(dest) then
95✔
194
        return false,"cannot overwrite destination"
×
195
    end
196
    if is_windows then
95✔
197
        -- if we haven't tried to load Alien/LuaJIT FFI before, then do so
198
        find_ffi_copyfile()
25✔
199
        -- fallback if there's no Alien, just use DOS commands *shudder*
200
        -- 'rename' involves a copy and then deleting the source.
201
        if not CopyFile then
25✔
202
            if path.is_windows then
20✔
203
                src = src:gsub("/","\\")
20✔
204
                dest = dest:gsub("/","\\")
20✔
205
            end
206
            local res, err = execute_command('copy',two_arguments(src,dest))
20✔
207
            if not res then return false,err end
20✔
208
            if not is_copy then
8✔
209
                return execute_command('del',utils.quote_arg(src))
4✔
210
            end
211
            return true
4✔
212
        else
213
            if path.isdir(dest) then
10✔
214
                dest = path.join(dest,path.basename(src))
×
215
            end
216
            local ret
217
            if is_copy then ret = CopyFile(src,dest,flag)
5✔
218
            else ret = MoveFile(src,dest) end
2✔
219
            if ret == 0 then
5✔
220
                local err = GetLastError()
3✔
221
                for name,value in pairs(win32_errors) do
30✔
222
                    if value == err then return false,name end
30✔
223
                end
224
                return false,"Error #"..err
×
225
            else return true
2✔
226
            end
227
        end
228
    else -- for Unix, just use cp for now
229
        return execute_command(is_copy and 'cp' or 'mv',
120✔
230
            two_arguments(src,dest))
90✔
231
    end
232
end
233

234
--- copy a file.
235
-- @string src source file
236
-- @string dest destination file or directory
237
-- @bool flag true if you want to force the copy (default)
238
-- @treturn bool operation succeeded
239
-- @raise src and dest must be strings
240
function dir.copyfile (src,dest,flag)
38✔
241
    assert_string(1,src)
57✔
242
    assert_string(2,dest)
57✔
243
    flag = flag==nil or flag
57✔
244
    return file_op(true,src,dest,flag and 0 or 1)
57✔
245
end
246

247
--- move a file.
248
-- @string src source file
249
-- @string dest destination file or directory
250
-- @treturn bool operation succeeded
251
-- @raise src and dest must be strings
252
function dir.movefile (src,dest)
38✔
253
    assert_string(1,src)
38✔
254
    assert_string(2,dest)
38✔
255
    return file_op(false,src,dest,0)
38✔
256
end
257

258
local function _dirfiles(dirname,attrib)
259
    local dirs = {}
133✔
260
    local files = {}
133✔
261
    for f in ldir(dirname) do
608✔
262
        if f ~= '.' and f ~= '..' then
475✔
263
            local p = path.join(dirname,f)
209✔
264
            local mode = attrib(p,'mode')
209✔
265
            if mode=='directory' then
209✔
266
                append(dirs,f)
57✔
267
            else
268
                append(files,f)
152✔
269
            end
270
        end
271
    end
272
    return makelist(dirs), makelist(files)
203✔
273
end
274

275

276
--- return an iterator which walks through a directory tree starting at root.
277
-- The iterator returns (root,dirs,files)
278
-- Note that dirs and files are lists of names (i.e. you must say path.join(root,d)
279
-- to get the actual full path)
280
-- If bottom_up is false (or not present), then the entries at the current level are returned
281
-- before we go deeper. This means that you can modify the returned list of directories before
282
-- continuing.
283
-- This is a clone of os.walk from the Python libraries.
284
-- @string root A starting directory
285
-- @bool bottom_up False if we start listing entries immediately.
286
-- @bool follow_links follow symbolic links
287
-- @return an iterator returning root,dirs,files
288
-- @raise root must be a directory
289
function dir.walk(root,bottom_up,follow_links)
38✔
290
    assert_dir(1,root)
76✔
291
    local attrib
292
    if follow_links then
76✔
UNCOV
293
        attrib = path.attrib
×
294
    else
295
        attrib = path.link_attrib
76✔
296
    end
297

298
    local to_scan = { root }
76✔
299
    local to_return = {}
76✔
300
    local iter = function()
301
        while #to_scan > 0 do
342✔
302
            local current_root = table.remove(to_scan)
133✔
303
            local dirs,files = _dirfiles(current_root, attrib)
133✔
304
            for _, d in ipairs(dirs) do
190✔
305
                table.insert(to_scan, current_root..path.sep..d)
57✔
306
            end
307
            if not bottom_up then
133✔
308
                return current_root, dirs, files
×
309
            else
310
                table.insert(to_return, { current_root, dirs, files })
133✔
311
            end
312
        end
313
        if #to_return > 0 then
209✔
314
            return utils.unpack(table.remove(to_return))
168✔
315
        end
316
    end
317

318
    return iter
76✔
319
end
320

321
--- remove a whole directory tree.
322
-- Symlinks in the tree will be deleted without following them.
323
-- @string fullpath A directory path (must be an actual directory, not a symlink)
324
-- @return true or nil
325
-- @return error if failed
326
-- @raise fullpath must be a string
327
function dir.rmtree(fullpath)
38✔
328
    assert_dir(1,fullpath)
95✔
329
    if path.islink(fullpath) then return false,'will not follow symlink' end
120✔
330
    for root,dirs,files in dir.walk(fullpath,true) do
284✔
331
        if path.islink(root) then
168✔
332
            -- sub dir is a link, remove link, do not follow
UNCOV
333
            if is_windows then
×
334
                -- Windows requires using "rmdir". Deleting the link like a file
335
                -- will instead delete all files from the target directory!!
336
                local res, err = rmdir(root)
×
337
                if not res then return nil,err .. ": " .. root end
×
338
            else
UNCOV
339
                local res, err = remove(root)
×
UNCOV
340
                if not res then return nil,err .. ": " .. root end
×
341
            end
342
        else
343
            for i,f in ipairs(files) do
285✔
344
                local p = path.join(root,f)
152✔
345
                if is_windows and path.islink(p) and path.isdir(p) then
162✔
346
                    -- Windows requires using "rmdir" for directory symlinks/junctions.
347
                    -- Deleting them like a file fails with "permission denied".
NEW
348
                    local res, err = rmdir(p)
10✔
NEW
349
                    if not res then return nil,err .. ": " .. p end
10✔
350
                else
351
                    local res, err = remove(p)
142✔
352
                    if not res then return nil,err .. ": " .. p end
142✔
353
                end
354
            end
355
            local res, err = rmdir(root)
133✔
356
            if not res then return nil,err .. ": " .. root end
133✔
357
        end
358
    end
359
    return true
76✔
360
end
361

362

363
do
364
  local dirpat
365
  if path.is_windows then
38✔
366
      dirpat = '(.+)\\[^\\]+$'
10✔
367
  else
368
      dirpat = '(.+)/[^/]+$'
28✔
369
  end
370

371
  local _makepath
372
  function _makepath(p)
32✔
373
      -- windows root drive case
374
      if p:find '^%a:[\\]*$' then
361✔
375
          return true
×
376
      end
377
      if not path.isdir(p) then
456✔
378
          local subp = p:match(dirpat)
209✔
379
          if subp then
209✔
380
            local ok, err = _makepath(subp)
209✔
381
            if not ok then return nil, err end
209✔
382
          end
383
          return mkdir(p)
209✔
384
      else
385
          return true
152✔
386
      end
387
  end
388

389
  --- create a directory path.
390
  -- This will create subdirectories as necessary!
391
  -- @string p A directory path
392
  -- @return true on success, nil + errormsg on failure
393
  -- @raise failure to create
394
  function dir.makepath (p)
38✔
395
      assert_string(1,p)
152✔
396
      if path.is_windows then
152✔
397
          p = p:gsub("/", "\\")
40✔
398
      end
399
      return _makepath(path.abspath(p))
192✔
400
  end
401
end
402

403
--- clone a directory tree. Will always try to create a new directory structure
404
-- if necessary.
405
-- @string path1 the base path of the source tree
406
-- @string path2 the new base path for the destination
407
-- @func file_fun an optional function to apply on all files
408
-- @bool verbose an optional boolean to control the verbosity of the output.
409
--  It can also be a logging function that behaves like print()
410
-- @return true, or nil
411
-- @return error message, or list of failed directory creations
412
-- @return list of failed file operations
413
-- @raise path1 and path2 must be strings
414
-- @usage clonetree('.','../backup',copyfile)
415
function dir.clonetree (path1,path2,file_fun,verbose)
38✔
416
    assert_string(1,path1)
×
417
    assert_string(2,path2)
×
418
    if verbose == true then verbose = print end
×
419
    local abspath,normcase,isdir,join = path.abspath,path.normcase,path.isdir,path.join
×
420
    local faildirs,failfiles = {},{}
×
421
    if not isdir(path1) then return raise 'source is not a valid directory' end
×
422
    path1 = abspath(normcase(path1))
×
423
    path2 = abspath(normcase(path2))
×
424
    if verbose then verbose('normalized:',path1,path2) end
×
425
    -- particularly NB that the new path isn't fully contained in the old path
426
    if path1 == path2 then return raise "paths are the same" end
×
427
    local _,i2 = path2:find(path1,1,true)
×
428
    if i2 == #path1 and path2:sub(i2+1,i2+1) == path.sep then
×
429
        return raise 'destination is a subdirectory of the source'
×
430
    end
431
    local cp = path.common_prefix (path1,path2)
×
432
    local idx = #cp
×
433
    if idx == 0 then -- no common path, but watch out for Windows paths!
×
434
        if path1:sub(2,2) == ':' then idx = 3 end
×
435
    end
436
    for root,dirs,files in dir.walk(path1) do
×
437
        local opath = path2..root:sub(idx)
×
438
        if verbose then verbose('paths:',opath,root) end
×
439
        if not isdir(opath) then
×
440
            local ret = dir.makepath(opath)
×
441
            if not ret then append(faildirs,opath) end
×
442
            if verbose then verbose('creating:',opath,ret) end
×
443
        end
444
        if file_fun then
×
445
            for i,f in ipairs(files) do
×
446
                local p1 = join(root,f)
×
447
                local p2 = join(opath,f)
×
448
                local ret = file_fun(p1,p2)
×
449
                if not ret then append(failfiles,p2) end
×
450
                if verbose then
×
451
                    verbose('files:',p1,p2,ret)
×
452
                end
453
            end
454
        end
455
    end
456
    return true,faildirs,failfiles
×
457
end
458

459

460
-- each entry of the stack is an array with three items:
461
-- 1. the name of the directory
462
-- 2. the lfs iterator function
463
-- 3. the lfs iterator userdata
464
local function treeiter(iterstack)
465
    local diriter = iterstack[#iterstack]
2,318✔
466
    if not diriter then
2,318✔
467
      return -- done
57✔
468
    end
469

470
    local dirname = diriter[1]
2,261✔
471
    local entry = diriter[2](diriter[3])
2,261✔
472
    if not entry then
2,261✔
473
      table.remove(iterstack)
152✔
474
      return treeiter(iterstack) -- tail-call to try next
152✔
475
    end
476

477
    if entry ~= "." and entry ~= ".." then
2,109✔
478
        entry = dirname .. sep .. entry
1,805✔
479
        if exists(entry) then  -- Just in case a symlink is broken.
2,280✔
480
            local is_dir = isdir(entry)
1,805✔
481
            if is_dir then
1,805✔
482
                table.insert(iterstack, { entry, ldir(entry) })
95✔
483
            end
484
            return entry, is_dir
1,805✔
485
        end
486
    end
487

488
    return treeiter(iterstack) -- tail-call to try next
304✔
489
end
490

491

492
--- return an iterator over all entries in a directory tree
493
-- @string d a directory
494
-- @return an iterator giving pathname and mode (true for dir, false otherwise)
495
-- @raise d must be a non-empty string
496
function dir.dirtree( d )
38✔
497
    assert( d and d ~= "", "directory parameter is missing or empty" )
57✔
498

499
    local last = sub ( d, -1 )
57✔
500
    if last == sep or last == '/' then
57✔
501
        d = sub( d, 1, -2 )
×
502
    end
503

504
    local iterstack = { {d, ldir(d)} }
57✔
505

506
    return treeiter, iterstack
57✔
507
end
508

509

510
--- Recursively returns all the file starting at 'path'. It can optionally take a shell pattern and
511
-- only returns files that match 'shell_pattern'. If a pattern is given it will do a case insensitive search.
512
-- @string[opt='.'] start_path  A directory.
513
-- @string[opt='*'] shell_pattern A shell pattern (see `fnmatch`).
514
-- @treturn List(string) containing all the files found recursively starting at 'path' and filtered by 'shell_pattern'.
515
-- @raise start_path must be a directory
516
function dir.getallfiles( start_path, shell_pattern )
38✔
517
    start_path = start_path or '.'
57✔
518
    assert_dir(1,start_path)
57✔
519
    shell_pattern = shell_pattern or "*"
57✔
520

521
    local files = {}
57✔
522
    local normcase = path.normcase
57✔
523
    for filename, mode in dir.dirtree( start_path ) do
2,367✔
524
        if not mode then
1,805✔
525
            local mask = filemask( shell_pattern )
1,710✔
526
            if normcase(filename):find( mask ) then
2,160✔
527
                files[#files + 1] = filename
456✔
528
            end
529
        end
530
    end
531

532
    return makelist(files)
57✔
533
end
534

535
return dir
38✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc