Jump to content

Module:Parameter validation

Permanently protected module
From Wikipedia, the free encyclopedia

local util = {
	empty = function( s )
		return s == nil or type( s ) == 'string' and mw.text.trim( s ) == ''
	end,

	extract_options = function ( frame, optionsPrefix )
		optionsPrefix = optionsPrefix or 'options'

		local options, n = {}
		if frame.args.module_options then
			local module_options = mw.loadData( frame.args.module_options )
			if type( module_options ) ~= 'table' then return {} end
			local title = mw.title.getCurrentTitle()
			local local_ptions = module_options[ title.namespace ] or module_options[ title.nsText ] or {}
			for k, v in pairs( local_ptions ) do options[k] = v end
		end

		repeat
			local ok, more = pcall( mw.text.jsonDecode, frame.args[optionsPrefix .. ( n or '' )] )
			if ok and type( more ) == 'table' then
				for k, v in pairs( more ) do options[k] = v end
			end
			n = ( n or 0 ) + 1
		until not ok

		return options
	end,

	build_namelist = function ( template_name, sp )
		local res = { template_name }
		if sp then
			if type( sp ) == 'string' then sp = { sp } end
			for _, p in ipairs( sp ) do table.insert( res, template_name .. '/' .. p ) end
		end
		return res
	end,

	table_empty = function( t ) -- normally, test if next(t) is nil, but for some perverse reason, non-empty tables returned by loadData return nil...
		if type( t ) ~= 'table' then return true end
		if #t > 0 then return false end
		return true
	end,
}

local function _readTemplateData( templateName )
	local title = mw.title.makeTitle( 0, templateName )
	local templateContent = title and title.exists and title:getContent() -- template's raw content
	local capture = templateContent and mw.ustring.match( templateContent, '<templatedata%s*>(.*)</templatedata%s*>' ) -- templatedata as text
--	capture = capture and mw.ustring.gsub( capture, '"(%d+)"', tonumber ) -- convert "1": {} to 1: {}. frame.args uses numerical indexes for order-based params.
	local trailingComma = capture and mw.ustring.find( capture, ',%s*[%]%}]' ) -- look for ,] or ,} : jsonDecode allows it, but it's verbotten in json
	if capture and not trailingComma then return pcall( mw.text.jsonDecode, capture ) end
	return false
end

local function readTemplateData( templateName )
	for _, name in ipairs( templateName ) do
		local td, result = _readTemplateData( name )
		if td then return result end
	end
	return nil
end

-- this is the function to be called by other modules. it expects the frame, and then an optional list of subpages, e.g. { "Documentation" }.
-- if second parameter is nil, only template page will be searched for templatedata.
local function calculateViolations( frame, subpages )
-- used for parameter type validy test. keyed by TD 'type' string. values are function(val) returning bool.
	local type_validators = {
		['number'] = function( s ) return mw.language.getContentLanguage():parseFormattedNumber( s ) end
	}
	local function compatible( typ, val )
		local func = type_validators[typ]
		return type( func ) ~= 'function' or util.empty( val ) or func( val )
	end

	local t_frame = frame:getParent()
	local t_args, template_name = t_frame.args, t_frame:getTitle()
	template_name = mw.ustring.gsub( template_name, '/sandbox', '', 1 )
	local td_source = util.build_namelist( template_name, subpages )
	if frame.args.td_source then
		table.insert(td_source, frame.args.td_source)
	end

	local templatedata = readTemplateData( td_source )
	local td_params = templatedata and templatedata.params
	local all_aliases = {}

	if not td_params then return { ['no-templatedata'] = { [''] = '' } } end
	-- from this point on, we know templatedata is valid.

	local res = {} -- before returning to caller, we'll prune empty tables

	-- allow for aliases
	for x, p in pairs( td_params ) do
		for y, alias in ipairs( p.aliases or {} ) do
			p.primary = x
			td_params[x] = p
			all_aliases[alias] = p
			if tonumber(alias) then all_aliases[tonumber(alias)] = p end
		end
	end

	-- handle undeclared and deprecated
	local already_seen = {}
	local series = frame.args.series
	for p_name, value in pairs( t_args ) do
		local tp_param, noval, numeric = td_params[p_name] or all_aliases[p_name], util.empty( value ), tonumber( p_name )
		local table_name
		local hasval = not noval

		if not tp_param and series then -- 2nd chance. check to see if series
			for s_name, p in pairs(td_params) do
				if mw.ustring.match( p_name, '^' .. s_name .. '%d+' .. '$') then
					-- mw.log('found p_name ' .. p_name .. ' s_name:' .. s_name, ' p is:', p) debugging series support
					tp_param = p
				end -- don't bother breaking. td always correct.
			end
		end

		if not tp_param then -- not in TD: this is called undeclared
			-- calculate the relevant table for this undeclared parameter, based on parameter and value types
			table_name =
				noval and numeric and 'empty-undeclared-numeric' or
				noval and not numeric and 'empty-undeclared' or
				hasval and numeric and 'undeclared-numeric' or
				'undeclared' -- tzvototi nishar.
		else -- in td: test for deprecation and mistype. if deprecated, no further tests
			table_name = tp_param.deprecated and hasval and 'deprecated'
				or tp_param.deprecated and noval and 'empty-deprecated'
				or not compatible( tp_param.type, value ) and 'incompatible'
				or not series and already_seen[tp_param] and hasval and 'duplicate'

			if hasval and table_name ~= 'duplicate' then
				already_seen[tp_param] = p_name
			end
		end

		-- report it.
		if table_name then
			res[table_name] = res[table_name] or {}
			if table_name == 'duplicate' then
				local primary_param = tp_param.primary
				local primaryData = res[table_name][primary_param]
				if not primaryData then
					primaryData = {}
					table.insert(primaryData, already_seen[tp_param])
				end
				table.insert(primaryData, p_name)
				res[table_name][primary_param] = primaryData
			else
				res[table_name][p_name] = value
			end
		end
	end

	-- check for empty/missing parameters declared "required"
	for p_name, param in pairs( td_params ) do
		if param.required and util.empty( t_args[p_name] ) then
			local is_alias
			for _, alias in ipairs( param.aliases or {} ) do is_alias = is_alias or not util.empty( t_args[alias] ) end
			if not is_alias then
				res['empty-required'] = res['empty-required'] or {}
				res['empty-required'][p_name] = ''
			end
		end
	end

	mw.logObject(res)

	return res
