file.lua

---------------------------------------------------------------------------
--- High level file handling library.
--
-- A file handle can be created through one of the constructor functions. File
-- operations are performed on that handle.
--
-- Example to write and read-back a file:
--
--    local lgi = require("lgi")
--    local File = require("lgi-async-extra.file")
--    local path = "%s/foo.txt":format(lgi.GLib.get_tmp_dir())
--    local f = File.new_for_path(path)
--    async.waterfall({
--        function(cb)
--            -- By default, writing replaces any existing content
--            f:write("hello", cb)
--        end,
--        function(cb)
--            -- But we can also append to the file
--            f:write("world", "append", cb)
--        end,
--        function(cb)
--            f:read_string(cb)
--        end,
--    }, function(err, data)
--        print(err)
--        print(data)
--    end)
--
-- @module file
-- @license GPL v3.0
---------------------------------------------------------------------------

local async = require("async")
local lgi = require("lgi")
local Gio = lgi.Gio
local GLib = lgi.GLib
local GFile = Gio.File

local stream_utils = require("lgi-async-extra.stream")


-- Class marker
local FILE_CLASS_MARKER = setmetatable({}, { __newindex = function() end, __tostring = "File" })


local File = {
    class = FILE_CLASS_MARKER,
}
local file = {}


--- Constructors
-- @section constructors

--- Create a file handle for the given local path.
--
-- This is a cheap operation, that only creates an in memory representation of the resource location.
-- No I/O will take place until a corresponding method is called on the returned `File` object.
--
-- @tparam string path
-- @treturn File
function file.new_for_path(path)
    local f = GFile.new_for_path(path)
    local ret = {
        _private = {
            f = f,
            path = path,
        }
    }
    return setmetatable(ret, { __index = File  })
end


--- Create a file handle for the given remote URI.
--
-- This is a cheap operation, that only creates an in memory representation of the resource location.
-- No I/O will take place until a corresponding method is called on the returned `File` object.
--
-- @tparam string uri
-- @treturn File
function file.new_for_uri(uri)
    local f = GFile.new_for_uri(uri)
    local ret = {
        _private = {
            f = f,
            path = uri,
        }
    }
    return setmetatable(ret, { __index = File  })
end


