模組:Special wikitext/JSON

local p={}
local lib_arg={}
-- ======== 前處理器 ========
--由於技術限制,lua無法從mw.text.jsonDecode的回傳結果判斷物件、陣列與null因此定義一個$轉義符號來處理
function _preEsc(str)
	return mw.ustring.gsub(str,'%$',"$$")
end
function _postEsc(str)
	if not mw.ustring.find(tostring(str),'%$') then return str end
	return mw.ustring.gsub(str,'%$%$',"$")
end
--處理HTML轉義符號
function _wikiescape(str)
	local escape_list = {
		{'#','#'}, {'%{','{'}, {'%|','|'}, {'%}','}'}, {'=','='}, {':',':'}, {'%[','['}, {'%]',']'}
	}
	str = mw.text.encode( str )
	for i=1,#escape_list do str = mw.ustring.gsub(str,escape_list[i][1],escape_list[i][2]) end
	return str
end
--標記null物件
function _handleNull(input_string)
	local JSON_syms = ",:%[%]%{%}"
	local head_patterns,tail_patterns = {'^(%s*)','(['..JSON_syms..']%s*)'},{'(%s*)$','(%s*['..JSON_syms..'])'}
	local result = input_string
	for i=1,#head_patterns do
		for j=1,#tail_patterns do
			result = mw.ustring.gsub(result,head_patterns[i].."null"..tail_patterns[j],function(head,tail)
				local p_head,p_tail = mw.text.trim(head),mw.text.trim(tail)
				local body = (p_tail==':'and''or'$').."null"
				return head..'"'..body..'"'..tail
			end)
		end
	end
	return result
end
--標記陣列物件,令其與object物件區別
function _handleArray(input_string)
	local result = input_string
	result = mw.ustring.gsub(result,'%[','["$array",')
	result = mw.ustring.gsub(result,",%s*([%]%}])","%1")
	return result
