-- lmcp/json.lua — Minimal JSON encoder/decoder, zero dependencies -- SPDX-License-Identifier: MIT local json = {} -- Encode -- local encode_value local escape_chars = { ['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t', } local function encode_string(s) return '"' .. s:gsub('[%z\1-\31"\\]', function(c) return escape_chars[c] or string.format('\\u%04x', c:byte()) end) .. '"' end local function encode_array(t) local parts = {} for i = 1, #t do parts[i] = encode_value(t[i]) end return '[' .. table.concat(parts, ',') .. ']' end local function encode_object(t) local parts = {} for k, v in pairs(t) do if type(k) == 'string' then parts[#parts + 1] = encode_string(k) .. ':' .. encode_value(v) end end return '{' .. table.concat(parts, ',') .. '}' end local function is_array(t) if type(t) ~= 'table' then return false end local n = #t if n == 0 then -- empty table: check if it has any keys return next(t) == nil end for k in pairs(t) do if type(k) ~= 'number' or k < 1 or k > n or k ~= math.floor(k) then return false end end return true end encode_value = function(v) local t = type(v) if v == nil or v == json.null then return 'null' elseif t == 'boolean' then return v and 'true' or 'false' elseif t == 'number' then if v ~= v then return 'null' end -- NaN if v == math.huge or v == -math.huge then return 'null' end if v == math.floor(v) and v >= -2^53 and v <= 2^53 then return string.format('%.0f', v) end return tostring(v) elseif t == 'string' then return encode_string(v) elseif t == 'table' then if is_array(v) then return encode_array(v) else return encode_object(v) end else return 'null' end end function json.encode(v) return encode_value(v) end -- Decode -- local decode_value local MAX_DEPTH = 64 local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true } local function skip_ws(s, pos) while pos <= #s and ws_chars[s:sub(pos, pos)] do pos = pos + 1 end return pos end local function decode_string(s, pos) -- pos is at opening quote pos = pos + 1 local parts = {} while pos <= #s do local c = s:sub(pos, pos) if c == '"' then return table.concat(parts), pos + 1 elseif c == '\\' then pos = pos + 1 c = s:sub(pos, pos) if c == 'u' then local hex = s:sub(pos + 1, pos + 4) parts[#parts + 1] = utf8.char(tonumber(hex, 16)) pos = pos + 5 else local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' } parts[#parts + 1] = esc[c] or c pos = pos + 1 end else local next_special = s:find('["\\]', pos) if next_special then parts[#parts + 1] = s:sub(pos, next_special - 1) pos = next_special else parts[#parts + 1] = s:sub(pos) break end end end error('unterminated string') end local function decode_number(s, pos) local start = pos if s:sub(pos, pos) == '-' then pos = pos + 1 end while pos <= #s and s:sub(pos, pos):match('[%d%.eE%+%-]') do pos = pos + 1 end local n = tonumber(s:sub(start, pos - 1)) if not n then error('invalid number at ' .. start) end return n, pos end local function decode_array(s, pos, depth) pos = pos + 1 -- skip [ local arr = {} pos = skip_ws(s, pos) if s:sub(pos, pos) == ']' then return arr, pos + 1 end while true do local val val, pos = decode_value(s, pos, (depth or 0) + 1) arr[#arr + 1] = val pos = skip_ws(s, pos) local c = s:sub(pos, pos) if c == ']' then return arr, pos + 1 end if c ~= ',' then error('expected , or ] at ' .. pos) end pos = skip_ws(s, pos + 1) end end local function decode_object(s, pos, depth) pos = pos + 1 -- skip { local obj = {} pos = skip_ws(s, pos) if s:sub(pos, pos) == '}' then return obj, pos + 1 end while true do pos = skip_ws(s, pos) if s:sub(pos, pos) ~= '"' then error('expected string key at ' .. pos) end local key key, pos = decode_string(s, pos) pos = skip_ws(s, pos) if s:sub(pos, pos) ~= ':' then error('expected : at ' .. pos) end pos = skip_ws(s, pos + 1) local val val, pos = decode_value(s, pos, (depth or 0) + 1) obj[key] = val pos = skip_ws(s, pos) local c = s:sub(pos, pos) if c == '}' then return obj, pos + 1 end if c ~= ',' then error('expected , or } at ' .. pos) end pos = pos + 1 end end decode_value = function(s, pos, depth) depth = depth or 0 if depth > MAX_DEPTH then error("JSON nesting too deep") end pos = skip_ws(s, pos) local c = s:sub(pos, pos) if c == '"' then return decode_string(s, pos) elseif c == '{' then return decode_object(s, pos, depth) elseif c == '[' then return decode_array(s, pos, depth) elseif c == 't' then if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end elseif c == 'f' then if s:sub(pos, pos + 4) == 'false' then return false, pos + 5 end elseif c == 'n' then if s:sub(pos, pos + 3) == 'null' then return json.null, pos + 4 end elseif c == '-' or c:match('%d') then return decode_number(s, pos) end error('unexpected character at ' .. pos .. ': ' .. c) end function json.decode(s) local val, pos = decode_value(s, 1) return val end -- Sentinel for JSON null json.null = setmetatable({}, { __tostring = function() return 'null' end }) return json