end

-- wraps report in hidden frame
local function wrapReport(report, template_name, options)
	mw.logObject(report)
	if util.empty( report ) then return '' end
	local naked = mw.title.new( template_name ).text
	naked = mw.ustring.gsub(naked, 'Infobox', 'infobox', 1)

	report = ( options['wrapper-prefix'] or '<div class="paramvalidator-wrapper"><span class="paramvalidator-error">' )
			.. report
			.. ( options['wrapper-suffix'] or '</span></div>' )

	report = mw.ustring.gsub( report, 'tname_naked', naked )
	report = mw.ustring.gsub( report, 'templatename', template_name )

	return report
end

-- this is the "user" version, called with {{#invoke:}} returns a string, as defined by the options parameter
local function validateParams( frame )
	local options, report, template_name = util.extract_options( frame ), '', frame:getParent():getTitle()

	local ignore = function( p_name )
		for _, pattern in ipairs( options.ignore or {} ) do
			if mw.ustring.match( p_name, '^' .. pattern .. '$' ) then return true end
		end
		return false
	end

	local replace_macros = function( error_type, s, param_names )
		local function concat_and_escape( t , sep )
			sep = sep or ', '
			return ( mw.ustring.gsub( table.concat( t, sep ), '%%', '%%%%' ) )
		end

		if s and ( type( param_names ) == 'table' ) then
			local k_ar, kv_ar = {}, {}
			for k, v in pairs( param_names ) do
				table.insert( k_ar, k )
				if type(v) == 'table' then
					v = table.concat(v, ', ')
				end

				if error_type == 'duplicate' then
					table.insert( kv_ar, v)
				else
					table.insert( kv_ar, k .. ': ' .. v)
				end
			end

			s = mw.ustring.gsub( s, 'paramname', concat_and_escape( k_ar ) )
			s = mw.ustring.gsub( s, 'paramandvalue', concat_and_escape( kv_ar, ' AND ' ) )

			if mw.getCurrentFrame():preprocess( '{{REVISIONID}}' ) ~= '' then
				s = mw.ustring.gsub( s, '<div.*<%/div>', '', 1 )
			end
		end
		return s
	end

	local report_params = function( key, param_names )
		local res = replace_macros( key, options[key], param_names )
		res = frame:preprocess(res or '')
		report = report .. ( res or '' )
		return res
	end

	-- no option no work.
	if util.table_empty( options ) then return '' end

	-- get the errors.
	local violations = calculateViolations( frame, options['doc-subpage'] )
	-- special request of bora: use skip_empty_numeric
	if violations['empty-undeclared-numeric'] then
		for i = 1, tonumber( options['skip-empty-numeric'] ) or 0 do
			violations['empty-undeclared-numeric'][i] = nil
		end
	end

	-- handle ignore list, and prune empty violations - in that order!
	local offenders = 0
	for name, tab in pairs( violations ) do
		-- remove ignored parameters from all violations
		for pname in pairs( tab ) do if ignore( pname ) then tab[pname] = nil end end
		-- prune empty violations
		if util.table_empty( tab ) then violations[name] = nil end
	-- WORK IS DONE. report the errors.
	-- if report then count it.
		if violations[name] and report_params( name, tab ) then offenders = offenders + 1 end
	end

	if offenders > 1 then report_params( 'multiple' ) end
	if offenders ~= 0 then report_params( 'any' ) end -- could have tested for empty( report ), but since we count them anyway...
	return wrapReport(report, template_name, options)
end

return {
	['validateparams'] = validateParams,
	['calculateViolations'] = calculateViolations,
	['wrapReport'] = wrapReport
}