neyuki 0.6.2

A small Lua-like scripting language implemented in Rust
local table = require("@neyuki/table")
local string = require("@neyuki/string")

local fs = {}

function checkString(s: string, name: string)
    if (s == nil) then error(name .. " must be provided") end
    if (type(s) != "string") then error(name .. " must be a string") end
end

function checkOptionalBoolean(b: boolean?, name: string)
    if (b != nil and type(b) != "boolean") then error(name .. " must be a boolean") end
end

function splitLines(content: string): {string}
    local lines = string.split(content, "\n")
    local count = #lines
    if (count > 0 and lines[count] == "") then
        table.remove(lines, count)
        count--
    end
    for index = 1, count do
        local line = lines[index]
        if (string.sub(line, -1) == "\r") then
            lines[index] = string.sub(line, 1, -2)
        end
    end
    return lines
end

function fs.open(path: string, mode: string?): File
    checkString(path, "path")
    mode = mode ?? "r"
    checkString(mode, "mode")
    __fs_open(path, mode)

    local closed = false
    local written_once = false
    local file = {}
    file.path = path
    file.mode = mode

    function checkOpen()
        if (closed) then error("file `" .. path .. "` is closed") end
    end

    function checkWritable()
        checkOpen()
        if (mode == "r") then error("file `" .. path .. "` was opened read-only") end
    end

    function file.read(self: File): string
        checkOpen()
        return __fs_read(path)
    end

    function file.write(self: File, text: string, append: boolean?): ()
        checkWritable()
        checkString(text, "text")
        checkOptionalBoolean(append, "append")
        local should_append = false
        if (mode == "a" or (mode == "w" and written_once)) then
            should_append = true
        elseif (append != nil) then
            should_append = append
        end
        __fs_write(path, text, should_append)
        written_once = true
    end

    function file.lines(self: File): () -> string?
        checkOpen()
        local lines = splitLines(__fs_read(path))
        local index = 0
        return function(): string?
            index++
            return lines[index]
        end
    end

    function file.close(self: File): ()
        closed = true
    end

    return table.freeze(file)
end

function fs.exists(path: string): boolean
    checkString(path, "path")
    return __fs_exists(path)
end

-- Removes a file or an empty directory; with `recursive`, a directory and
-- everything in it.
function fs.remove(path: string, recursive: boolean?): ()
    checkString(path, "path")
    checkOptionalBoolean(recursive, "recursive")
    __fs_remove(path, recursive ?? false)
end

function fs.rename(from: string, to: string): ()
    checkString(from, "from")
    checkString(to, "to")
    __fs_rename(from, to)
end

function fs.mkdir(path: string, recursive: boolean?): ()
    checkString(path, "path")
    checkOptionalBoolean(recursive, "recursive")
    __fs_mkdir(path, recursive ?? false)
end

function fs.list(path: string): {string}
    checkString(path, "path")
    return __fs_list(path)
end

function fs.isDir(path: string): boolean
    checkString(path, "path")
    return __fs_isdir(path)
end

-- WHOLE FILES --------------------------------------------------------------

-- The contents of a text file.
function fs.read(path: string): string
    checkString(path, "path")
    return __fs_read(path)
end

-- Replaces the contents of a file, creating it if needed.
function fs.write(path: string, text: string): ()
    checkString(path, "path")
    checkString(text, "text")
    __fs_write(path, text, false)
end

-- Adds text to the end of a file, creating it if needed.
function fs.append(path: string, text: string): ()
    checkString(path, "path")
    checkString(text, "text")
    __fs_write(path, text, true)
end

-- An iterator over the lines of a file, without their line endings.
function fs.lines(path: string): () -> string?
    checkString(path, "path")
    local lines = splitLines(__fs_read(path))
    local index = 0
    return function(): string?
        index++
        return lines[index]
    end
end

-- Copies a file, replacing `to`, and returns the number of bytes copied.
function fs.copy(from: string, to: string): number
    checkString(from, "from")
    checkString(to, "to")
    return __fs_copy(from, to)
end

-- `{ size, isDir, isFile, readonly, modified, created }` for a path, or nil
-- when nothing is there. Times are seconds since the Unix epoch.
function fs.info(path: string): FileInfo?
    checkString(path, "path")
    return __fs_info(path)
end

function fs.isFile(path: string): boolean
    checkString(path, "path")
    local info = __fs_info(path)
    return info != nil and info.isFile
end

-- Every file under a directory, as paths relative to it joined with "/",
-- in sorted order. Directories themselves are not listed.
function fs.walk(path: string): {string}
    checkString(path, "path")
    if (not __fs_isdir(path)) then error("`" .. path .. "` is not a directory") end
    local found = {}
    local function visit(dir: string, prefix: string)
        for _, name in __fs_list(dir) do
            local full = fs.join(dir, name)
            local relative = prefix .. name
            if (__fs_isdir(full)) then
                visit(full, relative .. "/")
            else
                found[#found + 1] = relative
            end
        end
    end
    visit(path, "")
    return found
end

-- PATHS --------------------------------------------------------------------

function isSeparator(char: string): boolean
    return char == "/" or char == "\\"
end

-- The index of the last separator in `path`, or 0.
function lastSeparator(path: string): number
    for index = #path, 1, -1 do
        if (isSeparator(string.sub(path, index, index))) then return index end
    end
    return 0
end

-- Joins path parts with "/", which every platform accepts, without doubling
-- separators. A part that is absolute starts over.
function fs.join(...: string): string
    local result = ""
    for index, part in {...} do
        if (type(part) != "string") then
            error("part " .. tostring(index) .. " must be a string")
        end
        if (part == "") then continue end
        local first = string.sub(part, 1, 1)
        if (result == "" or isSeparator(first) or string.match(part, "^%a:") != nil) then
            result = part
        elseif (isSeparator(string.sub(result, -1))) then
            result = result .. part
        else
            result = result .. "/" .. part
        end
    end
    return result
end

-- The last part of a path: `basename("docs/guide.md")` is "guide.md".
function fs.basename(path: string): string
    checkString(path, "path")
    while (#path > 1 and isSeparator(string.sub(path, -1))) do path = string.sub(path, 1, -2) end
    return string.sub(path, lastSeparator(path) + 1)
end

-- Everything before the last part: `dirname("docs/guide.md")` is "docs",
-- and a bare name gives ".".
function fs.dirname(path: string): string
    checkString(path, "path")
    while (#path > 1 and isSeparator(string.sub(path, -1))) do path = string.sub(path, 1, -2) end
    local at = lastSeparator(path)
    if (at == 0) then return "." end
    if (at == 1) then return string.sub(path, 1, 1) end
    return string.sub(path, 1, at - 1)
end

-- The extension of the last part, dot included: ".md" for "guide.md" and ""
-- for "Makefile" or ".gitignore".
function fs.extension(path: string): string
    local name = fs.basename(path)
    local dot = nil
    for index = #name, 2, -1 do
        if (string.sub(name, index, index) == ".") then
            dot = index
            break
        end
    end
    if (dot == nil) then return "" end
    return string.sub(name, dot)
end

-- `path` made absolute against the working directory.
function fs.absolute(path: string): string
    checkString(path, "path")
    return __fs_absolute(path)
end

-- The working directory.
function fs.cwd(): string
    return __fs_cwd()
end

return table.freeze(fs)