-- Syntax highlighting implementation for Hilbish

local colors = require "lunacolors"
local hilbish = require "hilbish"
local fs = require "fs"

local utils = require ".utils"

local M = {}

local SQ = 0x27 -- U+0027 APORSTROPHE
local DQ = 0x22 -- U+0022 QUOTATION MARK
local SP = 0x20 -- U+0020 SPACE
local HT = 0x09 -- U+0009 CHARACTER TABULATION
local LF = 0x0A -- U+000A LINE FEED (LF)
local CR = 0x0D -- U+000D CARRIAGE RETURN (CR)
local BS = 0x5C -- U+005C BACKSLASH

-- sh builtins
local sh_builtins = {
    "admin", "aliasar", "asa", "at", "awk", "basename", "batch", "bc", "bg", "c99", "cal", "cat", "cd", "cflow", "chgrp",
    "chmod",
    "chown", "cksum", "cmp", "comm", "command", "compress", "cp", "crontab", "csplit", "ctags", "cut", "cxref", "date",
    "dd",
    "delta", "df", "diff", "dirname", "du", "echo", "ed", "env", "ex", "expand", "expr", "false", "fc", "fg", "file",
    "find", "fold",
    "fort77", "fuser", "gencat", "get", "getconf", "getopts", "grep", "hash", "head", "iconv", "id", "ipcrm", "ipcs",
    "jobs",
    "join", "kill", "lex", "link", "ln", "locale", "localedef", "logger", "logname", "lp", "ls", "m4", "mailx", "make",
    "man",
    "mesg", "mkdir", "mkfifo", "more", "mv", "newgrp", "nice", "nl", "nm", "nohup", "od", "paste", "patch", "pathchk",
    "pax",
    "pr", "printf", "prs", "ps", "pwd", "qalter", "qdel", "qhold", "qmove", "qmsg", "qrerun", "qrls", "qselect", "qsig",
    "qstat", "qsub", "read", "renice", "rm", "rmdel", "rmdir", "sact", "sccs", "sed", "sh", "sleep", "sort", "split",
    "strings", "strip", "stty", "tabs", "tail", "talk", "tee", "test", "time", "touch", "tput", "tr", "true", "tsort",
    "tty", "type", "ulimit", "umask", "unalias", "uname", "uncompress", "unexpand", "unget", "uniq", "unlink",
    "uucp", "uudecode", "uuencode", "uustat", "uux", "val", "vi", "wait", "wc", "what", "who", "write", "xargs", "yacc",
    "zcat"
}

-- sh keywords
local sh_keywords = {
    "if", "then", "else", "elif", "fi", "case", "esac", "for", "select", "while", "until", "do", "done", "in", "function",
    "time", "coproc"
}

-- Determine if this string is a significant keyword, and if so, what it is
function M.is_cmd(str)
    if utils.contains(sh_builtins, str) then
        return "builtin"
    elseif utils.contains(sh_keywords, str) then
        return "keyword"
    elseif hilbish.which(str) ~= nil then
        return "cmd"
    else
        -- Try to use this as a filesystem glob
        -- This may fail with an error, so we need to make sure that
        -- doesn't happen first.
        str = str:gsub("~", os.getenv("HOME"))
        if pcall(fs.glob, str) then
            local globs = fs.glob(str)
            if #globs > 0 then
                return "file"
            end
        end
    end
    return nil
end

-- Crude sh lexer
function M.lex_sh(s)
    local state = nil
    local escape = false
    local result = {}
    local point = 0
    local index = 0
    -- Go through the string
    for i = 1, #s do
        index = i
        local c = s:byte(i)
        local v = string.char(c)
        -- Single quoted string state
        if state == SQ then
            if c == SQ then
                state = nil
                result[#result + 1] = { string.sub(s, point, i), "string" }
                point = i + 1
            end
            -- Double quoted string state
        elseif state == DQ then
            if escape then
                escape = false
            else
                if c == DQ then
                    state = nil
                    result[#result + 1] = { string.sub(s, point, i), "string" }
                    point = i + 1
                elseif c == BS then
                    escape = true
                end
            end
            -- Default state
        else
            if escape then
                escape = false
            elseif c == SP or c == HT or c == LF or c == CR then
                if point ~= i then
                    result[#result + 1] = { string.sub(s, point, i - 1), "other" }
                end
                result[#result + 1] = { v, "whitespace" }
                point = i + 1
            elseif c == DQ then
                state = DQ
                if point ~= i then
                    result[#result + 1] = { string.sub(s, point, i - 1), "other" }
                end
                point = i
            elseif c == SQ then
                state = SQ
                if point ~= i then
                    result[#result + 1] = { string.sub(s, point, i - 1), "other" }
                end
                point = i
            elseif c == BS then
                escape = true
            end
        end
    end
    -- If we are still in a string then say as such
    if state ~= nil then
        result[#result + 1] = { string.sub(s, point, index), "string" }
        -- Otherwise, return the rest as a regular token
    else
        if point ~= index then
            result[#result + 1] = { string.sub(s, point, index), "other" }
        else
            local c = s:byte(index)
            -- If the final character isn't a space, render it anyway
            -- This is a side effect of the space handling code. TODO: fix.
            if c ~= SP and c ~= HT and c ~= LF and c ~= CR then
                result[#result + 1] = { string.sub(s, point, index), "other" }
            end
        end
    end
    return result
end

-- Sort the table's key pairs
function M.pairsByKeys(t, f)
    local a = {}
    for n in pairs(t) do table.insert(a, n) end
    table.sort(a, f)
    local i = 0
    local iter = function()
        i = i + 1
        if a[i] == nil then
            return nil
        else
            return a[i], t[a[i]]
        end
    end
    return iter
end

-- sh highlighter
function M.sh(str)
    -- Parse the string
    local table = M.lex_sh(str)
    -- Construct the format string
    local format_str = ""
    for _, v in M.pairsByKeys(table) do
        if v[2] == "string" then
            format_str = format_str .. "{red}" .. v[1]
        else
            local cmd = M.is_cmd(v[1])
            if cmd == "cmd" then
                format_str = format_str .. "{bold}{brightGreen}" .. v[1] .. "{reset}"
            elseif cmd == "builtin" then
                format_str = format_str .. "{bold}{brightBlue}" .. v[1] .. "{reset}"
            elseif cmd == "keyword" then
                format_str = format_str .. "{bold}{brightMagenta}" .. v[1] .. "{reset}"
            elseif cmd == "file" then
                format_str = format_str .. "{bold}{brightYellow}" .. v[1] .. "{reset}"
            else
                format_str = format_str .. "{brightWhite}" .. v[1]
            end
        end
    end
    return colors.format(format_str)
end

return M
