Skip to content

Instantly share code, notes, and snippets.

@MCJack123
Created January 23, 2024 00:29
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MCJack123/59a9699d7ed86ca6762c7230dc6f8b9e to your computer and use it in GitHub Desktop.
Save MCJack123/59a9699d7ed86ca6762c7230dc6f8b9e to your computer and use it in GitHub Desktop.
TOML parser for CC Lua
-- TOML library for Lua/CC
-- From Phoenix libsystem serialization.toml
--
-- MIT License
--
-- Copyright (c) 2024 JackMacWindows
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to deal
-- in the Software without restriction, including without limitation the rights
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-- copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
local expect = require "cc.expect"
local toml = {}
-- From json.lua by rxi
-- MIT license
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
-- End json.lua
local keywords = {
["and"] = true,
["break"] = true,
["do"] = true,
["else"] = true,
["elseif"] = true,
["end"] = true,
["false"] = true,
["for"] = true,
["function"] = true,
["goto"] = true,
["if"] = true,
["in"] = true,
["local"] = true,
["nil"] = true,
["not"] = true,
["or"] = true,
["repeat"] = true,
["return"] = true,
["then"] = true,
["true"] = true,
["until"] = true,
["while"] = true,
}
local function lua_serialize(val, stack, opts, level)
if stack[val] then error("Cannot serialize recursive value", 0) end
local tt = type(val)
if tt == "table" then
if not next(val) then return "{}" end
stack[val] = true
local res = opts.minified and "{" or "{\n"
local num = {}
for i, v in ipairs(val) do
if not opts.minified then res = res .. (" "):rep(level) end
num[i] = true
res = res .. lua_serialize(v, stack, opts, level + 1) .. (opts.minified and "," or ",\n")
end
for k, v in pairs(val) do if not num[k] then
if not opts.minified then res = res .. (" "):rep(level) end
if type(k) == "string" and k:match "^[A-Za-z_][A-Za-z0-9_]*$" and not keywords[k] then res = res .. k
else res = res .. "[" .. lua_serialize(k, stack, opts, level + 1) .. "]" end
res = res .. (opts.minified and "=" or " = ") .. lua_serialize(v, stack, opts, level + 1) .. (opts.minified and "," or ",\n")
end end
if opts.minified then res = res:gsub(",$", "")
else res = res .. (" "):rep(level - 1) end
stack[val] = nil
return res .. "}"
elseif tt == "function" and opts.allow_functions then
local ok, dump = pcall(string.dump, val)
if not ok then error("Cannot serialize C function", 0) end
dump = ("%q"):format(dump):gsub("\\[%z\1-\31\127-\255]", function(c) return ("\\%03d"):format(string.byte(c)) end)
local ups = {n = 0}
stack[val] = true
for i = 1, math.huge do
local ok, name, value = pcall(debug.getupvalue, val, i)
if not ok or not name then break end
ups[i] = value
ups.n = i
end
local name = "=(serialized function)"
local ok, info = pcall(debug.getinfo, val, "S")
if ok then name = info.source or name end
local v = ("__function(%s,%q,%s)"):format(dump, name, lua_serialize(ups, stack, opts, level + 1))
stack[val] = nil
return v
elseif tt == "nil" or tt == "number" or tt == "boolean" or tt == "string" then
return ("%q"):format(val):gsub("\\\n", "\\n"):gsub("\\?[%z\1-\31\127-\255]", function(c) return ("\\%03d"):format(string.byte(c)) end)
else
error("Cannot serialize type " .. tt, 0)
end
end
local function encodeTOMLArray(arr, opts, names)
local int, str = false, false
for l in pairs(arr) do
if type(l) == "number" then int = true
elseif type(l) == "string" then str = true
else error("key " .. table.concat(names, ".") .. "." .. tostring(l) .. " is not a string") end
end
local e = #names + 1
if not int and not str then return "[]"
elseif int and str then error("invalid entry " .. table.concat(names, ".") .. " (contains both array and dictionary values)")
elseif int then
local retval = "["
for i, v in ipairs(arr) do
if type(v) == "table" then
names[e] = tostring(i)
retval = retval .. (retval == "[" and "" or ", ") .. encodeTOMLArray(v, opts, names)
names[e] = nil
else retval = retval .. (retval == "[" and "" or ", ") .. lua_serialize(v, {}, {}, #names) end
end
return retval .. "]"
else
local res = "{"
for k, v in pairs(arr) do
if res ~= "{" then res = res .. ", " end
if type(k) == "string" and k:match "^[A-Za-z0-9_%-]+$" then res = res .. k
else res = res .. lua_serialize(k, {}, {}, #names) end
if type(v) == "table" then
names[e] = k
res = res .. " = " .. encodeTOMLArray(v, opts, names)
names[e] = nil
else res = res .. " = " .. lua_serialize(v, {}, {}, #names) end
end
return res .. "}"
end
end
local function encodeTOML(tbl, opts, names)
local retval = ""
local indent = opts.indent == false and "" or (" "):rep(#names)
if #names > 0 then retval = ("%s[%s]\n"):format((" "):rep(#names - 1), table.concat(names, ".")) end
local tbls, arrs = {}, {}
local e = #names + 1
for k, v in pairs(tbl) do
assert(type(k) == "string", "key " .. table.concat(names, ".") .. "." .. tostring(k) .. " is not a string")
local key = k:match("^[A-Za-z0-9_%-]+$") and k or lua_serialize(k, {}, {}, 1)
local t = type(v)
if t == "table" then
local int, str, tab = false, false, true
for l, w in pairs(v) do
if type(l) == "number" then int = true
elseif type(l) == "string" then str = true
else error("key " .. table.concat(names, ".") .. "." .. tostring(k) .. "." .. tostring(l) .. " is not a string") end
if type(w) ~= "table" then tab = false
else for m in pairs(w) do if type(m) ~= "string" then tab = false break end end end
end
if not int and not str then retval = retval .. indent .. key .. " = []\n"
elseif int and str then error("invalid entry " .. table.concat(names, ".") .. "." .. tostring(k) .. " (contains both array and dictionary values)")
elseif int then
if tab then
arrs[k] = v
else
names[e] = k
retval = retval .. indent .. key .. " = " .. encodeTOMLArray(v, opts, names)
names[e] = nil
end
else tbls[k] = v end
else retval = retval .. indent .. key .. " = " .. lua_serialize(v, {}, {}, #names) .. "\n" end
end
for k, arr in pairs(arrs) do
names[e] = k
for _, v in ipairs(arr) do
retval = retval .. ("%s[[%s]]\n"):format(indent, table.concat(names, ".")) .. encodeTOML(v, opts, names) .. "\n"
end
end
for k, v in pairs(tbls) do
names[e] = k
retval = retval .. ("%s[%s]\n"):format(indent, table.concat(names, ".")) .. encodeTOML(v, opts, names) .. "\n"
end
names[e] = nil
return retval
end
--- Encodes a table into TOML format. This table must only have integer or
-- string keys in itself and each subtable, and cannot mix strings and ints.
-- @tparam table tbl The table to encode
-- @tparam[opt] {indent=boolean} opts Any options to specify while encoding
-- @treturn string The encoded TOML data
function toml.encode(tbl, opts)
expect(1, tbl, "table")
expect(2, opts, "table", "nil")
return encodeTOML(tbl, opts or {}, {})
end
local function traverse(tab, name, pos, ln, wantlast)
local last, nm
while pos < #name do
if pos > 1 then
pos = name:match("^%s*()", pos)
if wantlast and name:sub(pos, pos) == "=" then return last, nm, pos + 1 end
if name:sub(pos, pos) ~= "." then error("Expected . on line " .. ln, 3) end
pos = name:match("^%s*()", pos + 1)
end
local key
if name:match('^"', pos) then key, pos = parse_string(name, pos + 1)
elseif name:match("^'", pos) then key, pos = name:match("'([^']*)'()", pos)
else key, pos = name:match("^([A-Za-z0-9_%-]+)()", pos) end
if not key then error("Invalid key name on line " .. ln, 3) end
last, nm = tab, key
if not tab[key] then tab[key] = {} end
tab = tab[key]
end
if wantlast then error("Expected = on line " .. ln, 3) end
return tab
end
local function next_token(line, pos, ln)
pos = line:match("^%s*()", pos)
while pos > #line or line:sub(pos, pos) == "#" do
line = coroutine.yield()
ln = ln + 1
pos = line:match "^%s*()"
end
return line, pos, ln
end
local function toml_assign(tab, key, line, pos, ln)
local op = line:sub(pos, pos)
while op == "#" do
line = coroutine.yield()
ln = ln + 1
pos = line:match "^%s*()"
op = line:sub(pos, pos)
end
if op == "[" then
local retval = {}
local i = 1
line, pos, ln = next_token(line, pos + 1, ln)
while true do
op = line:sub(pos, pos)
if op == "]" then break end
line, pos, ln = toml_assign(retval, i, line, pos, ln)
line, pos, ln = next_token(line, pos, ln)
op = line:sub(pos, pos)
if op == "]" then break end
if op ~= "," then error("Expected , on line " .. ln, 0) end
line, pos, ln = next_token(line, pos + 1, ln)
i = i + 1
end
tab[key] = retval
return line, pos + 1, ln
elseif op == "{" then
local retval = {}
line, pos, ln = next_token(line, pos + 1, ln)
while true do
op = line:sub(pos, pos)
if op == "}" then break end
local t, k
t, k, pos = traverse(retval, line, pos, ln, true)
line, pos, ln = next_token(line, pos, ln)
line, pos, ln = toml_assign(t, k, line, pos, ln)
line, pos, ln = next_token(line, pos, ln)
op = line:sub(pos, pos)
if op == "}" then break end
if op ~= "," then error("Expected , on line " .. ln, 0) end
line, pos, ln = next_token(line, pos + 1, ln)
end
tab[key] = retval
return line, pos + 1, ln
elseif op == "'" then
if line:match("^'''", pos) then
pos = pos + 3
local str = ""
while not line:find("'''", pos) do
if not (str == "" and pos == #line) then
str = str .. line:sub(pos) .. "\n"
end
line = coroutine.yield()
ln, pos = ln + 1, 1
end
str = str .. line:sub(pos, line:find("'''", pos) - 1)
pos = line:match("'''()", pos)
tab[key] = str
return line, pos, ln
else
local str, pos = line:match("^'([^']*)'()", pos)
if not str then error("Invalid literal string on line " .. ln, 0) end
tab[key] = str
return line, pos, ln
end
elseif op == '"' then
if line:match('^"""', pos) then
local s = ""
while not line:find('"""', pos) do
if not (s == "" and pos == #line) then
s = s .. line:sub(pos) .. "\n"
end
line = coroutine.yield()
ln, pos = ln + 1, 1
end
s = s .. line:sub(pos, line:find('"""', pos) - 1)
s = s:gsub("\\\r?\n", ""):gsub('"', '\\"') .. '"'
tab[key] = parse_string(s, 1)
pos = line:match('"""()', pos)
return line, pos, ln
else
local str, pos = parse_string(line, pos)
if not str then error("Invalid string on line " .. ln, 0) end
tab[key] = str
return line, pos, ln
end
elseif line:match("^%d%d%d%d%-%d%d%-%d%d[T ]%d%d:%d%d:%d%d", pos) then
local y, M, d, h, m, s, pos = line:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)()", pos)
local date = {
year = tonumber(y),
month = tonumber(M),
day = tonumber(d),
hour = tonumber(h),
min = tonumber(m),
sec = tonumber(s)
}
local time = os.time(date)
if line:match("^%.%d+", pos) then
local ss
ss, pos = line:match("(%.%d+)()", pos)
time = time + tonumber("0" .. ss)
end
local c = line:sub(pos, pos)
if c == "+" or c == "-" then
local oh, om
oh, om, pos = line:match("^[%+%-](%d%d):(%d%d)()", pos)
if not oh then error("Invalid date format on line " .. ln, 0) end
local offset = tonumber(oh) * 3600 + tonumber(om) * 60
if c == "-" then offset = -offset end
time = time + offset
elseif c == "Z" then pos = pos + 1 end
tab[key] = time
return line, pos, ln
elseif line:match("^%d%d%d%d%-%d%d%-%d%d", pos) then
local y, M, d, pos = line:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)()", pos)
local date = {
year = tonumber(y),
month = tonumber(M),
day = tonumber(d),
hour = 0,
min = 0,
sec = 0
}
local time = os.time(date)
tab[key] = time
return line, pos, ln
elseif line:match("^%d%d%:%d%d:%d%d", pos) then
local h, m, s, pos = line:match("^(%d%d):(%d%d):(%d%d)()", pos)
local time = h * 3600 + m * 60 * s
if line:match("^%.%d+", pos) then
local ss
ss, pos = line:match("(%.%d+)()", pos)
time = time + tonumber("0" .. ss)
end
tab[key] = time
return line, pos, ln
elseif op:match "%d" or op == "+" or op == "-" then
if line:match("^%+inf", pos) then tab[key] = math.huge return line, pos + 4, ln
elseif line:match("^%-inf", pos) then tab[key] = -math.huge return line, pos + 4, ln
elseif line:match("^%+nan", pos) then tab[key] = -(0/0) return line, pos + 4, ln
elseif line:match("^%-nan", pos) then tab[key] = 0/0 return line, pos + 4, ln
elseif line:match("^[%+%-]?0o", pos) then
local sign, num, pos = line:match("^([%+%-]?)0o([0-7_]+)()", pos):gsub("_", "")
if not num then error("Invalid number on line " .. ln, 0) end
num = tonumber(num, 8)
if not num then error("Invalid number on line " .. ln, 0) end
if sign == "-" then num = -num end
tab[key] = num
return line, pos, ln
elseif line:match ("^[%+%-]?0b", pos) then
local sign, num, pos = line:match("^([%+%-]?)0b([01_]+)()", pos):gsub("_", "")
if not num then error("Invalid number on line " .. ln, 0) end
num = tonumber(num, 2)
if not num then error("Invalid number on line " .. ln, 0) end
if sign == "-" then num = -num end
tab[key] = num
return line, pos, ln
else
local num, pos = line:match("^([%+%-]?[%d_]+%.?[%d_]*[Ee]?[%+%-]?[%d_]*)()", pos)
num = num:gsub("_", "")
num = tonumber(num)
if not num then error("Invalid number on line " .. ln, 0) end
tab[key] = num
return line, pos, ln
end
elseif line:match("^true", pos) then tab[key] = true return line, pos + 4, ln
elseif line:match("^false", pos) then tab[key] = false return line, pos + 5, ln
elseif line:match("^nil", pos) then tab[key] = nil return line, pos + 3, ln -- extension
elseif line:match("^inf", pos) then tab[key] = math.huge return line, pos + 3, ln
elseif line:match("^nan", pos) then tab[key] = -(0/0) return line, pos + 3, ln
else error("Unexpected " .. op .. " on line " .. ln, 0) end
end
--- Parses TOML data into a table.
-- @tparam string str The TOML data to decode
-- @tparam[opt] table opts Options (none available in this version)
-- @treturn table A table representing the TOML data
function toml.decode(str, opts)
expect(1, str, "string")
opts = expect(2, opts, "table", "nil") or {}
local retval = {}
local current = retval
local ln = 1
local coro
for line in str:gmatch "([^\r\n]*)\r?\n" do
if coro then
-- continuation of multi-line value
local ok, err = coroutine.resume(coro, line)
if not ok then error(err, 3) end
if coroutine.status(coro) == "dead" then coro = nil end
else
line = line:gsub("^%s+", "")
if line:match "^#" or line == "" then -- nothing
elseif line:match "^%[%[" then
local tag = line:match "^%[(%b[])%]"
if not tag then error("Expected ]] on line " .. ln, 2) end
current = traverse(retval, tag:sub(2, -2), 1, ln)
current[#current+1] = {}
current = current[#current]
elseif line:match "^%[" then
local tag = line:match "^%b[]"
if not tag then error("Expected ] on line " .. ln, 2) end
current = traverse(retval, tag:sub(2, -2), 1, ln)
else
local last, key, pos = traverse(current, line, 1, ln, true)
pos = line:match("^%s*()", pos)
if not pos then error("Expected value on line " .. ln, 2) end
coro = coroutine.create(toml_assign)
local ok, err = coroutine.resume(coro, last, key, line, pos, ln)
if not ok then error(err, 3) end
if coroutine.status(coro) == "dead" then coro = nil end
end
end
ln = ln + 1
end
if coro then error("Unfinished value at end of file", 2) end
return retval
end
return toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment