This module offers some utility methods for converting Lua values into JSON values (in UTF-8-encoded Lua strings).
Unfortunately, Lua's data model differs somewhat from JSON's, so it's not possible to write a general function that takes any Lua value and returns a JSON value, always "doing the right thing". Rather, some values cannot be converted at all, and other values have multiple possible non-equivalent representations.
The differences are:
(Note: the above is an attempt at an exhaustive list of differences, but it's quite possible that I missed some.)
local export = {}
local m_table = require("Module:table")
local codepoint = require("Module:string utilities").codepoint
local concat = table.concat
local converter -- forward declaration
local format = string.format
local getmetatable = getmetatable
local index_ipairs = m_table.indexIpairs
local insert = table.insert
local is_array = m_table.isArray
local is_finite_real_number = require("Module:math").is_finite_real_number
local is_utf8 = mw.ustring.isutf8
local pairs = pairs
local pcall = pcall
local sorted_pairs = m_table.sortedPairs
local type = type
local ugsub = mw.ustring.gsub
-- Given a finite real number x, returns a string containing its JSON
-- representation, with enough precision that it *should* round-trip correctly
-- (depending on the well-behavedness of the system on the other end).
local function json_fromNumber(x, level)
if is_finite_real_number(x) then
return format("%.17g", x)
end
error(format("Cannot encode non-finite real number %g", x), level)
end
local escape_char_map = {
= "\\b",
= "\\t",
= "\\n",
= "\\f",
= "\\r",
= "\\\"",
= "\\\\",
}
local function escape_codepoint_utf16(c)
if c >= 0x10000 then
c = c - 0x10000
return format("\\u%04x\\u%04x", 0xD800 + (c / 1024), 0xDC00 + (c % 1024))
end
return format("\\u%04x", c)
end
local function escape_char(c)
return escape_char_map or escape_codepoint_utf16(codepoint(c))
end
-- Given a string, escapes any illegal characters and wraps it in double-quotes.
-- Raises an error if the string is not valid UTF-8.
local function json_fromString(s, ascii, level)
if not is_utf8(s) then
error(format("Cannot encode non-UTF-8 string '%s'", s), level)
elseif ascii then
-- U+0080 = \194\128 in UTF-8, U+10FFFF = \244\143\191\191 in UTF-8
s = ugsub(s, '', escape_char)
else
-- U+2029 (LINE SEPARATOR, \226\128\168 in UTF-8)
-- and U+2028 (PARAGRAPH SEPARATOR, \226\128\169 in UTF-8) are allowed
-- in JSON, but must be escaped for compatibility with JavaScript.
s = ugsub(s, '', escape_char)
end
return '"' .. s .. '"'
end
local function json_fromTable(t, opts, current, level)
local ret, open, close = {}
if is_array(t) then
for key, value in index_ipairs(t) do
ret = converter(value, opts, current, level + 1) or "null"
end
open, close = ""
else
-- `seen_keys` memoizes keys already seen, to prevent collisions (e.g. 1
-- and "1").
local seen_keys, colon, ascii = {}, opts.compress and ":" or " : ", opts.ascii
for key, value in (opts.sort_keys and sorted_pairs or pairs)(t) do
local key_type = type(key)
if key_type == "number" then
key = json_fromNumber(key, level + 1)
elseif key_type ~= "string" then
error(format("Cannot use type '%s' as a table key", key_type), level)
end
key = json_fromString(key, ascii, level + 1)
if seen_keys then
error(format("Collision for JSON key %s", key), level)
end
seen_keys = true
insert(ret, key .. colon .. (converter(value, opts, current, level + 1) or "null"))
end
open, close = "{", "}"
end
ret = open .. (
opts.compress and concat(ret, ",") .. close or
" " .. concat(ret, ", ") .. (
#ret == 0 and "" or " "
) .. close
)
current = nil
return ret
end
function converter(this, opts, current, level) -- local declared above
local val_type = type(this)
if val_type == "nil" then
return "null"
elseif val_type == "boolean" then
return this and "true" or "false"
elseif val_type == "number" then
return json_fromNumber(this, level + 1)
elseif val_type == "string" then
return json_fromString(this, opts.ascii, level + 1)
elseif val_type ~= "table" then
error(format("Cannot encode type '%s'", val_type), level)
elseif current then
error("Cannot use recursive tables", level)
end
-- Memoize the table to enable recursion checking.
current = true
if opts.ignore_toJSON then
return json_fromTable(this, opts, current, level + 1)
end
-- Check if a toJSON method can be used. Use the lua_table flag to get a Lua
-- table, as any options need to be applied to the output.
local to_json = this.toJSON
if to_json == nil then
return json_fromTable(this, opts, current, level + 1)
end
local to_json_type = type(to_json)
-- If it's a function, call it.
if to_json_type == "function" then
local ret = converter(to_json(this, {lua_table = true}), opts, current, level + 1)
current = nil
return ret
-- If it's a table and there's a metatable, try to call it. If getmetatable
-- returns nil, there's definitely no metatable (so it can't be callable),
-- but otherwise the metatable could be protected with __metatable, so the
-- only reliable approach is to call it with pcall.
elseif to_json_type == "table" and getmetatable(to_json) ~= nil then
local success, new = pcall(to_json, this, {lua_table = true})
if success then
local ret = converter(new, opts, current, level + 1)
current = nil
return ret
-- The error message will only take this exact form if it was thrown due
-- to `this` not being callable, as it will contain a traceback if
-- thrown in some other function, so raise the error if it's not a
-- match, since it's an error elsewhere.
elseif new ~= "attempt to call a table value" then
error(new)
end
-- Not a callable table.
end
-- Treat as a conventional value.
return json_fromTable(this, opts, current, level + 1)
end
-- This function makes an effort to convert an arbitrary Lua value to a string
-- containing a JSON representation of it. It's not intended to be very robust,
-- but may be useful for prototyping.
function export.toJSON(this, opts)
return converter(this, opts == nil and {} or opts, {}, 3)
end
return export