664 lines
16 KiB
Lua
664 lines
16 KiB
Lua
|
local fs = require 'bee.filesystem'
|
||
|
local platform = require 'bee.platform'
|
||
|
|
||
|
local type = type
|
||
|
local ioOpen = io.open
|
||
|
local pcall = pcall
|
||
|
local pairs = pairs
|
||
|
local setmetatable = setmetatable
|
||
|
local next = next
|
||
|
local ipairs = ipairs
|
||
|
local tostring = tostring
|
||
|
local tableSort = table.sort
|
||
|
|
||
|
_ENV = nil
|
||
|
|
||
|
---@class fs-utility
|
||
|
local m = {}
|
||
|
--- 读取文件
|
||
|
---@param path string|fs.path
|
||
|
function m.loadFile(path, keepBom)
|
||
|
if type(path) ~= 'string' then
|
||
|
---@diagnostic disable-next-line: undefined-field
|
||
|
path = path:string()
|
||
|
end
|
||
|
local f, e = ioOpen(path, 'rb')
|
||
|
if not f then
|
||
|
return nil, e
|
||
|
end
|
||
|
local text = f:read 'a'
|
||
|
f:close()
|
||
|
if not keepBom then
|
||
|
if text:sub(1, 3) == '\xEF\xBB\xBF' then
|
||
|
return text:sub(4)
|
||
|
end
|
||
|
if text:sub(1, 2) == '\xFF\xFE'
|
||
|
or text:sub(1, 2) == '\xFE\xFF' then
|
||
|
return text:sub(3)
|
||
|
end
|
||
|
end
|
||
|
return text
|
||
|
end
|
||
|
|
||
|
--- 写入文件
|
||
|
---@param path any
|
||
|
---@param content string
|
||
|
function m.saveFile(path, content)
|
||
|
if type(path) ~= 'string' then
|
||
|
---@diagnostic disable-next-line: undefined-field
|
||
|
path = path:string()
|
||
|
end
|
||
|
local f, e = ioOpen(path, "wb")
|
||
|
|
||
|
if f then
|
||
|
f:write(content)
|
||
|
f:close()
|
||
|
return true
|
||
|
else
|
||
|
return false, e
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function m.relative(path, base)
|
||
|
local sPath = fs.absolute(path):string()
|
||
|
local sBase = fs.absolute(base):string()
|
||
|
--TODO 先只支持最简单的情况
|
||
|
if sPath:sub(1, #sBase) == sBase
|
||
|
and sPath:sub(#sBase + 1, #sBase + 1):match '^[/\\]' then
|
||
|
return fs.path(sPath:sub(#sBase + 1):gsub('^[/\\]+', ''))
|
||
|
end
|
||
|
return nil
|
||
|
end
|
||
|
|
||
|
local function buildOption(option)
|
||
|
option = option or {}
|
||
|
option.add = option.add or {}
|
||
|
option.del = option.del or {}
|
||
|
option.mod = option.mod or {}
|
||
|
option.err = option.err or {}
|
||
|
return option
|
||
|
end
|
||
|
|
||
|
local function split(str, sep)
|
||
|
local t = {}
|
||
|
local current = 1
|
||
|
while current <= #str do
|
||
|
local s, e = str:find(sep, current)
|
||
|
if not s then
|
||
|
t[#t+1] = str:sub(current)
|
||
|
break
|
||
|
end
|
||
|
if s > 1 then
|
||
|
t[#t+1] = str:sub(current, s - 1)
|
||
|
end
|
||
|
current = e + 1
|
||
|
end
|
||
|
return t
|
||
|
end
|
||
|
|
||
|
---@class dummyfs
|
||
|
---@operator div(string|fs.path|dummyfs): dummyfs
|
||
|
---@field files table
|
||
|
local dfs = {}
|
||
|
dfs.__index = dfs
|
||
|
dfs.type = 'dummy'
|
||
|
dfs.path = ''
|
||
|
|
||
|
---@return dummyfs
|
||
|
function m.dummyFS(t)
|
||
|
return setmetatable({
|
||
|
files = t or {},
|
||
|
}, dfs)
|
||
|
end
|
||
|
|
||
|
function dfs:__tostring()
|
||
|
return 'dummy:' .. tostring(self.path)
|
||
|
end
|
||
|
|
||
|
function dfs:__div(filename)
|
||
|
if type(filename) ~= 'string' then
|
||
|
filename = filename:string()
|
||
|
end
|
||
|
local new = m.dummyFS(self.files)
|
||
|
if self.path:sub(-1):match '[^/\\]' then
|
||
|
new.path = self.path .. '\\' .. filename
|
||
|
else
|
||
|
new.path = self.path .. filename
|
||
|
end
|
||
|
return new
|
||
|
end
|
||
|
|
||
|
function dfs:_open(index)
|
||
|
local paths = split(self.path, '[/\\]')
|
||
|
local current = self.files
|
||
|
if not index then
|
||
|
index = #paths
|
||
|
elseif index < 0 then
|
||
|
index = #paths + index + 1
|
||
|
end
|
||
|
for i = 1, index do
|
||
|
local path = paths[i]
|
||
|
if current[path] then
|
||
|
current = current[path]
|
||
|
else
|
||
|
return nil
|
||
|
end
|
||
|
end
|
||
|
return current
|
||
|
end
|
||
|
|
||
|
function dfs:_filename()
|
||
|
return self.path:match '[^/\\]+$'
|
||
|
end
|
||
|
|
||
|
function dfs:parent_path()
|
||
|
local new = m.dummyFS(self.files)
|
||
|
if self.path:find('[/\\]') then
|
||
|
new.path = self.path:gsub('[/\\]+[^/\\]*$', '')
|
||
|
else
|
||
|
new.path = ''
|
||
|
end
|
||
|
return new
|
||
|
end
|
||
|
|
||
|
function dfs:filename()
|
||
|
local new = m.dummyFS(self.files)
|
||
|
new.path = self:_filename()
|
||
|
return new
|
||
|
end
|
||
|
|
||
|
function dfs:string()
|
||
|
return self.path
|
||
|
end
|
||
|
|
||
|
---@return fun(): dummyfs?
|
||
|
function dfs:listDirectory()
|
||
|
local dir = self:_open()
|
||
|
if type(dir) ~= 'table' then
|
||
|
return function () end
|
||
|
end
|
||
|
local keys = {}
|
||
|
for k in pairs(dir) do
|
||
|
keys[#keys+1] = k
|
||
|
end
|
||
|
tableSort(keys)
|
||
|
local i = 0
|
||
|
return function ()
|
||
|
i = i + 1
|
||
|
local k = keys[i]
|
||
|
if not k then
|
||
|
return nil
|
||
|
end
|
||
|
return self / k
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function dfs:isDirectory()
|
||
|
local target = self:_open()
|
||
|
if type(target) == 'table' then
|
||
|
return true
|
||
|
end
|
||
|
return false
|
||
|
end
|
||
|
|
||
|
function dfs:remove()
|
||
|
local dir = self:_open(-2)
|
||
|
local filename = self:_filename()
|
||
|
if not filename then
|
||
|
return
|
||
|
end
|
||
|
dir[filename] = nil
|
||
|
end
|
||
|
|
||
|
function dfs:exists()
|
||
|
local target = self:_open()
|
||
|
return target ~= nil
|
||
|
end
|
||
|
|
||
|
function dfs:createDirectories(path)
|
||
|
if not path then
|
||
|
return false
|
||
|
end
|
||
|
if type(path) ~= 'string' then
|
||
|
path = path:string()
|
||
|
end
|
||
|
local paths = split(path, '[/\\]+')
|
||
|
local current = self.files
|
||
|
for i = 1, #paths do
|
||
|
local sub = paths[i]
|
||
|
if current[sub] then
|
||
|
if type(current[sub]) ~= 'table' then
|
||
|
return false
|
||
|
end
|
||
|
else
|
||
|
current[sub] = {}
|
||
|
end
|
||
|
current = current[sub]
|
||
|
end
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
function dfs:saveFile(path, text)
|
||
|
if not path then
|
||
|
return false, 'no path'
|
||
|
end
|
||
|
if type(path) ~= 'string' then
|
||
|
path = path:string()
|
||
|
end
|
||
|
local temp = m.dummyFS(self.files)
|
||
|
temp.path = path
|
||
|
local dir = temp:_open(-2)
|
||
|
if not dir then
|
||
|
return false, '无法打开:' .. path
|
||
|
end
|
||
|
local filename = temp:_filename()
|
||
|
if not filename then
|
||
|
return false, '无法打开:' .. path
|
||
|
end
|
||
|
if type(dir[filename]) == 'table' then
|
||
|
return false, '无法打开:' .. path
|
||
|
end
|
||
|
dir[filename] = text
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
---@param path string|fs.path|dummyfs
|
||
|
---@param option table
|
||
|
---@return fs.path|dummyfs?
|
||
|
local function fsAbsolute(path, option)
|
||
|
if type(path) == 'string' then
|
||
|
local suc, res = pcall(fs.path, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return nil
|
||
|
end
|
||
|
path = res
|
||
|
elseif type(path) == 'table' then
|
||
|
return path
|
||
|
end
|
||
|
local suc, res = pcall(fs.absolute, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return nil
|
||
|
end
|
||
|
return res
|
||
|
end
|
||
|
|
||
|
local function fsIsDirectory(path, option)
|
||
|
if not path then
|
||
|
return false
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
return path:isDirectory()
|
||
|
end
|
||
|
local status = fs.symlink_status(path):type()
|
||
|
return status == 'directory'
|
||
|
end
|
||
|
|
||
|
---@param path fs.path|dummyfs|nil
|
||
|
---@param option table
|
||
|
---@return fun(): fs.path|dummyfs|nil
|
||
|
local function fsPairs(path, option)
|
||
|
if not path then
|
||
|
return function () end
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
return path:listDirectory()
|
||
|
end
|
||
|
local suc, res = pcall(fs.pairs, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return function () end
|
||
|
end
|
||
|
return res
|
||
|
end
|
||
|
|
||
|
local function fsRemove(path, option)
|
||
|
if not path then
|
||
|
return false
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
return path:remove()
|
||
|
end
|
||
|
local suc, res = pcall(fs.remove, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
end
|
||
|
option.del[#option.del+1] = path:string()
|
||
|
end
|
||
|
|
||
|
local function fsExists(path, option)
|
||
|
if not path then
|
||
|
return false
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
return path:exists()
|
||
|
end
|
||
|
local suc, res = pcall(fs.exists, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return false
|
||
|
end
|
||
|
return res
|
||
|
end
|
||
|
|
||
|
local function fsSave(path, text, option)
|
||
|
if not path then
|
||
|
return false
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
local dir = path:_open(-2)
|
||
|
if not dir then
|
||
|
option.err[#option.err+1] = '无法打开:' .. path:string()
|
||
|
return false
|
||
|
end
|
||
|
local filename = path:_filename()
|
||
|
if not filename then
|
||
|
option.err[#option.err+1] = '无法打开:' .. path:string()
|
||
|
return false
|
||
|
end
|
||
|
if type(dir[filename]) == 'table' then
|
||
|
option.err[#option.err+1] = '无法打开:' .. path:string()
|
||
|
return false
|
||
|
end
|
||
|
dir[filename] = text
|
||
|
else
|
||
|
local suc, err = m.saveFile(path, text)
|
||
|
if suc then
|
||
|
return true
|
||
|
end
|
||
|
option.err[#option.err+1] = err
|
||
|
return false
|
||
|
end
|
||
|
end
|
||
|
|
||
|
local function fsLoad(path, option)
|
||
|
if not path then
|
||
|
return nil
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
local text = path:_open()
|
||
|
if type(text) == 'string' then
|
||
|
return text
|
||
|
else
|
||
|
option.err[#option.err+1] = '无法打开:' .. path:string()
|
||
|
return nil
|
||
|
end
|
||
|
else
|
||
|
local text, err = m.loadFile(path)
|
||
|
if text then
|
||
|
return text
|
||
|
else
|
||
|
option.err[#option.err+1] = err
|
||
|
return nil
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
local function fsCopy(source, target, option)
|
||
|
if not source or not target then
|
||
|
return
|
||
|
end
|
||
|
if source.type == 'dummy' then
|
||
|
local sourceText = source:_open()
|
||
|
if not sourceText then
|
||
|
option.err[#option.err+1] = '无法打开:' .. source:string()
|
||
|
return false
|
||
|
end
|
||
|
return fsSave(target, sourceText, option)
|
||
|
else
|
||
|
if target.type == 'dummy' then
|
||
|
local sourceText, err = m.loadFile(source)
|
||
|
if not sourceText then
|
||
|
option.err[#option.err+1] = err
|
||
|
return false
|
||
|
end
|
||
|
return fsSave(target, sourceText, option)
|
||
|
else
|
||
|
local suc, res = pcall(fs.copy_file, source, target, fs.copy_options.overwrite_existing)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return false
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
---@param path dummyfs|fs.path
|
||
|
---@param option table
|
||
|
local function fsCreateDirectories(path, option)
|
||
|
if not path then
|
||
|
return
|
||
|
end
|
||
|
if path.type == 'dummy' then
|
||
|
return path:createDirectories()
|
||
|
end
|
||
|
local suc, res = pcall(fs.create_directories, path)
|
||
|
if not suc then
|
||
|
option.err[#option.err+1] = res
|
||
|
return false
|
||
|
end
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
local function fileRemove(path, option)
|
||
|
if not path then
|
||
|
return
|
||
|
end
|
||
|
if option.onRemove and option.onRemove(path) == false then
|
||
|
return
|
||
|
end
|
||
|
if fsIsDirectory(path, option) then
|
||
|
for child in fsPairs(path, option) do
|
||
|
fileRemove(child, option)
|
||
|
end
|
||
|
end
|
||
|
if fsRemove(path, option) then
|
||
|
option.del[#option.del+1] = path:string()
|
||
|
end
|
||
|
end
|
||
|
|
||
|
---@param source fs.path|dummyfs?
|
||
|
---@param target fs.path|dummyfs?
|
||
|
---@param option table
|
||
|
local function fileCopy(source, target, option)
|
||
|
if not source or not target then
|
||
|
return
|
||
|
end
|
||
|
local isDir1 = fsIsDirectory(source, option)
|
||
|
local isDir2 = fsIsDirectory(target, option)
|
||
|
local isExists = fsExists(target, option)
|
||
|
if isDir1 then
|
||
|
if isDir2 or fsCreateDirectories(target, option) then
|
||
|
for filePath in fsPairs(source, option) do
|
||
|
local name = filePath:filename():string()
|
||
|
fileCopy(filePath, target / name, option)
|
||
|
end
|
||
|
end
|
||
|
else
|
||
|
if isExists and not isDir2 then
|
||
|
local buf1 = fsLoad(source, option)
|
||
|
local buf2 = fsLoad(target, option)
|
||
|
if buf1 and buf2 then
|
||
|
if buf1 ~= buf2 then
|
||
|
if fsCopy(source, target, option) then
|
||
|
option.mod[#option.mod+1] = target:string()
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
else
|
||
|
if fsCopy(source, target, option) then
|
||
|
option.add[#option.add+1] = target:string()
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
---@param source fs.path|dummyfs?
|
||
|
---@param target fs.path|dummyfs?
|
||
|
---@param option table
|
||
|
local function fileSync(source, target, option)
|
||
|
if not source or not target then
|
||
|
return
|
||
|
end
|
||
|
local isDir1 = fsIsDirectory(source, option)
|
||
|
local isDir2 = fsIsDirectory(target, option)
|
||
|
local isExists = fsExists(target, option)
|
||
|
if isDir1 then
|
||
|
if isDir2 then
|
||
|
local fileList = m.fileList()
|
||
|
if type(target) == 'table' then
|
||
|
---@cast target dummyfs
|
||
|
for filePath in target:listDirectory() do
|
||
|
fileList[filePath] = true
|
||
|
end
|
||
|
else
|
||
|
---@cast target fs.path
|
||
|
for filePath in fs.pairs(target) do
|
||
|
fileList[filePath] = true
|
||
|
end
|
||
|
end
|
||
|
for filePath in fsPairs(source, option) do
|
||
|
local name = filePath:filename():string()
|
||
|
local targetPath = target / name
|
||
|
fileSync(filePath, targetPath, option)
|
||
|
fileList[targetPath] = nil
|
||
|
end
|
||
|
for path in pairs(fileList) do
|
||
|
fileRemove(path, option)
|
||
|
end
|
||
|
else
|
||
|
if isExists then
|
||
|
fileRemove(target, option)
|
||
|
end
|
||
|
if fsCreateDirectories(target, option) then
|
||
|
for filePath in fsPairs(source, option) do
|
||
|
local name = filePath:filename():string()
|
||
|
fileCopy(filePath, target / name, option)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
else
|
||
|
if isDir2 then
|
||
|
fileRemove(target, option)
|
||
|
end
|
||
|
if isExists then
|
||
|
local buf1 = fsLoad(source, option)
|
||
|
local buf2 = fsLoad(target, option)
|
||
|
if buf1 and buf2 then
|
||
|
if buf1 ~= buf2 then
|
||
|
if fsCopy(source, target, option) then
|
||
|
option.mod[#option.mod+1] = target:string()
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
else
|
||
|
if fsCopy(source, target, option) then
|
||
|
option.add[#option.add+1] = target:string()
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
--- 文件列表
|
||
|
function m.fileList(option)
|
||
|
option = option or buildOption(option)
|
||
|
local os = platform.OS
|
||
|
local keyMap = {}
|
||
|
local fileList = {}
|
||
|
local function computeKey(path)
|
||
|
path = fsAbsolute(path, option)
|
||
|
if not path then
|
||
|
return nil
|
||
|
end
|
||
|
return path:string()
|
||
|
end
|
||
|
return setmetatable({}, {
|
||
|
__index = function (_, path)
|
||
|
local key = computeKey(path)
|
||
|
return fileList[key]
|
||
|
end,
|
||
|
__newindex = function (_, path, value)
|
||
|
local key = computeKey(path)
|
||
|
if not key then
|
||
|
return
|
||
|
end
|
||
|
if value == nil then
|
||
|
keyMap[key] = nil
|
||
|
else
|
||
|
keyMap[key] = path
|
||
|
fileList[key] = value
|
||
|
end
|
||
|
end,
|
||
|
__pairs = function ()
|
||
|
local key, path
|
||
|
return function ()
|
||
|
key, path = next(keyMap, key)
|
||
|
return path, fileList[key]
|
||
|
end
|
||
|
end,
|
||
|
})
|
||
|
end
|
||
|
|
||
|
--- 删除文件(夹)
|
||
|
function m.fileRemove(path, option)
|
||
|
option = buildOption(option)
|
||
|
path = fsAbsolute(path, option)
|
||
|
|
||
|
fileRemove(path, option)
|
||
|
|
||
|
return option
|
||
|
end
|
||
|
|
||
|
--- 复制文件(夹)
|
||
|
---@param source string|fs.path|dummyfs
|
||
|
---@param target string|fs.path|dummyfs
|
||
|
---@return table
|
||
|
function m.fileCopy(source, target, option)
|
||
|
option = buildOption(option)
|
||
|
local fsSource = fsAbsolute(source, option)
|
||
|
local fsTarget = fsAbsolute(target, option)
|
||
|
|
||
|
fileCopy(fsSource, fsTarget, option)
|
||
|
|
||
|
return option
|
||
|
end
|
||
|
|
||
|
--- 同步文件(夹)
|
||
|
---@param source string|fs.path|dummyfs
|
||
|
---@param target string|fs.path|dummyfs
|
||
|
---@return table
|
||
|
function m.fileSync(source, target, option)
|
||
|
option = buildOption(option)
|
||
|
local fsSource = fsAbsolute(source, option)
|
||
|
local fsTarget = fsAbsolute(target, option)
|
||
|
|
||
|
fileSync(fsSource, fsTarget, option)
|
||
|
|
||
|
return option
|
||
|
end
|
||
|
|
||
|
---@param dir fs.path
|
||
|
---@param callback fun(fullPath: fs.path)
|
||
|
function m.scanDirectory(dir, callback)
|
||
|
for fullpath in fs.pairs(dir) do
|
||
|
local status = fs.symlink_status(fullpath):type()
|
||
|
if status == 'directory' then
|
||
|
m.scanDirectory(fullpath, callback)
|
||
|
elseif status == 'regular' then
|
||
|
callback(fullpath)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
function m.listDirectory(dir)
|
||
|
if dir.type == 'dummy' then
|
||
|
return dir:listDirectory()
|
||
|
else
|
||
|
return fs.pairs(dir)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
return m
|