end
--將字串物件轉義,避免_handleArray過度標記
function _handleEscape(json_str)
	local result, json_stack = {}, {}
	local result_str = json_str
	xpcall(function() 
		json_stack[#json_stack + 1]=mw.text.jsonDecode(json_str, mw.text.JSON_PRESERVE_KEYS + mw.text.JSON_TRY_FIXING)
		while(#json_stack > 0)do
			local top = json_stack[#json_stack]
			json_stack[#json_stack] = nil--pop
			if type(top) == type({}) then
				for k,v in pairs(top) do
					if type(k) == type('') then 
						local esc_res = _wikiescape(k)
						if esc_res ~= k then
							result[#result + 1] = {k, _wikiescape(k)}
						end
					end
					json_stack[#json_stack + 1] = v
				end
			elseif type(top) == type('') then
				local esc_res = _wikiescape(top)
				if esc_res ~= top then
					result[#result + 1] = {top, _wikiescape(top)}
				end
			end
		end
		for i=1,#result do
			local pattern = '([\'"])'..mw.ustring.gsub(result[i][1],'([%%%(%)%[%]%{%}%|%$%^%+%-%*%.%?])','%%%1')..'([\'"])'
			local replace_to = '%1'..mw.ustring.gsub(result[i][2],'%%','%%%%')..'%2'
			result_str = mw.ustring.gsub(result_str, pattern, replace_to)
		end
	end,function() 
		result = true
	end)
	if type(result) == type(true) then return result_str end
	return result_str
end

-- ======== 渲染器 ========
function _renderHide(value)
	return '<span class="hidden-copyable">'..value..'</span>'
end
function _renderKeyString(value)
	return _renderHide('"').._wikiescape(value).._renderHide('"')
end
function _renderKey(value)
	return _renderKeyString(value).._renderHide(':')
end
function _rendeString(value)
	return '"'.._wikiescape(value)..'"'
end
function _rendeRawString(value)
	return _renderHide('"')..value.._renderHide('"')
end
function _rendeValue(value)
	if _isNumber(value) then return tostring(value)
	elseif type(value)==type(true) then return value and 'true' or 'false' 
	elseif mw.ustring.find(value,"^%$null") then return 'null' 
	elseif mw.ustring.find(value,"^%$array") then return _rendeRawString('<div class="mw-json-empty"><span class="mw-json-empty-array"></span></div>') 
	elseif mw.ustring.find(value,"^%$reference") then 
		local s_strat, s_end = mw.ustring.find(value,":") 
		local path = s_strat and mw.ustring.sub(value,s_strat+1,-1)
		return _rendeRawString('<div class="mw-json-reference"><span class="mw-json-reference-tag">'..
			(path and('[[#mw-json-ref-'..path..'|'..path..']]')or'')..'</span></div>')
	elseif mw.ustring.find(value,"^%$function") then 
		local s_strat, s_end = mw.ustring.find(value,":") 
		return _rendeRawString('<div class="mw-json-function"><span class="mw-json-function-tag">'..
		(s_strat and mw.ustring.sub(value,s_strat+1,-1) or '')..'</span></div>')
	elseif mw.ustring.find(value,"^%$error") then 
		local s_strat, s_end = mw.ustring.find(value,":") 
		return _rendeRawString('<div class="mw-json-error"><strong class="error mw-json-error-tag">'..
		(s_strat and mw.ustring.sub(value,s_strat+1,-1) or '')..'</strong></div>')
	end
	return _rendeString(_postEsc(value))
end
function _renderTerminal(key, value, is_array, is_tail, sing_value)
	local body = '<tr>'
	..((key and not is_array) and ('<th><span>'.._renderKey(key)..'</span></th>') or '')
	..'<td'..(sing_value and''or' class="mw-json-value"')..'>'.._rendeValue(value)..((not is_tail) and _renderHide(',') or '')..'</td></tr>'
	return body
end
function _renderItem(key, item, is_array, is_tail)
	local body = '<tr>'
	..((key and not is_array) and ('<th><span>'.._renderKey(key)..'</span></th>') or '')
	..'<td>'..item..((not is_tail) and _renderHide(',') or '')..'</td></tr>'
	return body
end

-- ======== 型態判斷工具 ========
function _isNumber(obj)
	if (tostring(obj):lower():match("nan$"))=='nan' then return false end
	return tonumber(obj) ~= nil and tostring(tonumber(obj)) == tostring(obj)
end
function _isArray(obj)
	local min_val, max_val
	if obj == nil or type(obj) ~= type({}) then return false end
	if (obj[0] or '') == '$array' then return true end
	for k,v in pairs(obj) do
		if _isNumber(k) then
			local key = tonumber(k)
			if min_val==nil then min_val = key end
			if max_val==nil then max_val = key end
			if min_val > key  then min_val = key end
			if max_val < key  then max_val = key end
		else return false end
	end
	return (min_val or -1) > 0 and #obj==(max_val or -1)
end
function _jsonDecode(str)
	local preJSON = _preEsc(str)
	preJSON = _handleEscape(preJSON)
	preJSON = _handleNull(preJSON)
	preJSON = _handleArray(preJSON)
	preJSON = mw.text.decode(preJSON)
	return mw.text.jsonDecode(preJSON, mw.text.JSON_PRESERVE_KEYS + mw.text.JSON_TRY_FIXING)
end
function p.JSONTable(frame)
	local json_data = mw.text.killMarkers( mw.text.unstripNoWiki( frame.args[1] or frame.args['1'] or '' ) )
	local body = ''
	xpcall(function()
		body=p._json_reader(_jsonDecode(json_data))
	end,function(ex)
		local check_str = mw.ustring.gsub(json_data,"[\t\r\n\f%s,]+","")
		local regex_check, show_code = mw.ustring.gsub(check_str,"^([%{%[].).*(.[%}%]])$","%1%2")
		show_code = show_code > 0
		if mw.ustring.sub(regex_check,1,2)=="{{" then show_code = false end--物件中沒有key直接是物件絕對非法
		if show_code then
			mw.addWarning("{{error|JSON解析器回傳錯誤:"..ex.."}}")
			body = mw.getCurrentFrame():extensionTag{ name = 'syntaxhighlight', content = json_data, args = { lang = 'json', line = 'line' } }
		else --明顯非法的json當作一般wikitext
			mw.addWarning("{{error|錯誤,輸入的資料並非JSON}},將以普通wikitext呈現。")
			body = mw.getCurrentFrame():preprocess(mw.text.decode(json_data))
		end
	end)
	return body
end
--{{#invoke:Special wikitext/JSON|callAsJSON}}
function p.callAsJSON(...)
	local f_args = { ... }
    local in_args, working_frame
    local frame = f_args[1]
    if frame == mw.getCurrentFrame() then
        -- We're being called via #invoke. The args are passed through to the module
        -- from the template page, so use the args that were passed into the template.
        if lib_arg.getArgs == nil then lib_arg = require('Module:Arguments') end
        in_args = lib_arg.getArgs(frame, {parentFirst=true})
        working_frame = frame
    else
        -- We're being called from another module or from the debug console, so assume
        -- the args are passed in directly.
        in_args = frame
        working_frame = mw.getCurrentFrame()
        if type(in_args) ~= type({}) then 
        	in_args = {} 
        	for i=1,#f_args do
        		in_args[i] = f_args[i]
        	end
        end
    end
    local args = {}
    for k,v in pairs(in_args) do args[k]=v end
	local json_data, flag = mw.text.killMarkers( mw.text.unstripNoWiki( tostring(args[1] or args['1'] or '') ) )
	local path = mw.text.split(json_data,'%.')
	if #path < 1 then return '' end
	local obj = _G[path[1]]
	if obj == nil then
		local file = mw.title.new(path[1], 'module')
		if file.exists then
			local contentModel = file.contentModel
			if contentModel == 'scribunto' or contentModel == 'lua' then
				flag,obj = pcall(require, file.fullText)
			elseif contentModel == 'json' then
				flag,obj = pcall(mw.text.jsonDecode, file:getContent())
			end
		end
	end
	if obj == nil then return '' end
	local get_arg = function(arg_list)
		local result_args = {}
		for j=2,#arg_list do
			local check_key = mw.text.trim(arg_list[j])
			if mw.ustring.sub(check_key,1,1)=='$' then
				local str_res,is_str = mw.ustring.gsub(check_key,"^%$string",'')
				local num_res,is_num = mw.ustring.gsub(check_key,"^%$number",'')
				if is_str > 0 then
					result_args[#result_args+1] = str_res
				elseif is_num > 0 then
					num_res = mw.ustring.lower(mw.text.trim(num_res))
					if num_res == 'true' then result_args[#result_args+1] = true
					elseif num_res == 'false' then result_args[#result_args+1] = false
					elseif tonumber(num_res)~=nil then result_args[#result_args+1] = tonumber(num_res)
					else result_args[#result_args+1] = num_res
					end
				end
			else result_args[#result_args+1] = arg_list[j]
			end
		end
		return result_args
	end
	local target_obj = obj
	local old_obj = _G;
	local cur_path = path[1]
	local tmp_args = {}
	local no_call = false
	for i=2,#path do
		if i~=#path or path[i]~=':' then 
			if target_obj==nil then return p._json_reader('$error: "'..cur_path..'" is nil.') end
			local old_path = cur_path
			cur_path = old_path ..'.'.. path[i]
			if type(target_obj)==type(function()end) then 
				local success_obj
				flag,success_obj = pcall(target_obj, unpack(tmp_args))
				if not success_obj or not flag then 
					--try colon call
					flag,target_obj = pcall(target_obj, old_obj, unpack(tmp_args))
					if not target_obj or not flag then return p._json_reader('$error: fail to get "'..cur_path..'" .') end
				else target_obj = success_obj
				end
			elseif type(target_obj)~=type({}) then 
				return p._json_reader('$error: can not get "'..cur_path..'" from '..tostring(type(target_obj))..' value ('..old_path..').')
			end
			local cur_key = mw.text.split(path[i],':')
			tmp_args = get_arg(cur_key)
			local cur_obj_key = mw.text.trim(cur_key[1])
			if type(target_obj[cur_obj_key])~=type(function()end) then
				cur_obj_key = path[i]
			end
			old_obj = target_obj
			target_obj = target_obj[cur_obj_key]
		else
			no_call = true
		end
	end
	local pack_data = {true,target_obj}
	if type(target_obj)==type(function()end) and not no_call then
		tmp_args = get_arg(args)
		pack_data = {pcall(target_obj, unpack(tmp_args))}
		local err_flag = pack_data[1]
		if not err_flag then 
			local success_obj
			--try colon call
			success_obj = {pcall(target_obj, old_obj, unpack(tmp_args))}
			if not success_obj[1] then 
				return p._json_reader('$error: fail to call "'..cur_path..'" '..(pack_data[2] and (', Message: '..pack_data[2]) or '')..'.') 
			else pack_data = success_obj
			end
		end
	end
	local body_list = {}
	for i=2,#pack_data do
		local jsonlize
		local reference_list={}
		local target_unpack = pack_data[i]
		if target_unpack == nil then 
			jsonlize = '$null'
		elseif type(target_unpack)==type(function()end) then
			jsonlize = '$function:'..cur_path
		elseif type(target_unpack)~=type({}) then
			jsonlize = _preEsc(tostring(target_unpack))
		else
			jsonlize,reference_list = p._table_copy(target_unpack)
		end
		body_list[#body_list+1]=p._json_reader(jsonlize, reference_list, (#pack_data>2)and'mw-json-multivalue'or nil)
	end
	if #body_list == 0 then return p._json_reader('$null')
	elseif #body_list == 1 then return body_list[1]
	else
		local body = ''
		for i=1,#body_list do
			if body ~= '' then body=body..'\n' end
			body = body..body_list[i]
		end
		return '<table class="mw-json-multiple"><tr><td>'.. body..'</td></tr></table>'
	end
end
function p._table_copy(json,target)
	local copy = {result=target or {}}
	local check_deep = {}
	local reference_list = {}
	local json_stack={{obj=json,target=copy,key='result',path='root'}}
	while(#json_stack > 0)do
		local top = json_stack[#json_stack]
		json_stack[#json_stack] = nil--pop
		local topkey = _preEsc(top.key)
		if _isNumber(top.key) then topkey = tonumber(top.key) end
		if _isNumber(top.key) or type(top.key) == type('') then
			if type(top.obj) == type({}) then
				local is_same = false
				local cur_data
				for i=1,#check_deep do
					if check_deep[i].obj == top.obj then
						is_same = true
						cur_data = check_deep[i]
						break
					end
				end
				if not is_same then
					cur_data = {obj=top.obj, path=top.path..'.'..top.key}
					check_deep[#check_deep + 1] = cur_data
					top.target[topkey] = {}
					if top.obj[0]==nil and _isArray(top.obj)then
						top.target[topkey][0]="$array"
					end
					for k,v in pairs(top.obj) do
						if type(k) ~= type({}) and type(k) ~= type(function()end) then
							json_stack[#json_stack + 1]={
								obj=v,
								target=top.target[topkey],
								key=_isNumber(k)and tonumber(k)or tostring(k),
								path=top.path..'.'..top.key
							}
						end
					end
				else
					local ref_path = mw.ustring.gsub(cur_data.path,'^root%.result%.','')
					top.target[topkey] = '$reference:'..ref_path
					reference_list[ref_path] = true
				end
			elseif type(top.obj) == type(function()end) then
				top.target[topkey]='$function:'..mw.ustring.gsub(top.path..'.'..top.key,'^root%.result%.','')
			elseif type(top.obj) == type('') then
				if _isNumber(top.obj) then
					top.target[topkey]=tonumber(top.obj)
				else
					top.target[topkey]=_preEsc(top.obj)
				end
			else
				top.target[topkey]=top.obj
			end
		end
	end
	return copy.result,reference_list
end
function p._json_reader(json,reference_list,other_class)
	local reference_list = reference_list or {}
	local sing_value = type(json) == type('') or type(json) == type(0) or type(json) == type(true)
	--見lua說明,遞迴的變數行為可能會導致遞迴失效,因此用堆疊實現
	local json_stack={{result='',obj={}}}
	json_stack[#json_stack + 1] = {obj=json,parent=json_stack[1],state='start',root=true,result='',is_last=true,path='root'}
	while(#json_stack > 1)do
		local top = json_stack[#json_stack]
		if _isArray(top.obj) then
			if top.state == 'start'then
				top.state = 'process'
				top.cap = true
				local last_key
				for i=1,#top.obj do
					last_key = i
					json_stack[#json_stack + 1] = {
						obj = top.obj[i], key = i, parent=top, state='start',result='',type='array_item', path=top.path..'.'..(top.key or 'result')
					}
				end
				if last_key ~= nil then json_stack[#json_stack].is_last = true end
			elseif top.state == 'process' then
				json_stack[#json_stack] = nil--pop
				local peek = top.parent
				local top_result = top.result
				if mw.text.trim(top_result) == '' then top_result ='<td class="mw-json-empty"><span class="mw-json-empty-array"></span></td>'end
				local result_data = _renderHide('[')..'<table class="mw-json">'..top_result..'</table>'.. _renderHide(']')
				local is_array = top.key and not (top.type=='array_item')
				result_data = _renderItem(_postEsc(top.key), result_data, top.type=='array_item', top.is_last)
				peek.result=result_data..peek.result
			end
		elseif type(top.obj) == type({}) then
			if top.state == 'start'then
				top.state = 'process'
				top.cap = true
				local last_key
				for k,v in pairs(top.obj) do
					last_key = k
					json_stack[#json_stack + 1] = {
						obj = v, key = k, parent=top, state='start',result='',type='obj_item', path=top.path..'.'..(top.key or 'result')
					}
				end
				if last_key ~= nil then json_stack[#json_stack].is_last = true end
			elseif top.state == 'process' then
				json_stack[#json_stack] = nil--pop
				local peek = top.parent
				local top_result = top.result
				local html_id = nil
				local ref_path = mw.ustring.gsub(top.path,'^root%.result%.','')
				if reference_list[ref_path] == true then html_id='mw-json-ref-'..ref_path end
				if mw.text.trim(top_result) == '' then top_result ='<td class="mw-json-empty"><span class="mw-json-empty-object"></span></td>'end
				local result_data = _renderHide('{')..'<table class="mw-json"'..(html_id and (' id="'..html_id..'"')or'')..'>'..top_result..'</table>'.._renderHide('}')
				local is_array = top.key and not (top.type=='array_item')
				local result_data = _renderItem(_postEsc(top.key), result_data, top.type=='array_item', top.is_last)
				peek.result=result_data..peek.result
			end
		else
			json_stack[#json_stack] = nil--pop
			local peek = top.parent
			local result_data = (type(top.obj) == type(0) or type(top.obj) == type(true) or type(top.obj) == type(''))and 
				_renderTerminal(_postEsc(top.key), top.obj, top.type=='array_item', top.is_last, sing_value) or ''
			peek.result=top.result..result_data..peek.result
		end
	end
	local class_list = {}
	if _isArray(json) or sing_value then class_list[#class_list+1]="mw-json" end
	if sing_value then class_list[#class_list+1]="mw-json-single-value" end
	if other_class then class_list[#class_list+1]=other_class end
	class_list = table.concat(class_list, ' ')
	local outer_class = 'class="'..(class_list or '').. ' mw-json-main-table"'
	return '<table '..outer_class..'>' .. json_stack[1].result .. '</table>'
end
return p