-- 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