mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-05 19:47:49 +08:00
1768 lines
63 KiB
Lua
1768 lines
63 KiB
Lua
-- The MIT License (MIT)
|
|
|
|
-- Copyright (c) 2017 Jonathan Stoler
|
|
-- Copyright (c) 2025 Oleg Pustovit
|
|
-- Copyright (c) 2020-2025 Contributors (https://github.com/nexo-tech/toml2lua)
|
|
|
|
-- 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 TOML = {
|
|
-- denotes the current supported TOML version
|
|
version = "1.0.0",
|
|
|
|
-- sets whether the parser should follow the TOML spec strictly
|
|
-- currently, no errors are thrown for the following rules if strictness is turned off:
|
|
-- tables having mixed keys
|
|
-- redefining a table
|
|
-- redefining a key within a table
|
|
strict = true,
|
|
}
|
|
|
|
-- Value type creators for toml-test compatible intermediate format
|
|
local function createTomlTestValue(tomlType, value)
|
|
return { type = tomlType, value = tostring(value) }
|
|
end
|
|
|
|
-- Compatibility creators that maintain current behavior for now
|
|
local function createStringValue(str)
|
|
return { value = str, type = "string" }
|
|
end
|
|
|
|
local function createIntegerValue(num)
|
|
return { value = num, type = "integer" }
|
|
end
|
|
|
|
local function createFloatValue(num)
|
|
return { value = num, type = "float" }
|
|
end
|
|
|
|
local function createBooleanValue(bool)
|
|
return { value = bool, type = "boolean" }
|
|
end
|
|
|
|
local function createDateValue(dateObj)
|
|
return { value = dateObj, type = "date" }
|
|
end
|
|
|
|
local function createArrayValue(arr)
|
|
return { value = arr, type = "array" }
|
|
end
|
|
|
|
-- Helper function to determine the correct TOML type for dates
|
|
local function getDateTomlType(dateObj)
|
|
if dateObj.year and dateObj.hour and dateObj.zone ~= nil then
|
|
return "datetime"
|
|
elseif dateObj.year and dateObj.hour and dateObj.zone == nil then
|
|
return "datetime-local"
|
|
elseif dateObj.year and not dateObj.hour then
|
|
return "date-local"
|
|
elseif not dateObj.year and dateObj.hour then
|
|
return "time-local"
|
|
else
|
|
return "datetime" -- fallback
|
|
end
|
|
end
|
|
|
|
-- Convert from internal format to toml-test format
|
|
local function toTomlTestFormat(internalValue)
|
|
if internalValue.type == "string" then
|
|
return createTomlTestValue("string", internalValue.value)
|
|
elseif internalValue.type == "integer" then
|
|
return createTomlTestValue("integer", internalValue.value)
|
|
elseif internalValue.type == "float" then
|
|
-- Handle special float values
|
|
if internalValue.value == math.huge then
|
|
return createTomlTestValue("float", "inf")
|
|
elseif internalValue.value == -math.huge then
|
|
return createTomlTestValue("float", "-inf")
|
|
elseif internalValue.value ~= internalValue.value then -- NaN check
|
|
return createTomlTestValue("float", "nan")
|
|
else
|
|
return createTomlTestValue("float", internalValue.value)
|
|
end
|
|
elseif internalValue.type == "boolean" then
|
|
return createTomlTestValue("bool", internalValue.value)
|
|
elseif internalValue.type == "date" then
|
|
local dateType = getDateTomlType(internalValue.value)
|
|
return createTomlTestValue(dateType, internalValue.value)
|
|
elseif internalValue.type == "array" then
|
|
-- Arrays are handled differently - they remain as Lua tables
|
|
return internalValue
|
|
else
|
|
return internalValue -- fallback
|
|
end
|
|
end
|
|
|
|
-- Convert from toml-test format back to Lua native types (for final output)
|
|
local function fromTomlTestFormat(tomlTestValue)
|
|
if tomlTestValue.type == "string" then
|
|
return tomlTestValue.value
|
|
elseif tomlTestValue.type == "integer" then
|
|
return tonumber(tomlTestValue.value)
|
|
elseif tomlTestValue.type == "float" then
|
|
local val = tomlTestValue.value
|
|
if val == "inf" then
|
|
return math.huge
|
|
elseif val == "-inf" then
|
|
return -math.huge
|
|
elseif val == "nan" then
|
|
return 0 / 0
|
|
else
|
|
return tonumber(val)
|
|
end
|
|
elseif tomlTestValue.type == "bool" then
|
|
return tomlTestValue.value == "true"
|
|
elseif tomlTestValue.type:match("^date") or tomlTestValue.type:match("^time") then
|
|
return tomlTestValue.value -- date objects remain as-is
|
|
else
|
|
return tomlTestValue.value -- fallback
|
|
end
|
|
end
|
|
|
|
local date_metatable = {
|
|
__tostring = function(t)
|
|
local rep = ''
|
|
if t.year then
|
|
rep = rep .. string.format("%04d-%02d-%02d", t.year, t.month, t.day)
|
|
end
|
|
if t.hour then
|
|
if t.year then
|
|
rep = rep .. ' '
|
|
end
|
|
rep = rep .. string.format("%02d:%02d:", t.hour, t.min)
|
|
local sec, frac = math.modf(t.sec)
|
|
rep = rep .. string.format("%02d", sec)
|
|
if frac > 0 then
|
|
rep = rep .. tostring(frac):gsub("0(.-)0*$", "%1")
|
|
end
|
|
end
|
|
if t.zone then
|
|
if t.zone >= 0 then
|
|
rep = rep .. '+' .. string.format("%02d:00", t.zone)
|
|
elseif t.zone < 0 then
|
|
rep = rep .. '-' .. string.format("%02d:00", -t.zone)
|
|
end
|
|
end
|
|
return rep
|
|
end,
|
|
}
|
|
|
|
local setmetatable, getmetatable = setmetatable, getmetatable
|
|
|
|
TOML.datefy = function(tab)
|
|
-- Validate date/time components
|
|
if tab.year and (tab.year < 0 or tab.year > 9999) then
|
|
return nil, "Invalid year"
|
|
end
|
|
if tab.month and (tab.month < 1 or tab.month > 12) then
|
|
return nil, "Invalid month"
|
|
end
|
|
if tab.day and (tab.day < 1 or tab.day > 31) then
|
|
return nil, "Invalid day"
|
|
end
|
|
if tab.hour and (tab.hour < 0 or tab.hour > 23) then
|
|
return nil, "Invalid hour"
|
|
end
|
|
if tab.min and (tab.min < 0 or tab.min > 59) then
|
|
return nil, "Invalid minute"
|
|
end
|
|
if tab.sec and (tab.sec < 0 or tab.sec > 60) then -- Allow leap seconds
|
|
return nil, "Invalid second"
|
|
end
|
|
if tab.zone and (tab.zone < -23 or tab.zone > 23) then
|
|
return nil, "Invalid timezone"
|
|
end
|
|
|
|
-- Additional validation for day based on month/year
|
|
if tab.year and tab.month and tab.day then
|
|
local days_in_month = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
|
|
|
|
-- Check for leap year
|
|
if tab.month == 2 and ((tab.year % 4 == 0 and tab.year % 100 ~= 0) or (tab.year % 400 == 0)) then
|
|
days_in_month[2] = 29
|
|
end
|
|
|
|
if tab.day > days_in_month[tab.month] then
|
|
return nil, "Invalid day for month"
|
|
end
|
|
end
|
|
|
|
return setmetatable(tab, date_metatable)
|
|
end
|
|
|
|
TOML.isdate = function(tab)
|
|
return getmetatable(tab) == date_metatable
|
|
end
|
|
|
|
-- converts TOML data into a lua table
|
|
TOML.multistep_parser = function(options)
|
|
options = options or {}
|
|
local strict = (options.strict ~= nil and options.strict or TOML.strict)
|
|
local toml = ''
|
|
|
|
-- the output table
|
|
local out = {}
|
|
local ERR = {}
|
|
|
|
-- the current table to write to
|
|
local obj = out
|
|
|
|
-- stores text data
|
|
local buffer = ""
|
|
|
|
-- the current location within the string to parse
|
|
local cursor = 1
|
|
|
|
-- remember that the last chunk was already read
|
|
local stream_ended = false
|
|
|
|
local nl_count = 1
|
|
|
|
local function result_or_error()
|
|
if #ERR > 0 then return nil, ERR[1] end
|
|
return out
|
|
end
|
|
|
|
-- produce a parsing error message
|
|
-- the error contains the line number of the current position
|
|
local function err(message, strictOnly)
|
|
if not strictOnly or (strictOnly and strict) then
|
|
local line = 1
|
|
local c = 0
|
|
local msg = "At TOML line " .. nl_count .. ': ' .. message .. "."
|
|
if not ERR[msg] then
|
|
ERR[1 + #ERR] = msg
|
|
ERR[msg] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- read n characters (at least) or chunk terminator (nil)
|
|
local function getNewData(n)
|
|
while not stream_ended do
|
|
if cursor + (n or 0) < #toml then break end
|
|
local new_data = coroutine.yield(result_or_error())
|
|
if new_data == nil then
|
|
stream_ended = true
|
|
break
|
|
end
|
|
toml = toml:sub(cursor)
|
|
cursor = 1
|
|
toml = toml .. new_data
|
|
end
|
|
end
|
|
|
|
-- TODO : use 1-based indexing ?
|
|
-- returns the next n characters from the current position
|
|
local function getData(a, b)
|
|
getNewData(b)
|
|
a = a or 0
|
|
b = b or (toml:len() - cursor)
|
|
return toml:sub(cursor + a, cursor + b)
|
|
end
|
|
|
|
-- returns the next n characters from the current position
|
|
local function char(n)
|
|
n = n or 0
|
|
return getData(n, n)
|
|
end
|
|
|
|
-- count how many new lines are in the next n chars
|
|
local function count_source_line(n)
|
|
local count = 0
|
|
for _ in getData(0, n - 1):gmatch('\n') do
|
|
count = count + 1
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- function to check if current position is at a newline (LF or CRLF)
|
|
local function isNewline()
|
|
if char() == "\10" then -- LF
|
|
return true
|
|
elseif char() == "\13" and char(1) == "\10" then -- CRLF
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- moves the current position forward n (default: 1) characters
|
|
local function step(n)
|
|
n = n or 1
|
|
nl_count = nl_count + count_source_line(n)
|
|
cursor = cursor + n
|
|
end
|
|
|
|
-- prevent infinite loops by checking whether the cursor is
|
|
-- at the end of the document or not
|
|
local function bounds()
|
|
if cursor <= toml:len() then return true end
|
|
getNewData(1)
|
|
return cursor <= toml:len()
|
|
end
|
|
|
|
-- Check if we are at end of the data
|
|
local function dataEnd()
|
|
return cursor >= toml:len()
|
|
end
|
|
|
|
-- Match official TOML definition of whitespace
|
|
local function matchWs(n)
|
|
n = n or 0
|
|
return getData(n, n):match("[\009\032]")
|
|
end
|
|
|
|
-- Match the official TOML definition of newline
|
|
local function matchnl(n)
|
|
n = n or 0
|
|
local c = getData(n, n)
|
|
if c == '\10' then return '\10' end
|
|
return getData(n, n + 1):match("^\13\10")
|
|
end
|
|
|
|
-- move forward until the next non-whitespace character
|
|
local function skipWhitespace()
|
|
while (matchWs()) do
|
|
step()
|
|
end
|
|
end
|
|
|
|
-- remove the (Lua) whitespace at the beginning and end of a string
|
|
local function trim(str)
|
|
return str:gsub("^%s*(.-)%s*$", "%1")
|
|
end
|
|
|
|
-- divide a string into a table around a delimiter
|
|
local function split(str, delim)
|
|
if str == "" then return {} end
|
|
local result = {}
|
|
local append = delim
|
|
if delim:match("%%") then
|
|
append = delim:gsub("%%", "")
|
|
end
|
|
for match in (str .. append):gmatch("(.-)" .. delim) do
|
|
table.insert(result, match)
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function parseString()
|
|
local quoteType = char() -- should be single or double quote
|
|
|
|
-- this is a multiline string if the next 2 characters match
|
|
local multiline = (char(1) == char(2) and char(1) == char())
|
|
|
|
-- buffer to hold the string
|
|
local str = ""
|
|
|
|
-- skip the quotes
|
|
step(multiline and 3 or 1)
|
|
|
|
local foundClosingQuote = false
|
|
|
|
while (bounds()) do
|
|
if multiline and matchnl() and str == "" then
|
|
-- skip line break line at the beginning of multiline string
|
|
if char() == "\13" and char(1) == "\10" then
|
|
step(2) -- skip CRLF
|
|
else
|
|
step() -- skip LF
|
|
end
|
|
end
|
|
|
|
-- keep going until we encounter the quote character again
|
|
if char() == quoteType then
|
|
if multiline then
|
|
if char(1) == char(2) and char(1) == quoteType then
|
|
step(3)
|
|
foundClosingQuote = true
|
|
break
|
|
end
|
|
else
|
|
step()
|
|
foundClosingQuote = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if matchnl() and not multiline then
|
|
err("Single-line string cannot contain line break")
|
|
end
|
|
|
|
-- if we're in a double-quoted string, watch for escape characters!
|
|
if quoteType == '"' and char() == "\\" then
|
|
if multiline and matchnl(1) then
|
|
-- skip until first non-whitespace character
|
|
step(1) -- go past the line break
|
|
while (bounds()) do
|
|
if not matchWs() and not matchnl() then
|
|
break
|
|
end
|
|
if isNewline() then
|
|
if char() == "\13" and char(1) == "\10" then
|
|
step(2) -- skip CRLF
|
|
else
|
|
step() -- skip LF
|
|
end
|
|
else
|
|
step()
|
|
end
|
|
end
|
|
else
|
|
-- all available escape characters
|
|
local escape = {
|
|
b = "\b",
|
|
t = "\t",
|
|
n = "\n",
|
|
f = "\f",
|
|
r = "\r",
|
|
['"'] = '"',
|
|
["\\"] = "\\",
|
|
}
|
|
-- utf function from http://stackoverflow.com/a/26071044
|
|
-- converts \uXXX into actual unicode
|
|
local function utf(char)
|
|
local bytemarkers = { { 0x7ff, 192 }, { 0xffff, 224 }, { 0x1fffff, 240 } }
|
|
if char < 128 then return string.char(char) end
|
|
local charbytes = {}
|
|
for bytes, vals in pairs(bytemarkers) do
|
|
if char <= vals[1] then
|
|
for b = bytes + 1, 2, -1 do
|
|
local mod = char % 64
|
|
char = (char - mod) / 64
|
|
charbytes[b] = string.char(128 + mod)
|
|
end
|
|
charbytes[1] = string.char(vals[2] + char)
|
|
break
|
|
end
|
|
end
|
|
return table.concat(charbytes)
|
|
end
|
|
|
|
if escape[char(1)] then
|
|
-- normal escape
|
|
str = str .. escape[char(1)]
|
|
step(2) -- go past backslash and the character
|
|
elseif char(1) == "u" then
|
|
-- utf-16
|
|
step()
|
|
local uni = char(1) .. char(2) .. char(3) .. char(4)
|
|
step(5)
|
|
local uniNum = tonumber(uni, 16)
|
|
if not uniNum then
|
|
err("Unicode escape is not a Unicode scalar")
|
|
elseif (uniNum >= 0 and uniNum <= 0xd7ff) and not (uniNum >= 0xe000 and uniNum <= 0x10ffff) then
|
|
str = str .. utf(uniNum)
|
|
else
|
|
err("Unicode escape is not a Unicode scalar")
|
|
end
|
|
elseif char(1) == "U" then
|
|
-- utf-32
|
|
step()
|
|
local uni = char(1) .. char(2) .. char(3) .. char(4) .. char(5) .. char(6) .. char(7) .. char(8)
|
|
step(9)
|
|
local uniNum = tonumber(uni, 16)
|
|
if (uniNum >= 0 and uniNum <= 0xd7ff) and not (uniNum >= 0xe000 and uniNum <= 0x10ffff) then
|
|
str = str .. utf(uniNum)
|
|
else
|
|
err("Unicode escape is not a Unicode scalar")
|
|
end
|
|
else
|
|
err("Invalid escape")
|
|
step()
|
|
end
|
|
end
|
|
else
|
|
-- if we're not in a double-quoted string, just append it to our buffer raw and keep going
|
|
str = str .. char()
|
|
step()
|
|
end
|
|
end
|
|
|
|
-- If we get here without finding the closing quote, it's an error
|
|
if not foundClosingQuote then
|
|
err("Unterminated string")
|
|
end
|
|
|
|
return createStringValue(str)
|
|
end
|
|
|
|
-- Unified date/time component matchers
|
|
local function matchDate()
|
|
local year, month, day, n =
|
|
getData(0, 10):match('^(%d%d%d%d)%-([0-1][0-9])%-([0-3][0-9])()')
|
|
|
|
if not year then return nil end
|
|
step(n - 1)
|
|
|
|
return tonumber(year), tonumber(month), tonumber(day)
|
|
end
|
|
|
|
local function matchTime()
|
|
local hour, minute, second, n =
|
|
getData(0, 19):match('^([0-2][0-9])%:([0-6][0-9])%:(%d+%.?%d*)()')
|
|
|
|
if not hour then return nil end
|
|
step(n - 1)
|
|
|
|
return tonumber(hour), tonumber(minute), tonumber(second)
|
|
end
|
|
|
|
local function matchTimezone()
|
|
local eastwest, offset, zero, n =
|
|
getData(0, 6):match('^([%+%-])([0-9][0-9])%:([0-9][0-9])()')
|
|
|
|
if not eastwest then return nil end
|
|
step(n - 1)
|
|
|
|
return tonumber(eastwest .. offset)
|
|
end
|
|
|
|
-- Helper function to create and validate date objects
|
|
local function createValidatedDateValue(components)
|
|
local value, e = TOML.datefy(components)
|
|
if not value then
|
|
err(e)
|
|
return nil
|
|
end
|
|
return createDateValue(value)
|
|
end
|
|
|
|
local function parseDate()
|
|
local year, month, day = matchDate()
|
|
if not year then
|
|
err("Invalid date")
|
|
return nil
|
|
end
|
|
|
|
local hour, minute, second = nil, nil, nil
|
|
local zone = nil
|
|
|
|
-- Check for date-time separator
|
|
if char():match('[T ]') then
|
|
step(1)
|
|
hour, minute, second = matchTime()
|
|
if not hour then
|
|
err("Invalid date")
|
|
return nil
|
|
end
|
|
|
|
-- Check for timezone
|
|
if char():match('Z') then
|
|
step(1)
|
|
zone = 0
|
|
else
|
|
zone = matchTimezone()
|
|
end
|
|
end
|
|
|
|
local components = {
|
|
year = year,
|
|
month = month,
|
|
day = day,
|
|
hour = hour,
|
|
min = minute,
|
|
sec = second,
|
|
zone = zone,
|
|
}
|
|
|
|
return createValidatedDateValue(components)
|
|
end
|
|
|
|
local function parseTime()
|
|
local hour, minute, second = matchTime()
|
|
if not hour then
|
|
err("Invalid time")
|
|
return nil
|
|
end
|
|
|
|
local components = {
|
|
hour = hour,
|
|
min = minute,
|
|
sec = second,
|
|
}
|
|
|
|
return createValidatedDateValue(components)
|
|
end
|
|
|
|
-- Helper functions for number parsing
|
|
local function isNumberTerminator()
|
|
return matchWs() or char() == "#" or matchnl() or char() == "," or char() == "]" or char() == "}"
|
|
end
|
|
|
|
local function validateUnderscore(currentChar, nextChar, numberStr, prevUnderscore)
|
|
if currentChar == "_" then
|
|
if prevUnderscore then
|
|
err("Double underscore in number")
|
|
return false
|
|
end
|
|
if numberStr == "" then
|
|
err("Underscore cannot be at beginning of number")
|
|
return false
|
|
end
|
|
if numberStr:sub(#numberStr) == "." then
|
|
err("Underscore after decimal point")
|
|
return false
|
|
end
|
|
if nextChar == "." then
|
|
err("Underscore before decimal point")
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function parseSpecialBaseNumber()
|
|
local prefixes = { ["0x"] = 16, ["0o"] = 8, ["0b"] = 2 }
|
|
local prefix = char() .. char(1)
|
|
local base = prefixes[prefix]
|
|
if not base then return nil end
|
|
|
|
step(2)
|
|
local digits = ({ [2] = "[01]", [8] = "[0-7]", [16] = "%x" })[base]
|
|
local num = ""
|
|
local prevUnderscore = false
|
|
|
|
while bounds() do
|
|
if char():match(digits) then
|
|
num = num .. char()
|
|
prevUnderscore = false
|
|
elseif isNumberTerminator() then
|
|
break
|
|
elseif char() == "_" then
|
|
if prevUnderscore then
|
|
err("Double underscore in number")
|
|
return false -- Return false to indicate error
|
|
end
|
|
if num == "" then
|
|
err("Underscore cannot be at beginning of number")
|
|
return false
|
|
end
|
|
prevUnderscore = true
|
|
else
|
|
err("Invalid number")
|
|
return false
|
|
end
|
|
step()
|
|
end
|
|
|
|
if prevUnderscore then
|
|
err("Invalid underscore at end of number")
|
|
return false
|
|
end
|
|
if num == "" then
|
|
err("Invalid number")
|
|
return false
|
|
end
|
|
|
|
return createIntegerValue(tonumber(num, base))
|
|
end
|
|
|
|
local function parseNumber()
|
|
-- Try parsing special base numbers first
|
|
local specialResult = parseSpecialBaseNumber()
|
|
if specialResult == false then
|
|
return nil -- Error in special base parsing
|
|
elseif specialResult then
|
|
return specialResult -- Valid special base number
|
|
end
|
|
-- specialResult is nil, so not a special base number - continue with decimal parsing
|
|
|
|
-- Parse decimal numbers
|
|
local num = ""
|
|
local exp = nil
|
|
local dotfound = false
|
|
local prev_underscore = false
|
|
|
|
while bounds() do
|
|
if char():match("[%+%-%.eE_0-9]") then
|
|
if char():match '%.' then dotfound = true end
|
|
|
|
-- Handle underscore validation
|
|
if validateUnderscore(char(), char(1), num, prev_underscore) then
|
|
prev_underscore = true
|
|
else
|
|
-- Handle exponent
|
|
if not exp then
|
|
if char():lower() == "e" then
|
|
exp = ""
|
|
elseif char() ~= "_" then
|
|
num = num .. char()
|
|
end
|
|
elseif char():match("[%+%-_0-9]") then
|
|
if char() ~= "_" then
|
|
exp = exp .. char()
|
|
end
|
|
else
|
|
err("Invalid exponent")
|
|
end
|
|
|
|
prev_underscore = false
|
|
end
|
|
elseif isNumberTerminator() then
|
|
break
|
|
else
|
|
err("Invalid number")
|
|
end
|
|
step()
|
|
end
|
|
|
|
if prev_underscore then
|
|
err("Invalid underscore at end of number")
|
|
return nil
|
|
end
|
|
|
|
-- Validate number format
|
|
if num:match('^[%+%-]?0[0-9]') then
|
|
err('Leading zero found in number')
|
|
end
|
|
if dotfound and num:match('%.$') then
|
|
err('No trailing zero found in float')
|
|
end
|
|
|
|
-- Apply exponent
|
|
local exp_val = exp and tonumber(exp) or 0
|
|
local multiplier = 1
|
|
if exp_val > 0 then
|
|
multiplier = math.floor(10 ^ exp_val)
|
|
elseif exp_val < 0 then
|
|
multiplier = 10 ^ exp_val
|
|
end
|
|
local finalNum = tonumber(num) * multiplier
|
|
|
|
-- Return appropriate type
|
|
if exp_val < 0 or dotfound then
|
|
return createFloatValue(finalNum)
|
|
end
|
|
return createIntegerValue(finalNum)
|
|
end
|
|
|
|
local parseArray, getValue
|
|
|
|
function parseArray()
|
|
step() -- skip [
|
|
skipWhitespace()
|
|
|
|
local arrayType
|
|
local array = {}
|
|
|
|
while (bounds()) do
|
|
if char() == "]" then
|
|
break
|
|
elseif matchnl() then
|
|
-- skip
|
|
step()
|
|
skipWhitespace()
|
|
elseif char() == "#" then
|
|
while (bounds() and not matchnl()) do
|
|
step()
|
|
end
|
|
else
|
|
-- get the next object in the array
|
|
local v = getValue()
|
|
if not v then break end
|
|
|
|
-- v1.0.0 allows mixed types in arrays
|
|
if arrayType == nil then
|
|
arrayType = v.type
|
|
elseif arrayType ~= v.type then
|
|
-- Mixed types are allowed in v1.0.0, so just update arrayType to indicate mixed
|
|
arrayType = "mixed"
|
|
end
|
|
|
|
array = array or {}
|
|
table.insert(array, v.value)
|
|
|
|
if char() == "," then
|
|
step()
|
|
end
|
|
skipWhitespace()
|
|
end
|
|
end
|
|
|
|
-- Check if we found the closing bracket
|
|
if not bounds() or char() ~= "]" then
|
|
err("Missing closing bracket in array")
|
|
end
|
|
step()
|
|
|
|
return createArrayValue(array)
|
|
end
|
|
|
|
local function parseInlineTable()
|
|
step() -- skip opening brace
|
|
|
|
local buffer = ""
|
|
local quoted = false
|
|
local tbl = {}
|
|
|
|
while bounds() do
|
|
if char() == "}" then
|
|
break
|
|
elseif char() == "'" or char() == '"' then
|
|
buffer = parseString().value
|
|
quoted = true
|
|
skipWhitespace()
|
|
elseif char() == "=" then
|
|
if not quoted then
|
|
buffer = trim(buffer)
|
|
end
|
|
|
|
step() -- skip =
|
|
skipWhitespace()
|
|
|
|
if matchnl() then
|
|
err("Newline in inline table")
|
|
end
|
|
|
|
local v = getValue().value
|
|
tbl[buffer] = v
|
|
|
|
skipWhitespace()
|
|
|
|
if char() == "," then
|
|
step()
|
|
skipWhitespace()
|
|
if matchnl() then
|
|
err("Newline in inline table")
|
|
end
|
|
elseif matchnl() then
|
|
err("Newline in inline table")
|
|
end
|
|
|
|
quoted = false
|
|
buffer = ""
|
|
else
|
|
if quoted then
|
|
if not matchWs() then
|
|
err("Unexpected character after the key")
|
|
end
|
|
else
|
|
if matchnl() then
|
|
err("Newline in inline table")
|
|
end
|
|
buffer = buffer .. char()
|
|
end
|
|
step()
|
|
end
|
|
end
|
|
|
|
-- Check if we found the closing brace
|
|
if not bounds() or char() ~= "}" then
|
|
err("Missing closing brace in inline table")
|
|
end
|
|
step() -- skip closing brace
|
|
|
|
return createArrayValue(tbl)
|
|
end
|
|
|
|
local function parseBoolean()
|
|
local v
|
|
if getData(0, 3) == "true" then
|
|
step(4)
|
|
v = createBooleanValue(true)
|
|
elseif getData(0, 4) == "false" then
|
|
step(5)
|
|
v = createBooleanValue(false)
|
|
elseif getData(0, 2) == "inf" then
|
|
step(3)
|
|
v = createFloatValue(math.huge)
|
|
elseif getData(0, 3) == "+inf" then
|
|
step(4)
|
|
v = createFloatValue(math.huge)
|
|
elseif getData(0, 3) == "-inf" then
|
|
step(4)
|
|
v = createFloatValue(-math.huge)
|
|
elseif getData(0, 2) == "nan" then
|
|
step(3)
|
|
v = createFloatValue(0 / 0)
|
|
elseif getData(0, 3) == "+nan" then
|
|
step(4)
|
|
v = createFloatValue(0 / 0)
|
|
elseif getData(0, 3) == "-nan" then
|
|
step(4)
|
|
v = createFloatValue(0 / 0)
|
|
else
|
|
err("Invalid primitive")
|
|
end
|
|
|
|
skipWhitespace()
|
|
if char() == "#" then
|
|
while (bounds() and not matchnl()) do
|
|
step()
|
|
end
|
|
end
|
|
|
|
return v
|
|
end
|
|
|
|
-- Value type detection helpers
|
|
local function isStringStart()
|
|
return char() == '"' or char() == "'"
|
|
end
|
|
|
|
local function isDateStart()
|
|
return getData(0, 5):match("^%d%d%d%d%-%d")
|
|
end
|
|
|
|
local function isTimeStart()
|
|
return getData(0, 3):match("^%d%d%:%d")
|
|
end
|
|
|
|
local function isSpecialFloat()
|
|
local data2 = getData(0, 2)
|
|
local data3 = getData(0, 3)
|
|
return data2 == "inf" or data3 == "+inf" or data3 == "-inf" or
|
|
data2 == "nan" or data3 == "+nan" or data3 == "-nan"
|
|
end
|
|
|
|
local function isNumberStart()
|
|
return char():match("[%+%-0-9]")
|
|
end
|
|
|
|
local function isArrayStart()
|
|
return char() == "["
|
|
end
|
|
|
|
local function isInlineTableStart()
|
|
return char() == "{"
|
|
end
|
|
|
|
-- figure out the type and get the next value in the document
|
|
function getValue()
|
|
if isStringStart() then
|
|
return parseString()
|
|
elseif isDateStart() then
|
|
return parseDate()
|
|
elseif isTimeStart() then
|
|
return parseTime()
|
|
elseif isSpecialFloat() then
|
|
return parseBoolean() -- Special float values handled in parseBoolean
|
|
elseif isNumberStart() then
|
|
return parseNumber()
|
|
elseif isArrayStart() then
|
|
return parseArray()
|
|
elseif isInlineTableStart() then
|
|
return parseInlineTable()
|
|
else
|
|
return parseBoolean()
|
|
end
|
|
end
|
|
|
|
local function parse()
|
|
-- track whether the current key was quoted or not
|
|
local quotedKey = false
|
|
|
|
local function check_key()
|
|
if buffer == "" then
|
|
err("Empty key")
|
|
end
|
|
if buffer:match("[%s%c%%%(%)%*%+%.%?%[%]!\"#$&',/:;<=>@`\\^{|}~]") and not quotedKey then
|
|
err('Invalid character in key')
|
|
end
|
|
end
|
|
|
|
-- avoid double table definition
|
|
local defined_table = setmetatable({}, { __mode = 'kv' })
|
|
|
|
-- keep track of container type i.e. table vs array
|
|
local container_type = setmetatable({}, { __mode = 'kv' })
|
|
|
|
local function processKey(isLast, tableArray, quotedKey)
|
|
if isLast and obj[buffer] and not tableArray and #obj[buffer] > 0 then
|
|
err("Cannot redefine table", true)
|
|
end
|
|
|
|
-- set obj to the appropriate table so we can start
|
|
-- filling it with values!
|
|
if tableArray then
|
|
-- push onto cache
|
|
local current = obj[buffer]
|
|
|
|
-- crete as needed + identify table vs array
|
|
local isArray = false
|
|
if current then
|
|
isArray = (container_type[current] == 'array')
|
|
else
|
|
current = {}
|
|
obj[buffer] = current
|
|
if isLast then
|
|
isArray = true
|
|
container_type[current] = 'array'
|
|
else
|
|
isArray = false
|
|
container_type[current] = 'hash'
|
|
end
|
|
end
|
|
|
|
if isLast and not isArray then
|
|
err('The selected key contains a table, not an array', true)
|
|
end
|
|
|
|
-- update current object
|
|
if not isLast then obj = current end
|
|
if isArray then
|
|
if isLast then table.insert(current, {}) end
|
|
obj = current[#current]
|
|
end
|
|
else
|
|
local newObj = obj[buffer] or {}
|
|
obj[buffer] = newObj
|
|
if #newObj > 0 then
|
|
if type(newObj) ~= 'table' then
|
|
err('Duplicate field')
|
|
else
|
|
-- an array is already in progress for this key, so modify its
|
|
-- last element, instead of the array itself
|
|
obj = newObj[#newObj]
|
|
end
|
|
else
|
|
obj = newObj
|
|
end
|
|
end
|
|
if isLast then
|
|
if defined_table[obj] then
|
|
err('Duplicated table definition')
|
|
end
|
|
defined_table[obj] = true
|
|
end
|
|
end
|
|
|
|
-- track dotted key parsing state
|
|
local dottedKeyParts = {}
|
|
local inDottedKey = false
|
|
|
|
-- parse the document!
|
|
while (bounds()) do
|
|
-- skip comments and whitespace
|
|
-- Only treat # as comment if we're not in the middle of parsing a key
|
|
if char() == "#" and (trim(buffer) == "" or quotedKey) then
|
|
while (bounds() and not matchnl()) do
|
|
step()
|
|
end
|
|
end
|
|
|
|
if matchnl() then
|
|
if trim(buffer) ~= '' then
|
|
err('Invalid key')
|
|
end
|
|
buffer = ""
|
|
dottedKeyParts = {}
|
|
inDottedKey = false
|
|
step()
|
|
elseif char() == "=" then
|
|
step()
|
|
skipWhitespace()
|
|
|
|
-- Add current buffer to dotted key parts if we're in a dotted key
|
|
if inDottedKey then
|
|
if not quotedKey then
|
|
buffer = trim(buffer)
|
|
end
|
|
if buffer ~= "" then
|
|
table.insert(dottedKeyParts, buffer)
|
|
end
|
|
end
|
|
|
|
-- Handle dotted keys vs regular keys
|
|
if inDottedKey and #dottedKeyParts > 1 then
|
|
-- This is a dotted key - create nested structure
|
|
local currentObj = obj
|
|
local conflictDetected = false
|
|
|
|
for i = 1, #dottedKeyParts - 1 do
|
|
local key = dottedKeyParts[i]
|
|
local numericKey = key
|
|
if key:match("^[0-9]+$") then
|
|
numericKey = tonumber(key)
|
|
end
|
|
if numericKey and not currentObj[numericKey] then
|
|
currentObj[numericKey] = {}
|
|
elseif type(currentObj[numericKey]) ~= "table" then
|
|
err('Cannot create table: key "' .. key .. '" already has a non-table value')
|
|
conflictDetected = true
|
|
break
|
|
end
|
|
currentObj = currentObj[numericKey]
|
|
end
|
|
|
|
if not conflictDetected then
|
|
local finalKey = dottedKeyParts[#dottedKeyParts]
|
|
local finalNumericKey = finalKey
|
|
if finalKey:match("^[0-9]+$") then
|
|
finalNumericKey = tonumber(finalKey)
|
|
end
|
|
|
|
local v = getValue()
|
|
if v then
|
|
if currentObj[finalNumericKey] ~= nil then
|
|
err('Cannot redefine key "' .. finalKey .. '"', true)
|
|
end
|
|
if finalNumericKey then
|
|
currentObj[finalNumericKey] = v.value
|
|
end
|
|
end
|
|
else
|
|
-- Still need to consume the value even if there was a conflict
|
|
getValue()
|
|
end
|
|
elseif not quotedKey and buffer:find("%.") then
|
|
-- Handle simple dotted keys (backward compatibility)
|
|
local keys = {}
|
|
for key in buffer:gmatch("[^%.]+") do
|
|
table.insert(keys, key)
|
|
end
|
|
|
|
-- Validate each key segment
|
|
for _, key in ipairs(keys) do
|
|
local tempBuffer = key
|
|
if tempBuffer:match("[%s%c%%%(%)%*%+%.%?%[%]!\"#$&',/:;<=>@`\\^{|}~]") then
|
|
err('Invalid character in key')
|
|
end
|
|
end
|
|
|
|
-- Navigate/create the nested structure
|
|
local currentObj = obj
|
|
local conflictDetected = false
|
|
for i = 1, #keys - 1 do
|
|
local key = keys[i]
|
|
local numericKey = key
|
|
if key:match("^[0-9]+$") then
|
|
numericKey = tonumber(key)
|
|
end
|
|
if numericKey and not currentObj[numericKey] then
|
|
currentObj[numericKey] = {}
|
|
elseif type(currentObj[numericKey]) ~= "table" then
|
|
err('Cannot create table: key "' .. key .. '" already has a non-table value')
|
|
conflictDetected = true
|
|
break
|
|
end
|
|
currentObj = currentObj[numericKey]
|
|
end
|
|
|
|
-- Set the final key only if no conflict was detected
|
|
if not conflictDetected then
|
|
local finalKey = keys[#keys]
|
|
local finalNumericKey = finalKey
|
|
if finalKey:match("^[0-9]+$") then
|
|
finalNumericKey = tonumber(finalKey)
|
|
end
|
|
|
|
local v = getValue()
|
|
if v and finalNumericKey then
|
|
if currentObj[finalNumericKey] ~= nil then
|
|
err('Cannot redefine key "' .. finalKey .. '"', true)
|
|
end
|
|
currentObj[finalNumericKey] = v.value
|
|
end
|
|
else
|
|
-- Still need to consume the value even if there was a conflict
|
|
getValue()
|
|
end
|
|
else
|
|
-- Regular key handling
|
|
if not quotedKey then
|
|
buffer = trim(buffer)
|
|
check_key()
|
|
end
|
|
|
|
local keyForAccess = buffer
|
|
if buffer:match("^[0-9]+$") and not quotedKey then
|
|
local numericBuffer = tonumber(buffer)
|
|
if numericBuffer then
|
|
keyForAccess = numericBuffer
|
|
end
|
|
end
|
|
|
|
if buffer == "" and not quotedKey then
|
|
err("Empty key name")
|
|
end
|
|
local v = getValue()
|
|
if v then
|
|
-- if the key already exists in the current object, throw an error
|
|
if obj[keyForAccess] ~= nil then
|
|
err('Cannot redefine key "' .. buffer .. '"', true)
|
|
end
|
|
obj[keyForAccess] = v.value
|
|
end
|
|
end
|
|
|
|
-- clear the buffer and reset dotted key state
|
|
buffer = ""
|
|
dottedKeyParts = {}
|
|
inDottedKey = false
|
|
quotedKey = false
|
|
|
|
-- skip whitespace and comments
|
|
skipWhitespace()
|
|
if char() == "#" then
|
|
while (bounds() and not matchnl()) do
|
|
step()
|
|
end
|
|
end
|
|
|
|
-- if there is anything left on this line after parsing a key and its value,
|
|
-- throw an error
|
|
if not dataEnd() and not matchnl() then
|
|
err("Invalid primitive")
|
|
end
|
|
elseif char() == "[" then
|
|
if trim(buffer) ~= '' then
|
|
err("Invalid key")
|
|
end
|
|
|
|
buffer = ""
|
|
step()
|
|
local tableArray = false
|
|
|
|
-- if there are two brackets in a row, it's a table array!
|
|
if char() == "[" then
|
|
tableArray = true
|
|
step()
|
|
end
|
|
|
|
obj = out
|
|
|
|
while (bounds()) do
|
|
if char() == "]" then
|
|
break
|
|
elseif char() == '"' or char() == "'" then
|
|
buffer = parseString().value
|
|
quotedKey = true
|
|
elseif char() == "." then
|
|
step() -- skip period
|
|
if not quotedKey then
|
|
buffer = trim(buffer)
|
|
end
|
|
if not quotedKey then check_key() end
|
|
processKey(false, tableArray, quotedKey)
|
|
buffer = ""
|
|
elseif char() == "[" then
|
|
err('Invalid character in key')
|
|
step()
|
|
else
|
|
buffer = buffer .. char()
|
|
step()
|
|
end
|
|
end
|
|
if tableArray then
|
|
if char(1) ~= "]" then
|
|
err("Mismatching brackets")
|
|
else
|
|
step() -- skip inside bracket
|
|
end
|
|
end
|
|
step() -- skip outside bracket
|
|
if not quotedKey then
|
|
buffer = trim(buffer)
|
|
end
|
|
if not quotedKey then check_key() end
|
|
processKey(true, tableArray, quotedKey)
|
|
buffer = ""
|
|
buffer = ""
|
|
quotedKey = false
|
|
skipWhitespace()
|
|
if bounds() and (not char():match('#') and not matchnl()) then
|
|
err("Something found on the same line of a table definition")
|
|
end
|
|
elseif char() == "." then
|
|
-- Handle dot in dotted key
|
|
if buffer == "" then
|
|
err("Empty key segment before dot")
|
|
end
|
|
|
|
-- Add current buffer content to dotted key parts
|
|
if not quotedKey then
|
|
buffer = trim(buffer)
|
|
end
|
|
if buffer == "" then
|
|
err("Empty key segment")
|
|
end
|
|
table.insert(dottedKeyParts, buffer)
|
|
inDottedKey = true
|
|
buffer = ""
|
|
quotedKey = false
|
|
step()
|
|
elseif (char() == '"' or char() == "'") then
|
|
-- quoted key
|
|
buffer = parseString().value
|
|
quotedKey = true
|
|
else
|
|
if not quotedKey then
|
|
buffer = buffer .. (matchnl() and "" or char())
|
|
end
|
|
step()
|
|
end
|
|
end
|
|
|
|
-- Check for incomplete line at end of file
|
|
if trim(buffer) ~= '' then
|
|
err('Invalid key')
|
|
end
|
|
|
|
return result_or_error()
|
|
end
|
|
|
|
local coparse = coroutine.wrap(parse)
|
|
coparse()
|
|
return coparse
|
|
end
|
|
|
|
TOML.parse = function(data, options)
|
|
local cp = TOML.multistep_parser(options)
|
|
cp(data)
|
|
return cp()
|
|
end
|
|
|
|
-- Parse TOML and return values in toml-test intermediate format
|
|
-- This can be useful for debugging or when you need explicit type information
|
|
TOML.parseToTestFormat = function(data, options)
|
|
options = options or {}
|
|
local originalParser = TOML.multistep_parser(options)
|
|
|
|
-- Create a modified parser that returns toml-test format
|
|
local function convertToTestFormat(result)
|
|
if type(result) ~= "table" then
|
|
return result
|
|
end
|
|
|
|
local converted = {}
|
|
for key, value in pairs(result) do
|
|
if type(value) == "table" and value.type and value.value ~= nil then
|
|
-- This looks like an intermediate format value, convert it
|
|
converted[key] = toTomlTestFormat(value)
|
|
elseif type(value) == "table" then
|
|
-- Recursively convert nested tables
|
|
converted[key] = convertToTestFormat(value)
|
|
else
|
|
-- Native Lua value, wrap it appropriately
|
|
local valueType = type(value)
|
|
if valueType == "string" then
|
|
converted[key] = createTomlTestValue("string", value)
|
|
elseif valueType == "number" then
|
|
if value == math.floor(value) then
|
|
converted[key] = createTomlTestValue("integer", value)
|
|
else
|
|
converted[key] = createTomlTestValue("float", value)
|
|
end
|
|
elseif valueType == "boolean" then
|
|
converted[key] = createTomlTestValue("bool", value)
|
|
else
|
|
converted[key] = value
|
|
end
|
|
end
|
|
end
|
|
return converted
|
|
end
|
|
|
|
originalParser(data)
|
|
local result = originalParser()
|
|
if result then
|
|
return convertToTestFormat(result)
|
|
end
|
|
return result
|
|
end
|
|
|
|
TOML.encode = function(tbl)
|
|
local toml = ""
|
|
|
|
local cache = {}
|
|
|
|
-- Helper function to encode keys properly according to TOML v1.0.0 spec
|
|
local function encodeKey(key)
|
|
local keyStr = tostring(key)
|
|
|
|
-- Empty keys must be quoted
|
|
if keyStr == "" then
|
|
return '""'
|
|
end
|
|
|
|
-- Check if the key needs quoting (contains special characters)
|
|
-- Bare keys may only contain ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-)
|
|
if keyStr:match("^[A-Za-z0-9_%-]+$") then
|
|
return keyStr
|
|
else
|
|
-- Key needs to be quoted, escape quotes and backslashes
|
|
local escapedKey = keyStr:gsub("\\", "\\\\"):gsub('"', '\\"')
|
|
return '"' .. escapedKey .. '"'
|
|
end
|
|
end
|
|
|
|
-- Helper function to encode dotted table names
|
|
local function encodeDottedName(keyList)
|
|
local encodedKeys = {}
|
|
for i, key in ipairs(keyList) do
|
|
table.insert(encodedKeys, encodeKey(key))
|
|
end
|
|
return table.concat(encodedKeys, ".")
|
|
end
|
|
|
|
-- Helper function to get sorted keys for consistent output order
|
|
local function getSortedKeys(t)
|
|
local keys = {}
|
|
for k in pairs(t) do
|
|
table.insert(keys, k)
|
|
end
|
|
-- Sort keys, handling mixed types gracefully
|
|
table.sort(keys, function(a, b)
|
|
local ta, tb = type(a), type(b)
|
|
if ta == tb then
|
|
return tostring(a) > tostring(b) -- Reverse alphabetical order
|
|
else
|
|
return ta < tb -- type names sorted alphabetically
|
|
end
|
|
end)
|
|
return keys
|
|
end
|
|
|
|
local function parse(tbl)
|
|
local keys = getSortedKeys(tbl)
|
|
|
|
-- First pass: handle all non-table values
|
|
for _, k in ipairs(keys) do
|
|
local v = tbl[k]
|
|
if type(v) == "boolean" then
|
|
toml = toml .. encodeKey(k) .. " = " .. tostring(v) .. "\n"
|
|
elseif type(v) == "number" then
|
|
-- Handle special float values for v1.0.0 compatibility
|
|
if v == math.huge then
|
|
toml = toml .. encodeKey(k) .. " = inf\n"
|
|
elseif v == -math.huge then
|
|
toml = toml .. encodeKey(k) .. " = -inf\n"
|
|
elseif v ~= v then -- NaN check (NaN != NaN)
|
|
toml = toml .. encodeKey(k) .. " = nan\n"
|
|
else
|
|
toml = toml .. encodeKey(k) .. " = " .. tostring(v) .. "\n"
|
|
end
|
|
elseif type(v) == "string" then
|
|
local quote = '"'
|
|
v = v:gsub("\\", "\\\\")
|
|
|
|
-- if the string has any line breaks, make it multiline
|
|
if v:match("^\n(.*)$") then
|
|
quote = quote:rep(3)
|
|
v = "\\n" .. v
|
|
elseif v:match("\n") then
|
|
quote = quote:rep(3)
|
|
end
|
|
|
|
v = v:gsub("\b", "\\b")
|
|
v = v:gsub("\t", "\\t")
|
|
v = v:gsub("\f", "\\f")
|
|
v = v:gsub("\r", "\\r")
|
|
v = v:gsub('"', '\\"')
|
|
toml = toml .. encodeKey(k) .. " = " .. quote .. v .. quote .. "\n"
|
|
elseif type(v) == "table" and getmetatable(v) == date_metatable then
|
|
toml = toml .. encodeKey(k) .. " = " .. tostring(v) .. "\n"
|
|
end
|
|
end
|
|
|
|
-- Second pass: handle simple array values (arrays of non-tables)
|
|
for _, k in ipairs(keys) do
|
|
local v = tbl[k]
|
|
if type(v) == "table" and getmetatable(v) ~= date_metatable then
|
|
-- Check if this is an array (all numeric keys)
|
|
local isArray = true
|
|
local isArrayOfHashTables = true
|
|
for kk, vv in pairs(v) do
|
|
if type(kk) ~= "number" then
|
|
isArray = false
|
|
break
|
|
end
|
|
if type(vv) ~= "table" then
|
|
isArrayOfHashTables = false
|
|
else
|
|
-- Check if the inner table is a hash table (has non-numeric keys)
|
|
local isHashTable = false
|
|
local hasKeys = false
|
|
for kkk, vvv in pairs(vv) do
|
|
hasKeys = true
|
|
if type(kkk) ~= "number" then
|
|
isHashTable = true
|
|
break
|
|
end
|
|
end
|
|
-- Empty tables are considered hash tables for array of tables syntax
|
|
if hasKeys and not isHashTable then
|
|
isArrayOfHashTables = false
|
|
end
|
|
end
|
|
end
|
|
|
|
if isArray and not isArrayOfHashTables then
|
|
-- Check if this is an array of arrays (all elements are arrays)
|
|
local isArrayOfArrays = true
|
|
for kk, vv in pairs(v) do
|
|
if type(vv) ~= "table" then
|
|
isArrayOfArrays = false
|
|
break
|
|
end
|
|
-- Check if the inner table is also an array
|
|
for kkk, vvv in pairs(vv) do
|
|
if type(kkk) ~= "number" then
|
|
isArrayOfArrays = false
|
|
break
|
|
end
|
|
end
|
|
if not isArrayOfArrays then break end
|
|
end
|
|
|
|
if isArrayOfArrays then
|
|
-- This is an array of arrays, encode as nested arrays
|
|
toml = toml .. encodeKey(k) .. " = ["
|
|
local first_outer = true
|
|
for kk, vv in pairs(v) do
|
|
if not first_outer then
|
|
toml = toml .. ", "
|
|
end
|
|
toml = toml .. "["
|
|
local first_inner = true
|
|
for kkk, vvv in pairs(vv) do
|
|
if not first_inner then
|
|
toml = toml .. ", "
|
|
end
|
|
if type(vvv) == "number" then
|
|
-- Check if any number in any array is a float
|
|
local hasFloat = false
|
|
for _, arr in pairs(v) do
|
|
for _, val in pairs(arr) do
|
|
if type(val) == "number" and val ~= math.floor(val) then
|
|
hasFloat = true
|
|
break
|
|
end
|
|
end
|
|
if hasFloat then break end
|
|
end
|
|
if hasFloat then
|
|
toml = toml .. string.format("%.1f", vvv)
|
|
else
|
|
toml = toml .. tostring(vvv)
|
|
end
|
|
else
|
|
toml = toml .. tostring(vvv)
|
|
end
|
|
first_inner = false
|
|
end
|
|
toml = toml .. "]"
|
|
first_outer = false
|
|
end
|
|
toml = toml .. "]\n"
|
|
else
|
|
-- This is a simple array, use multi-line format
|
|
toml = toml .. encodeKey(k) .. " = [\n"
|
|
for kk, vv in pairs(v) do
|
|
if type(vv) == "string" then
|
|
local escaped_string = vv
|
|
escaped_string = escaped_string:gsub("\\", "\\\\")
|
|
escaped_string = escaped_string:gsub("\b", "\\b")
|
|
escaped_string = escaped_string:gsub("\t", "\\t")
|
|
escaped_string = escaped_string:gsub("\f", "\\f")
|
|
escaped_string = escaped_string:gsub("\r", "\\r")
|
|
escaped_string = escaped_string:gsub("\n", "\\n")
|
|
escaped_string = escaped_string:gsub('"', '\\"')
|
|
toml = toml .. '"' .. escaped_string .. '",\n'
|
|
else
|
|
toml = toml .. tostring(vv) .. ",\n"
|
|
end
|
|
end
|
|
toml = toml .. "]\n"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Third pass: handle hash table values and arrays of tables
|
|
for _, k in ipairs(keys) do
|
|
local v = tbl[k]
|
|
if type(v) == "table" and getmetatable(v) ~= date_metatable then
|
|
-- Check if this is an array (all numeric keys)
|
|
local isArray = true
|
|
local isArrayOfHashTables = true
|
|
for kk, vv in pairs(v) do
|
|
if type(kk) ~= "number" then
|
|
isArray = false
|
|
break
|
|
end
|
|
if type(vv) ~= "table" then
|
|
isArrayOfHashTables = false
|
|
else
|
|
-- Check if the inner table is a hash table (has non-numeric keys)
|
|
local isHashTable = false
|
|
local hasKeys = false
|
|
for kkk, vvv in pairs(vv) do
|
|
hasKeys = true
|
|
if type(kkk) ~= "number" then
|
|
isHashTable = true
|
|
break
|
|
end
|
|
end
|
|
-- Empty tables are considered hash tables for array of tables syntax
|
|
if hasKeys and not isHashTable then
|
|
isArrayOfHashTables = false
|
|
end
|
|
end
|
|
end
|
|
|
|
if isArray and isArrayOfHashTables then
|
|
-- This is an array of hash tables, use [[table]] syntax
|
|
for kk, vv in pairs(v) do
|
|
toml = toml .. "[[" .. encodeKey(k) .. "]]\n"
|
|
if type(vv) == "table" then
|
|
parse(vv)
|
|
end
|
|
end
|
|
elseif not isArray then
|
|
local array, arrayTable = true, true
|
|
local first = {}
|
|
local tableCopy = {}
|
|
for kk, vv in pairs(v) do
|
|
if type(kk) ~= "number" then array = false end
|
|
if type(vv) ~= "table" then
|
|
first[kk] = vv
|
|
arrayTable = false
|
|
else
|
|
tableCopy[kk] = vv
|
|
end
|
|
end
|
|
|
|
if array then
|
|
if arrayTable then
|
|
-- Check if inner tables are arrays (all numeric keys) or hash tables
|
|
local innerTablesAreArrays = true
|
|
for kk, vv in pairs(tableCopy) do
|
|
for k3, v3 in pairs(vv) do
|
|
if type(k3) ~= "number" then
|
|
innerTablesAreArrays = false
|
|
break
|
|
end
|
|
end
|
|
if not innerTablesAreArrays then
|
|
break
|
|
end
|
|
end
|
|
|
|
if innerTablesAreArrays then
|
|
-- This is an array of arrays, encode as nested array
|
|
toml = toml .. encodeKey(k) .. " = ["
|
|
|
|
-- Check if any element in any array is a float to determine formatting
|
|
local hasFloat = false
|
|
for kk, vv in pairs(tableCopy) do
|
|
for k3, v3 in pairs(vv) do
|
|
if type(v3) == "number" and v3 ~= math.floor(v3) then
|
|
hasFloat = true
|
|
break
|
|
end
|
|
end
|
|
if hasFloat then break end
|
|
end
|
|
|
|
local first_element = true
|
|
for kk, vv in pairs(tableCopy) do
|
|
if not first_element then
|
|
toml = toml .. ", "
|
|
end
|
|
toml = toml .. "["
|
|
local first_inner = true
|
|
|
|
for k3, v3 in pairs(vv) do
|
|
if not first_inner then
|
|
toml = toml .. ", "
|
|
end
|
|
if type(v3) == "string" then
|
|
toml = toml .. '"' .. v3 .. '"'
|
|
elseif type(v3) == "number" then
|
|
if hasFloat then
|
|
-- Format all numbers as floats for consistency
|
|
toml = toml .. string.format("%.1f", v3)
|
|
else
|
|
toml = toml .. tostring(v3)
|
|
end
|
|
else
|
|
toml = toml .. tostring(v3)
|
|
end
|
|
first_inner = false
|
|
end
|
|
toml = toml .. "]"
|
|
first_element = false
|
|
end
|
|
toml = toml .. "]\n"
|
|
else
|
|
-- double bracket syntax go!
|
|
table.insert(cache, k)
|
|
for kk, vv in pairs(tableCopy) do
|
|
toml = toml .. "[[" .. encodeDottedName(cache) .. "]]\n"
|
|
local tableCopyInner = {}
|
|
local firstInner = {}
|
|
local sortedKeys = getSortedKeys(vv)
|
|
for _, k3 in ipairs(sortedKeys) do
|
|
local v3 = vv[k3]
|
|
if type(v3) ~= "table" then
|
|
firstInner[k3] = v3
|
|
else
|
|
tableCopyInner[k3] = v3
|
|
end
|
|
end
|
|
parse(firstInner)
|
|
parse(tableCopyInner)
|
|
end
|
|
table.remove(cache)
|
|
end
|
|
else
|
|
-- plain ol boring array
|
|
toml = toml .. encodeKey(k) .. " = [\n"
|
|
local quote = '"'
|
|
for kk, vv in pairs(first) do
|
|
if type(vv) == "string" then
|
|
local escaped_string = vv
|
|
escaped_string = escaped_string:gsub("\\", "\\\\")
|
|
escaped_string = escaped_string:gsub("\b", "\\b")
|
|
escaped_string = escaped_string:gsub("\t", "\\t")
|
|
escaped_string = escaped_string:gsub("\f", "\\f")
|
|
escaped_string = escaped_string:gsub("\r", "\\r")
|
|
escaped_string = escaped_string:gsub("\n", "\\n")
|
|
escaped_string = escaped_string:gsub('"', '\\"')
|
|
toml = toml .. quote .. escaped_string .. quote .. ",\n"
|
|
else
|
|
toml = toml .. tostring(vv) .. ",\n"
|
|
end
|
|
end
|
|
toml = toml .. "]\n"
|
|
end
|
|
else
|
|
-- just a key/value table, folks
|
|
table.insert(cache, k)
|
|
toml = toml .. "[" .. encodeDottedName(cache) .. "]\n"
|
|
parse(first)
|
|
parse(tableCopy)
|
|
table.remove(cache)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
parse(tbl)
|
|
|
|
return toml:sub(1, -2)
|
|
end
|
|
|
|
return TOML
|