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

lunarmodules / Penlight / 7742759193

01 Feb 2024 02:24PM UTC coverage: 88.938% (-0.7%) from 89.675%
7742759193

push

github

web-flow
docs: Fix typos (#462)

7 of 7 new or added lines in 2 files covered. (100.0%)

120 existing lines in 14 files now uncovered.

5443 of 6120 relevant lines covered (88.94%)

256.25 hits per line

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

65.61
/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'
12✔
9
local path = require 'pl.path'
12✔
10
local is_windows = path.is_windows
12✔
11
local ldir = path.dir
12✔
12
local mkdir = path.mkdir
12✔
13
local rmdir = path.rmdir
12✔
14
local sub = string.sub
12✔
15
local os,pcall,ipairs,pairs,require,setmetatable = os,pcall,ipairs,pairs,require,setmetatable
12✔
16
local remove = os.remove
12✔
17
local append = table.insert
12✔
18
local assert_arg,assert_string,raise = utils.assert_arg,utils.assert_string,utils.raise
12✔
19

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

23
local dir = {}
12✔
24

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

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

33
local function filemask(mask)
34
    mask = utils.escape(path.normcase(mask))
990✔
35
    return '^'..mask:gsub('%%%*','.*'):gsub('%%%?','.')..'$'
594✔
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)
12✔
48
    assert_string(1,filename)
54✔
49
    assert_string(2,pattern)
54✔
50
    return path.normcase(filename):find(filemask(pattern)) ~= nil
90✔
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)
12✔
59
    assert_arg(1,filenames,'table')
6✔
60
    assert_string(2,pattern)
6✔
61
    local res = {}
6✔
62
    local mask = filemask(pattern)
6✔
63
    for i,f in ipairs(filenames) do
30✔
64
        if path.normcase(f):find(mask) then append(res,f) end
32✔
65
    end
66
    return makelist(res)
6✔
67
end
68

69
local function _listfiles(dirname,filemode,match)
70
    local res = {}
24✔
71
    local check = utils.choose(filemode,path.isfile,path.isdir)
24✔
72
    if not dirname then dirname = '.' end
24✔
73
    for f in ldir(dirname) do
582✔
74
        if f ~= '.' and f ~= '..' then
558✔
75
            local p = path.join(dirname,f)
510✔
76
            if check(p) and (not match or match(f)) then
684✔
77
                append(res,p)
144✔
78
            end
79
        end
80
    end
81
    return makelist(res)
24✔
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)
12✔
90
    dirname = dirname or '.'
18✔
91
    assert_dir(1,dirname)
18✔
92
    if mask then assert_string(2,mask) end
18✔
93
    local match
94
    if mask then
18✔
95
        mask = filemask(mask)
8✔
96
        match = function(f)
97
            return path.normcase(f):find(mask)
16✔
98
        end
99
    end
100
    return _listfiles(dirname,true,match)
18✔
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)
12✔
108
    dirname = dirname or '.'
6✔
109
    assert_dir(1,dirname)
6✔
110
    return _listfiles(dirname,false)
6✔
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
32✔
117
   local err = path.is_windows and ' > ' or ' 2> '
30✔
118
    cmd = cmd..' '..parms..err..utils.quote_arg(cmd_tmpfile)
40✔
119
    local ret = utils.execute(cmd)
30✔
120
    if not ret then
30✔
121
        local err = (utils.readfile(cmd_tmpfile):gsub('\n(.*)',''))
24✔
122
        remove(cmd_tmpfile)
18✔
123
        return false,err
18✔
124
    else
125
        remove(cmd_tmpfile)
12✔
126
        return true
12✔
127
    end
128
end
129

130
local function find_ffi_copyfile ()
UNCOV
131
    if not ffi_checked then
×
UNCOV
132
        ffi_checked = true
×
133
        local res
UNCOV
134
        res,alien = pcall(require,'alien')
×
UNCOV
135
        if not res then
×
UNCOV
136
            alien = nil
×
UNCOV
137
            res, ffi = pcall(require,'ffi')
×
138
        end
UNCOV
139
        if not res then
×
UNCOV
140
            ffi = nil
×
UNCOV
141
            return
×
142
        end
143
    else
UNCOV
144
        return
×
145
    end
146
    if alien then
×
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
×
156
        ffi.cdef [[
×
157
            int CopyFileA(const char *src, const char *dest, int iovr);
158
            int MoveFileA(const char *src, const char *dest);
159
            int GetLastError();
160
        ]]
×
161
        CopyFile = ffi.C.CopyFileA
×
162
        MoveFile = ffi.C.MoveFileA
×
163
        GetLastError = ffi.C.GetLastError
×
164
    end
165
    win32_errors = {
×
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
    }
186
end
187

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

192
local function file_op (is_copy,src,dest,flag)
193
    if flag == 1 and path.exists(dest) then
30✔
194
        return false,"cannot overwrite destination"
×
195
    end
196
    if is_windows then
30✔
197
        -- if we haven't tried to load Alien/LuaJIT FFI before, then do so
UNCOV
198
        find_ffi_copyfile()
×
199
        -- fallback if there's no Alien, just use DOS commands *shudder*
200
        -- 'rename' involves a copy and then deleting the source.
UNCOV
201
        if not CopyFile then
×
UNCOV
202
            if path.is_windows then
×
UNCOV
203
                src = src:gsub("/","\\")
×
UNCOV
204
                dest = dest:gsub("/","\\")
×
205
            end
UNCOV
206
            local res, err = execute_command('copy',two_arguments(src,dest))
×
UNCOV
207
            if not res then return false,err end
×
UNCOV
208
            if not is_copy then
×
UNCOV
209
                return execute_command('del',utils.quote_arg(src))
×
210
            end
UNCOV
211
            return true
×
212
        else
213
            if path.isdir(dest) then
×
214
                dest = path.join(dest,path.basename(src))
×
215
            end
216
            local ret
217
            if is_copy then ret = CopyFile(src,dest,flag)
×
218
            else ret = MoveFile(src,dest) end
×
219
            if ret == 0 then
×
220
                local err = GetLastError()
×
221
                for name,value in pairs(win32_errors) do
×
222
                    if value == err then return false,name end
×
223
                end
224
                return false,"Error #"..err
×
225
            else return true
×
226
            end
227
        end
228
    else -- for Unix, just use cp for now
229
        return execute_command(is_copy and 'cp' or 'mv',
50✔
230
            two_arguments(src,dest))
40✔
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)
12✔
241
    assert_string(1,src)
18✔
242
    assert_string(2,dest)
18✔
243
    flag = flag==nil or flag
18✔
244
    return file_op(true,src,dest,flag and 0 or 1)
18✔
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)
12✔
253
    assert_string(1,src)
12✔
254
    assert_string(2,dest)
12✔
255
    return file_op(false,src,dest,0)
12✔
256
end
257

258
local function _dirfiles(dirname,attrib)
259
    local dirs = {}
30✔
260
    local files = {}
30✔
261
    for f in ldir(dirname) do
138✔
262
        if f ~= '.' and f ~= '..' then
108✔
263
            local p = path.join(dirname,f)
48✔
264
            local mode = attrib(p,'mode')
48✔
265
            if mode=='directory' then
48✔
266
                append(dirs,f)
18✔
267
            else
268
                append(files,f)
30✔
269
            end
270
        end
271
    end
272
    return makelist(dirs), makelist(files)
50✔
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)
12✔
290
    assert_dir(1,root)
12✔
291
    local attrib
292
    if path.is_windows or not follow_links then
12✔
293
        attrib = path.attrib
12✔
294
    else
295
        attrib = path.link_attrib
×
296
    end
297

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

318
    return iter
12✔
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)
12✔
328
    assert_dir(1,fullpath)
18✔
329
    if path.islink(fullpath) then return false,'will not follow symlink' end
24✔
330
    for root,dirs,files in dir.walk(fullpath,true) do
60✔
331
        if path.islink(root) then
40✔
332
            -- sub dir is a link, remove link, do not follow
333
            if is_windows then
6✔
334
                -- Windows requires using "rmdir". Deleting the link like a file
335
                -- will instead delete all files from the target directory!!
UNCOV
336
                local res, err = rmdir(root)
×
UNCOV
337
                if not res then return nil,err .. ": " .. root end
×
338
            else
339
                local res, err = remove(root)
6✔
340
                if not res then return nil,err .. ": " .. root end
6✔
341
            end
342
        else
343
            for i,f in ipairs(files) do
48✔
344
                local res, err = remove(path.join(root,f))
32✔
345
                if not res then return nil,err .. ": " .. path.join(root,f) end
24✔
346
            end
347
            local res, err = rmdir(root)
24✔
348
            if not res then return nil,err .. ": " .. root end
24✔
349
        end
350
    end
351
    return true
12✔
352
end
353

354

355
do
356
  local dirpat
357
  if path.is_windows then
12✔
UNCOV
358
      dirpat = '(.+)\\[^\\]+$'
×
359
  else
360
      dirpat = '(.+)/[^/]+$'
12✔
361
  end
362

363
  local _makepath
364
  function _makepath(p)
10✔
365
      -- windows root drive case
366
      if p:find '^%a:[\\]*$' then
78✔
367
          return true
×
368
      end
369
      if not path.isdir(p) then
104✔
370
          local subp = p:match(dirpat)
48✔
371
          if subp then
48✔
372
            local ok, err = _makepath(subp)
48✔
373
            if not ok then return nil, err end
48✔
374
          end
375
          return mkdir(p)
48✔
376
      else
377
          return true
30✔
378
      end
379
  end
380

381
  --- create a directory path.
382
  -- This will create subdirectories as necessary!
383
  -- @string p A directory path
384
  -- @return true on success, nil + errormsg on failure
385
  -- @raise failure to create
386
  function dir.makepath (p)
12✔
387
      assert_string(1,p)
30✔
388
      if path.is_windows then
30✔
UNCOV
389
          p = p:gsub("/", "\\")
×
390
      end
391
      return _makepath(path.abspath(p))
40✔
392
  end
393
end
394

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

451

452
-- each entry of the stack is an array with three items:
453
-- 1. the name of the directory
454
-- 2. the lfs iterator function
455
-- 3. the lfs iterator userdata
456
local function treeiter(iterstack)
457
    local diriter = iterstack[#iterstack]
720✔
458
    if not diriter then
720✔
459
      return -- done
18✔
460
    end
461

462
    local dirname = diriter[1]
702✔
463
    local entry = diriter[2](diriter[3])
702✔
464
    if not entry then
702✔
465
      table.remove(iterstack)
48✔
466
      return treeiter(iterstack) -- tail-call to try next
48✔
467
    end
468

469
    if entry ~= "." and entry ~= ".." then
654✔
470
        entry = dirname .. sep .. entry
558✔
471
        if exists(entry) then  -- Just in case a symlink is broken.
744✔
472
            local is_dir = isdir(entry)
558✔
473
            if is_dir then
558✔
474
                table.insert(iterstack, { entry, ldir(entry) })
30✔
475
            end
476
            return entry, is_dir
558✔
477
        end
478
    end
479

480
    return treeiter(iterstack) -- tail-call to try next
96✔
481
end
482

483

484
--- return an iterator over all entries in a directory tree
485
-- @string d a directory
486
-- @return an iterator giving pathname and mode (true for dir, false otherwise)
487
-- @raise d must be a non-empty string
488
function dir.dirtree( d )
12✔
489
    assert( d and d ~= "", "directory parameter is missing or empty" )
18✔
490

491
    local last = sub ( d, -1 )
18✔
492
    if last == sep or last == '/' then
18✔
493
        d = sub( d, 1, -2 )
×
494
    end
495

496
    local iterstack = { {d, ldir(d)} }
18✔
497

498
    return treeiter, iterstack
18✔
499
end
500

501

502
--- Recursively returns all the file starting at 'path'. It can optionally take a shell pattern and
503
-- only returns files that match 'shell_pattern'. If a pattern is given it will do a case insensitive search.
504
-- @string[opt='.'] start_path  A directory.
505
-- @string[opt='*'] shell_pattern A shell pattern (see `fnmatch`).
506
-- @treturn List(string) containing all the files found recursively starting at 'path' and filtered by 'shell_pattern'.
507
-- @raise start_path must be a directory
508
function dir.getallfiles( start_path, shell_pattern )
12✔
509
    start_path = start_path or '.'
18✔
510
    assert_dir(1,start_path)
18✔
511
    shell_pattern = shell_pattern or "*"
18✔
512

513
    local files = {}
18✔
514
    local normcase = path.normcase
18✔
515
    for filename, mode in dir.dirtree( start_path ) do
774✔
516
        if not mode then
558✔
517
            local mask = filemask( shell_pattern )
528✔
518
            if normcase(filename):find( mask ) then
704✔
519
                files[#files + 1] = filename
132✔
520
            end
521
        end
522
    end
523

524
    return makelist(files)
18✔
525
end
526

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

© 2025 Coveralls, Inc