--- Create a new file in a directory preferred for temporary storage.
--
-- If `template` is given, it must contain a sequence of six `X`s somewhere in the string, which
-- will replaced by a unique ID to ensure the new file does not overwrite existing ones. The template must not contain
-- any directory components.
-- If `template == nil`, a default value will be used.
--
-- The directory is determined by [g_get_tmp_dir](https://docs.gtk.org/glib/func.get_tmp_dir.html).
--
-- The second return value is a `Gio.FileIOStream`, which contains both an input and output stream to the created
-- file. The caller is responsible for closing these streams.
--
-- The third return value will be an instance of `GLib.Error` if the attempt to create the file failed. If this
-- is not `nil`, attempts to access the other return values will result in undefined behavior.
--
-- See [docs.gtk.org](https://docs.gtk.org/gio/type_func.File.new_tmp.html) for additional details.
--
-- @tparam[opt=".XXXXXX"] string template
-- @treturn File
-- @treturn GIO.FileIOStream
-- @treturn[opt] GLib.Error
function file.new_tmp(template)
    local f, stream, err = GFile.new_tmp(template)
    local ret = {
        _private = {
            f = f,
            template = template,
        }
    }
    return setmetatable(ret, { __index = File  }), stream, err
end


--- Static functions
-- @section static_functions

--- Checks if a table is an instance of file.
--
-- @since 0.2.0
-- @usage local File = require("lgi-async-extra.file")
-- local f = File.new_for_path("/tmp/foo.txt")
-- assert(File.is_instance(f))
-- @tparam table f The value to check.
-- @treturn boolean
function file.is_instance(f)
    return type(f) == "table" and f.class == FILE_CLASS_MARKER
end


--- @type file


--- Creates a final callback to pass results and clean up the file stream.
--
-- This is intended to be passed as `final_callback` parameter for an `async.dag` where a Gio stream was
-- opened at index `stream`.
-- The `result_index` parameter is required to be set. If no result should be passed, provide `nil`.
--
-- @tparam nil|string result_index Index into the `async.dag` results table.
-- @tparam[opt="stream"] string stream_index Index into the `async.dag` results table.
-- @tparam function cb The callback to proxy
-- @treturn function
local function clean_up_stream(result_index, stream_index, cb)
    if type(stream_index) == "function" then
        cb = stream_index
        stream_index = "stream"
    end

    return function(err, results)
        local result
        if result_index and results[result_index] then
            result = table.unpack(results[result_index])
        end

        if not results[stream_index] then
            return cb(err, result)
        end

        -- Make sure to always close the stream, even if the read operation failed.
        local stream = table.unpack(results[stream_index])
        stream:close_async(GLib.PRIORITY_DEFAULT, nil, function(_, token)
            local _, err_inner = stream:close_finish(token)
            -- Prioritize the outer error (from the read operation), as the inner error (closing the stream) may be
            -- a result of that.
            cb(err or err_inner, result)
        end)
    end
end


--- Get the file's path name.
--
-- The path is guaranteed to be absolute, by may contain unresolved symlinks.
-- However, a path may not exist, in which case `nil` will be returned.
--
-- @since 0.2.0
-- @treturn[opt] string
function File:get_path()
    return self._private.f:get_path()
end

--- Open a read stream.
--
-- The consumer is responsible for properly closing the stream:
--
--    stream:close_async(GLib.PRIORITY_DEFAULT, nil, function(_, token)
--        local _, err = stream:close_finish(token)
--        cb(err)
--    end)
--
-- A [GDataInputStream](https://docs.gtk.org/gio/class.DataInputStream.html) adds additional reading utilities:
--
--    stream = Gio.DataInputStream.new(stream)
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn[opt] Gio.FileInputStream
function File:read_stream(cb)
    local f = self._private.f

    f:read_async(GLib.PRIORITY_DEFAULT, nil, function(_, token)
        local stream, err = f:read_finish(token)
        cb(err, stream)
    end)
end


--- Open a write stream.
--
-- Write operations are buffered, so the stream needs to be flushed (or closed)
-- to be sure that changes are written to disk. Especially in `replace` mode,
-- reading before flushing will yield stale content.
--
-- The consumer is responsible for properly closing the stream:
--
--    stream:close_async(GLib.PRIORITY_DEFAULT, nil, function(_, token)
--        local _, err = stream:close_finish(token)
--        cb(err)
--    end)
--
-- @async
-- @tparam[opt="replace"] string mode Either `"append"` or `"replace"`.
--  `"replace"` will truncate the file before writing, `"append"` will keep
--  any existing content and add the new data at the end.
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn Gio.FileOutputStream
function File:write_stream(mode, cb)
    local f = self._private.f
    local priority = GLib.PRIORITY_DEFAULT

    if type(mode) == "function" then
        cb = mode
        mode = nil
    end

    if mode == "append" then
        f:append_to_async(
            Gio.FileCreateFlags.NONE,
            priority,
            nil,
            function(_, token)
                local stream, err = f:append_to_finish(token)
                cb(err, stream)
            end
        )
    else
        f:replace_async(
            nil,
            false,
            Gio.FileCreateFlags.NONE,
            priority,
            nil,
            function(_, token)
                local stream, err = f:replace_finish(token)
                cb(err, stream)
            end
        )
    end
end


--- Write the data to the opened file.
--
-- @async
-- @tparam string data The data to write.
-- @tparam[opt="replace"] string mode Either `"append"` or `"replace"`.
--  `"replace"` will truncate the file before writing, `"append"` will keep
--  any existing content and add the new data at the end.
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:write(data, mode, cb)
    local priority = GLib.PRIORITY_DEFAULT

    if type(mode) == "function" then
        cb = mode
        mode = nil
    end

    async.dag({
        stream = function(_, cb_inner)
            self:write_stream(mode, cb_inner)
        end,
        write = { "stream", function(results, cb_inner)
            local stream = table.unpack(results.stream)

            stream:write_all_async(data, priority, nil, function(_, token)
                local _, _, err = stream:write_all_finish(token)
                cb_inner(err)
            end)
        end },
    }, clean_up_stream(nil, cb))
end


--- Read at most the specified number of bytes from the file.
--
-- If there is not enough data to read, the result may contain less than `size` bytes of data.
--
-- @since 0.2.0
-- @async
-- @tparam number size The number of bytes to read.
-- @tparam function cb The callback to call when reading finished.
--   Signature: `function(err, data)`
-- @treturn[opt] GLib.Error An instance of `GError` if there was an error,
--   `nil` otherwise.
-- @treturn GLib.Bytes
function File:read_bytes(size, cb)
    local priority = GLib.PRIORITY_DEFAULT

    async.dag({
        stream = function(_, cb_inner)
            self:read_stream(cb_inner)
        end,
        bytes = { "stream", function(results, cb_inner)
            local stream = table.unpack(results.stream)

            stream:read_bytes_async(size, priority, nil, function(_, token)
                local bytes, err = stream:read_bytes_finish(token)
                cb_inner(err, bytes)
            end)
        end },
    }, clean_up_stream("bytes", cb))
end


--- Read the entire file's content into memory.
--
-- This collects the content into a Lua string, so text files an be used as-is.
-- For binary content, use string.byte to access the raw values or manually wrap the result of
-- file:read_stream in a [Gio.DataInputStream](https://docs.gtk.org/gio/class.DataInputStream.html) and
-- read individual values based on their binary size.
--
-- @since 0.2.0
-- @async
-- @tparam function cb The callback to call when reading finished.
--   Signature: `function(err, data)`
-- @treturn[opt] GLib.Error An instance of `GError` if there was an error,
--   `nil` otherwise.
-- @treturn[opt] string A string read from the file.
function File:read_string(cb)
    async.dag({
        stream = function(_, cb_inner)
            self:read_stream(cb_inner)
        end,
        string = { "stream", function(results, cb_inner)
            local stream = table.unpack(results.stream)
            stream_utils.read_string(stream, cb_inner)
        end },
    }, clean_up_stream("string", cb))
end


--- Read a line from the file.
--
-- Like all other operations, this always reads from the beginning of the file. Calling this function
-- repeatedly on the same file will always yield the first line.
--
-- To iterate over all lines, use file:iterate_lines. To read more than just one line, use file:read_bytes or
-- file:read_string.
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error An instance of `GError` if there was an error,
--   `nil` otherwise.
-- @treturn[opt] string A string read from the file,
--   or `nil` if the end was reached.
function File:read_line(cb)
    local priority = GLib.PRIORITY_DEFAULT

    async.dag({
        stream = function(_, cb_inner)
            self:read_stream(cb_inner)
        end,
        line = { "stream", function(results, cb_inner)
            local stream = table.unpack(results.stream)
            stream = Gio.DataInputStream.new(stream)

            stream:read_line_async(priority, nil, function(_, token)
                local line, _, err = stream:read_line_finish(token)
                cb_inner(err, line)
            end)
        end },
    }, clean_up_stream("line", cb))
end


--- Asynchronously iterate over the file line by line.
--
-- This function opens a read stream and starts reading the file line-wise,
-- asynchronously. For every line read, the given `iteratee` is called with any
-- potential error, the line's content (without the trailing newline)
-- and a callback function. The callback must always be called to ensure the
-- file handle is cleaned up eventually. The expected signature for the callback
-- is `cb(err, stop)`. If `err ~= nil` or a value for `stop` is given, iteration stops
-- immediately and `cb` will be called.
--
-- Changed 0.2.0: Renamed from `read_lines`.
--
-- @since 0.2.0
-- @async
-- @tparam function iteratee Function to call per line in the file. Signature:
--   `function(err, line, cb)`
-- @tparam function cb Function to call when iteration has stopped.
--   Signature: `function(err)`.
function File:iterate_lines(iteratee, cb)
    local priority = GLib.PRIORITY_DEFAULT

    async.dag({
        stream = function(_, cb_inner)
            self:read_stream(cb_inner)
        end,
        lines = { "stream", function(results, cb_inner)
            local stream = table.unpack(results.stream)
            stream = Gio.DataInputStream.new(stream)

            local function read_line(cb_line)
                stream:read_line_async(priority, nil, function(_, token)
                    local line, _, err = stream:read_line_finish(token)

                    iteratee(err, line, function(err, stop)
                        cb_line(err, stop or false, line)
                    end)
                end)
            end

            local function check(stop, line, cb_check)
                if type(line) == "function" then
                    cb_check = line
                    line = nil
                end

                local continue = (not stop) and (line ~= nil)
                cb_check(nil, continue)
            end

            async.do_while(read_line, check, function(err)
                cb_inner(err)
            end)
        end },
    }, clean_up_stream(nil, cb))
end


--- Move the file to a new location.
--
-- Due to limitations in GObject Introspection, this can currently only be implemented as
-- "copy and delete" operation.
--
-- @since 0.3.0
-- @async
-- @tparam string|file path New path to move to.
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:move(path, cb)
    async.waterfall({
        function(cb)
            self:copy(path, { recursive = true }, cb)
        end,
        function(cb)
            self:delete(cb)
        end
    }, function(err) cb(err) end)
end


local function _file_copy_impl(self, dest, options, cb)
    async.dag({
        check_overwrite = function(_, cb)
            if options.overwrite then
                return cb(nil)
            end

            dest:exists(function(err, exists)
                if not err and exists then
                    err = GLib.Error(
                        Gio.IOErrorEnum,
                        Gio.IOErrorEnum.EXISTS,
                        "Destination exists already"
                    )
                end

                cb(err)
            end)
        end,
        out_stream = { "check_overwrite", function(_, cb)
            dest:write_stream("replace", cb)
        end },
        in_stream = { "check_overwrite", function(_, cb)
            self:read_stream(cb)
        end },
        splice = { "out_stream", "in_stream", function(results, cb)
            local in_stream = table.unpack(results.in_stream)
            local out_stream = table.unpack(results.out_stream)
            local flags = {
                Gio.OutputStreamSpliceFlags.CLOSE_SOURCE,
                Gio.OutputStreamSpliceFlags.CLOSE_TARGET
            }

            out_stream:splice_async(in_stream, flags, GLib.PRIORITY_DEFAULT, nil, function(_, token)
                local _, err = out_stream:splice_finish(token)
                cb(err)
            end)
        end },
    }, function(err) cb(err) end)
end


--- Copies the file to a new location.
--
-- @since 0.3.0
-- @async
-- @tparam string|file dest_path Path to copy to.
-- @tparam table options
-- @tparam boolean recursive Copy directory contents recursively.
-- @tparam boolean overwrite Overwrite files at the destination path.
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:copy(dest_path, options, cb)
    local dest = dest_path
    if type(dest) == "string" then
        dest = file.new_for_path(dest_path)
    end

    if not options.recursive then
        return _file_copy_impl(self, dest, options, cb)
    end

    async.dag({
        file_type = function(_, cb)
            self:type(cb)
        end,
        copy = { "file_type", function(results, cb)
            local file_type = table.unpack(results.file_type)

            if file_type ~= Gio.FileType.DIRECTORY then
                return _file_copy_impl(self, dest, options, cb)
            elseif not options.recursive then
                local err = GLib.Error(
                    Gio.IOErrorEnum,
                    Gio.IOErrorEnum.IS_DIRECTORY,
                    "Directories can only be copied recursively"
                )
                return cb(err)
            end

            local filesystem = require("lgi-async-extra.filesystem")
            local path = self:get_path()

            local function iteratee(info, cb)
                local child = file.new_for_path(string.format("%s/%s", path, info:get_name()))
                local child_dest = file.new_for_path(
                    string.format("%s/%s", dest_path, info:get_name())
                )
                child:copy(child_dest, options, cb)
            end

            filesystem.iterate_contents(path, iteratee, cb)
        end },
    }, function(err) cb(err) end)
end


--- Delete the file.
--
-- This has the same semantics as POSIX `unlink()`, i.e. the link at the given
-- path is removed. If it was the last link to the file, the disk space occupied
-- by that file is freed as well.
--
-- Empty directories are deleted by this as well.
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:delete(cb)
    local f = self._private.f
    local priority = GLib.PRIORITY_DEFAULT

    f:delete_async(priority, nil, function(_, token)
        local _, err = f:delete_finish(token)
        cb(err)
    end)
end


--- Move the file to trash.
--
-- Support for this depends on the platform and file system. If unsupported
-- an error of type `Gio.IOErrorEnum.NOT_SUPPORTED` will be returned.
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:trash(cb)
    local f = self._private.f
    local priority = GLib.PRIORITY_DEFAULT

    f:trash_async(priority, nil, function(_, token)
        local _, err = f:trash_finish(token)
        cb(err)
    end)
end


--- Query file information.
--
-- This can be used to query for any file info attribute supported by GIO.
-- The attribute parameter may either be plain string, such as `"standard::size"`, a wildcard `"standard::*"` or
-- a list of both `"standard::*,owner::user"`.
--
-- GIO also offers constants for these attribute values, which can be found by querying the GIO docs for
-- `G_FILE_ATTRIBUTE_*` constants:
-- [https://docs.gtk.org/gio/index.html?q=G_FILE_ATTRIBUTE_](https://docs.gtk.org/gio/index.html?q=G_FILE_ATTRIBUTE_)
--
-- See [docs.gtk.org](https://docs.gtk.org/gio/method.File.query_info.html) for additional details.
--
-- @todo Document the conversion from GIO's attributes to what LGI expects.
-- @async
-- @tparam string attribute The GIO file info attribute to query for.
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn[opt] Gio.FileInfo
function File:query_info(attribute, cb)
    local f = self._private.f
    local priority = GLib.PRIORITY_DEFAULT

    f:query_info_async(attribute, 0, priority, nil, function(_, token)
        local info, err = f:query_info_finish(token)
        cb(err, info)
    end)
end


--- Check if the file exists.
--
-- Keep in mind that checking for existence before reading or writing a file is
-- subject to race conditions.
-- An external process may still alter a file between those two operations.
--
-- Also note that, due to limitations in GLib, this method cannot distinguish
-- between a file that is actually absent and a file that the user has no access
-- to.
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn boolean `true` if the file exists at its specified location.
function File:exists(cb)
    self:query_info("standard::type", function (err)
        if err then
            -- An error of "not found" is actually an expected outcome, so
            -- we hide the error.
            if err.code == Gio.IOErrorEnum[Gio.IOErrorEnum.NOT_FOUND] then
                cb(nil, false)
            else
                cb(err, false)
            end
        else
            cb(nil, true)
        end
    end)
end


--- Query the size of the file.
--
-- Note that due to limitations in GLib, this will return `0` for files
-- that the user has no access to.
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn[opt] number
function File:size(cb)
    self:query_info("standard::size", function (err, info)
        -- For some reason, the bindings return a float for a byte size
        cb(err, info and math.floor(info:get_size()))
    end)
end


--- Query the type of the file.
--
-- Common scenarios would be to compare this against `Gio.FileType`.
--
-- Note that due to limitations in GLib, this will return `Gio.FileType.UNKNOWN` for files
-- that the user has no access to.
--
-- @usage
--    f:type(function(err, type)
--        if err then return cb(err) end
--        local is_dir = type == Gio.FileType.DIRECTORY
--        local is_link = type == Gio.FileType.SYMBOLIC_LINK
--        local is_file = type == Gio.FileType.REGULAR
--        -- get a string representation
--        print(Gio.FileType[type])
--    end)
--
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
-- @treturn[opt] Gio.FileType
function File:type(cb)
    self:query_info("standard::type", function (err, info)
        cb(err, info and Gio.FileType[info:get_file_type()])
    end)
end


--- Creates an empty file.
--
-- Attempting to call this on an existing file will result in an error with type
-- `Gio.IOErrorEnum.EXISTS`.
--
-- Do not use this when you intend to write to the file immediately after creation, as it is subject
-- to race conditions.
-- Write operations, such as file.write and file.write_stream create files when needed.
--
-- @since 0.2.0
-- @async
-- @tparam function cb
-- @treturn[opt] GLib.Error
function File:create(cb)
    local f = self._private.f
    f:create_async(Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT, nil, function(_, token)
        local _, err = f:create_finish(token)
        cb(err)
    end)
end


return file