Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
Jump to content

Module:JSON

From Wiktionary, the free dictionary

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:

  • Lua has three types with no JSON analogues, namely function, userdata, and thread, so this module has no support for values of those types.
  • Lua's concept of "metatables" has no analogue in JSON, so this module ignores metatables completely.
  • Lua's number type, as implemented in Scribunto, consists of double-precision floating-point values, whereas JSON's number type consists of decimal representations. (And the end-recipient of the JSON data will likely convert the values back into some sort of floating-point notation.) This means that, aside from integers, you can't generally expect values to be converted exactly. (And even with integers, you can only expect perfect conversion in the range ±109 or so.) What's more, it means that Lua has a few numeric values with no JSON analogues at all, namely positive infinity, negative infinity, and "not a number" values; so, this module does not support those values.
  • Lua's string type represents strings of eight-bit bytes, whereas JSON's *string* type represents strings of Unicode characters. This module requires the Lua strings to be valid UTF-8 sequences.
  • Whereas Lua has only a single table type mapping from arbitrary non-nil values to arbitrary non-nil values, JSON has separate array and object types, where an array maps from a set of integers {0,1,…,n} to arbitrary values, and an object maps from arbitrary strings to arbitrary values. As a result, this module [TBD]

(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"] = "\\b",
	["\t"] = "\\t",
	["\n"] = "\\n",
	["\f"] = "\\f",
	["\r"] = "\\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[c] 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, '[%z\1-\31"\\\194\128-\244\143\191\191]', 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, '[%z\1-\31"\\\226\128\168\226\128\169]', 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[key] = 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[key] then
				error(format("Collision for JSON key %s", key), level)
			end
			seen_keys[key] = 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[t] = 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[this] then
		error("Cannot use recursive tables", level)
	end
	-- Memoize the table to enable recursion checking.
	current[this] = 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[this] = 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[this] = 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