Module:Documentation/Module

From Zelda Wiki, the Zelda encyclopedia
Jump to navigation Jump to search

local p = {}
local h = {}
local styles = mw.getCurrentFrame() and mw.getCurrentFrame():extensionTag({
	name = "templatestyles",
	args = { src = "Module:Documentation/Styles.css" }
})

local i18n = require("Module:I18n")
local s = i18n.getString
local lex = require("Module:Documentation/Lexer")
local utilsFunction = require("Module:UtilsFunction")
local utilsLayout = require("Module:UtilsLayout")
local utilsMarkup = require("Module:UtilsMarkup")
local utilsPage = require("Module:UtilsPage")
local utilsSchema = require("Module:UtilsSchema")
local utilsString = require("Module:UtilsString")
local utilsTable = require("Module:UtilsTable")
local utilsVar = require("Module:UtilsVar")

local caseCounter = utilsVar.counter("testCases")

local DocumentationTemplate = require("Module:Documentation/Template")

local MAX_ARGS_LENGTH = 50

function getModulePage(frame)
	local docPage = mw.title.new(frame:getParent():getTitle())
	local modulePage = docPage.basePageTitle
	local subpageText = modulePage.subpageText
	if subpageText == "Data" then
		modulePage = modulePage.basePageTitle
	end
	return modulePage.fullText, subpageText, docPage.fullText
end

function p.Schema(frame)
	local modulePage = frame.args.module or getModulePage(frame)
	local module = require(modulePage)
	local schemaName = frame.args[1]
	return styles..p.schema(module.Schemas()[schemaName], schemaName)
end

function p.Spec(frame)
	return p.ModuleSpec(frame)
end

function p.Main(frame)
	return p.Module(frame)
end

function p.Module(frame, hasSpecPage)
	local modulePage, subpage, docPage = getModulePage(frame, hasSpecPage)
	local specPage = hasSpecPage and (modulePage.."/Documentation/Spec") or modulePage
	local categories = utilsMarkup.categories(h.getCategories(frame.args.type))
	
	local result
	if utilsString.endsWith(docPage, "/Documentation/Snippets/Documentation") then
		local category = mw.title.getCurrentTitle().subpageText == "Documentation" and "[[Category:Module Snippets Documentation]]" or "[[Category:Module Snippets]]"
		result = string.format("This module contains snippets used to [[Module:Documentation|generate documentation and unit tests]] for [[Module:%s]]. %s", mw.title.getCurrentTitle().rootText, category)
	elseif utilsString.endsWith(docPage, "/TemplateData/Documentation") then
		local category = mw.title.getCurrentTitle().subpageText == "Documentation" and "[[Category:Template Data Documentation]]" or "[[Category:Template Data]]"
		result = string.format("This data is used to [[Module:Documentation|auto-generate documentation]] and [[Module:UtilsArg|validate input]] for templates backed by [[Module:%s]]. %s", mw.title.getCurrentTitle().rootText, category)
	elseif subpage == "Data" and modulePage ~= "Module:Data" then -- documenting data
		result = p.dataDoc(modulePage, specPage) .. categories
	elseif utilsString.startsWith(modulePage, "Module:Util/") then -- documentating new style of utils which export one function per page
		result = p.moduleDocUtil(modulePage, specPage) .. categories
	else --documenting a module
		result = p.moduleDoc(modulePage, specPage) .. categories
	end
	return styles..result
end

function p.ModuleSpec(frame)
	return p.Module(frame, true)
end

function p.dataDoc(modulePage, specPage)
	local result = "''For information on editing module data in general, see [[Guidelines:Modules/Data]].''\n"
	local spec = require(specPage)
	local tabs = {}
	if type(spec.Data) == "function" then
		table.insert(tabs, {
			label = "Data",
			content = spec.Data(mw.getCurrentFrame())
		})
	end
	local schemas = spec.Schemas and spec.Schemas()
	local dataSchema = schemas and schemas.Data
	if dataSchema then
		table.insert(tabs, {
			label = "Schema",
			content = p.schema(dataSchema, "Data")
		})
		local data = mw.loadData(modulePage.."/Data")
		local err = utilsSchema.validate(dataSchema, "Data", data, "data")
		if err then
			result = result.."[[Category:Modules with invalid data]]"
		end
	end
	result = result .. utilsLayout.tabs(tabs, {
		{
			tabs = {
				collapse = true
			}
		}
	})
	return result
end

function p.schema(schema, schemaName)
	local referenceDefinitions = p.collectReferenceDefinitions(schema)
	local dt, dds = p.printSchemaNode(referenceDefinitions, schema, schemaName)
	local schemaDef = utilsTable.flatten({dt, dds})
	schemaDef = utilsMarkup.definitionList({schemaDef})
	
	return schemaDef
end
function p.collectReferenceDefinitions(schema)
	local defs = schema.definitions or {}
	for k, v in pairs(schema) do
		if k == "_id" then
			defs[v] = schema
		elseif type(v) == "table" then
			defs = utilsTable.merge({}, defs, p.collectReferenceDefinitions(v))
		end
	end
	return defs
end

local seenRefs = {}
function p.printSchemaNode(definitions, schemaNode, schemaName, isCollectionItem)
	local dds = {}
	local nodeType
		
	local refName = schemaNode._ref and string.gsub(schemaNode._ref, "#/definitions/", "")
	if schemaNode._hideSubkeys then
		nodeType = refName
	elseif refName then
		local ref = definitions[refName]

		if not ref and not seenRefs[refName] then
			error(string.format("Definition '%s' not found", refName))
		end
		if not ref and seenRefs[refName] then
			nodeType = refName
		else 
			schemaNode = utilsTable.merge({}, schemaNode, ref)
		end
		
		-- prevents infinite recursion when definitions reference themselves
		seenRefs[refName] = true
		definitions = utilsTable.clone(definitions)
		definitions[refName] = nil
	end
	
	nodeType = nodeType or schemaNode.type

	if schemaNode.desc then
		table.insert(dds, schemaNode.desc)
	end
	if nodeType == "record" then
		local dl = {}
		for i, property in ipairs(schemaNode.properties or {}) do
			local subdt, subdds = p.printSchemaNode(definitions, property, property.name)
			table.insert(dl, utilsTable.flatten({subdt, subdds}))
		end
		dl = utilsMarkup.definitionList(dl)
		table.insert(dds, dl)
	end
	if nodeType == "map" then
		local dt, subdds, valueSubtype = p.printSchemaNode(definitions, schemaNode.values, nil, true)
		local _, keydds, keySubtype = p.printSchemaNode(definitions, schemaNode.keys, nil, true)
		local keyPlaceholder = string.format("[[Module:Documentation#Schemas|<code>&lt;%s&gt;</code>]]", schemaNode.keyPlaceholder or keySubtype)
		if #subdds > 0 then
			local dl = utilsMarkup.definitionList({utilsTable.flatten({keyPlaceholder, keydds, subdds})})
			table.insert(dds, dl)
		end
		nodeType = string.format("map<%s, %s>", keySubtype, valueSubtype)
	end
	if nodeType == "array" then
		local dt, subdds, subtype = p.printSchemaNode(definitions, schemaNode.items, nil, true)
		dds = utilsTable.concat(dds, subdds)
		nodeType = "{ "..(subtype).." }"
	end
	if schemaNode.allOf then
		local subtypes = {}
		for i, node in ipairs(schemaNode.allOf) do
			local dt, subdds, subtype = p.printSchemaNode(definitions, node)
			dds = utilsTable.concat(dds, subdds)
			table.insert(subtypes, subtype)
		end
		nodeType = table.concat(subtypes, "&")
	end
	if schemaNode.oneOf then
		local tabs = {}
		local subtypes = {}
		for k, node in pairs(schemaNode.oneOf) do
			local subdt, subdds, subtype = p.printSchemaNode(definitions, node, nil, true)
			
			if subtype == "record" and subtypes[#subtypes] == "record" then
				-- no-op: don't bother showing record|record
			else
				table.insert(subtypes, subtype)
			end

			local tabname = tostring(k)
			if type(k) == "number" and node._tabName then
				tabname = node._tabName
			elseif type(k) == "number" and node._ref then
				tabname = string.gsub(node._ref, "#/definitions/", "")
			elseif type(k) == "number" then
				tabname = subtype
			end
			if #subdds > 0 then
				table.insert(subdds, 1, nil)
				table.insert(tabs, {
					label = tabname,
					content = utilsMarkup.definitionList({subdds})
				})
			end
		end
		if #tabs > 0 then
			tabs = utilsLayout.tabs(tabs, {
				tabOptions = {
					collapse = true,
				},
			})
			table.insert(dds, tabs)
		end
		nodeType = table.concat(subtypes, "|")
	end
	if schemaNode.required and not isCollectionItem then
		nodeType = nodeType and nodeType.."!"
	elseif not isCollectionItem then
		nodeType = nodeType and "["..nodeType.."]"
		if schemaNode.default then
			schemaName = schemaName .."="..tostring(schemaNode.default)
		end
		schemaName = schemaName and "["..schemaName.."]"
	end
	local dt = schemaName and utilsMarkup.inline(schemaName, {
		code = true,
		tooltip = nodeType,
	})
	dt = utilsMarkup.link("Module:Documentation#Schemas", dt)

	return dt, dds, nodeType
end

function p.moduleDocUtil(modulePage, specPage)
	local title = mw.title.new(modulePage)
	local fn = title.subpageText -- function name
	fn = string.gsub(fn, "^ ", "_")
	local module = { [fn] = require(modulePage) }
	local spec = require(specPage)
	local doc = { 
		[fn] = spec.Documentation(),
		snippets = h.snippets(modulePage),
	}
	local schemas = spec.Schemas and { [fn] = spec.Schemas() } or {}
	local fnDoc = h.resolveFunctionDoc(module, doc, schemas, fn)
	local result = h.printFunctionDoc(fnDoc, modulePage)
	if string.find(fn, "^_") then
		-- Prevents the _ from being shown as a blank space in the h1 header for pages like Module:Util/strings/_startsWith
		local displayTitle = string.gsub(mw.title.getCurrentTitle().fullText, "/ ", "/_")
		result = result .. mw.getCurrentFrame():preprocess("{{DISPLAYTITLE:"..displayTitle.."}}")
		
		local nonCurriedFnPage = string.gsub(modulePage, "/ ", "/")
		local nonCurriedFnName = string.gsub(fn, "^_", "")
		local curryMsg = string.format("<p>A [[Module:Util/tables#Currying|curried]] version of [[%s]].</p>", nonCurriedFnPage, nonCurriedFnName)
		result = curryMsg .. result
	end
	return result
end

function p.moduleDoc(modulePage, specPage, section)
	local headingLevel = section and 3 or 2
	headingLevel =  mw.getCurrentFrame().args.functionHeadingLevel or headingLevel -- hack for [[Module:Error/Documentation]]
	local doc, templates = h.resolveDoc(modulePage, specPage, section)
	local output = ""
	if not section then
		if templates ~= nil then
			local templates = utilsTable.keys(templates)
			table.sort(templates)
			local templateLinks = {}
			for i, templateName in ipairs(templates) do
				local templatePage = "Template:"..templateName
				if utilsPage.exists(templatePage) then
					local templateLink = utilsMarkup.link(templatePage)
					table.insert(templateLinks, templateLink)
				end
			end
			if #templateLinks > 0 then
				local templateList = utilsMarkup.bulletList(templateLinks)
				output = "This is the main module for the following templates:" .. templateList .. "\n"
			end
			if #templateLinks > 0 and #doc.functions > 0 or doc.sections then
				output = output .. "In addition, this module exports the following functions. __TOC__\n"
			end
		elseif #doc.functions > 0 or doc.sections then
			output = "This module exports the following functions. __TOC__\n"
		end
	end
	for _, functionDoc in ipairs(doc.functions or {}) do
		output = output .. utilsMarkup.heading(headingLevel, functionDoc.name) .. "\n"
		if functionDoc.wip then
			output = output .. mw.getCurrentFrame():expandTemplate({
				title = "WIP",
				args = {
					align = "left",
				}	
			}) .. '<div style="clear:left"/>'
		end
		
		if functionDoc.fp then
			output = output .. utilsLayout.tabs({
				{
					label = functionDoc.name,
					content = h.printFunctionDoc(functionDoc)
				},
				{
					label = functionDoc.fp.name,
					content = h.printFunctionDoc(functionDoc.fp)
				}
			})
		else
			output = output .. h.printFunctionDoc(functionDoc, modulePage)
		end
	end
	if doc.sections then
		for _, section in ipairs(doc.sections) do
			local sectionModule = type(section.section) == "string" and section.section or modulePage
			if section.heading then
				output = output .. utilsMarkup.heading(headingLevel, section.heading)
				output = output .. p.moduleDoc(sectionModule, sectionModule, section.section) .. "\n"
			else
				output = output .. p.moduleDoc(sectionModule, sectionModule, section.section)
			end
		end
	end
	return output
end

function h.resolveDoc(modulePage, specPage, section)
	local spec = require(specPage)
	local module = require(modulePage)
	local doc
	local schemas
	local templates
	local templateDataPage = modulePage .. "/TemplateData"
	-- For performance reasons, template data may be placed on a separate page
	-- We try to load it from there first. If it doesn't exist, we load it directly from the module page itself
	if utilsPage.exists(templateDataPage) then
		local templateData = require(templateDataPage)
		if type(templateData) == "table" then
			templates = templateData
		end
	end
	if type(spec) == "table" then
		doc = spec.Documentation and spec.Documentation()
		schemas = spec.Schemas and spec.Schemas()
		templates = templates or module.Templates
	end
	if type(section) == "table" then
		doc = section
	end
	doc = doc or {}
	schemas = schemas or {}
	local err = utilsSchema.validate(p.Schemas().Documentation, "Documentation", doc, "p.Documentation")
	if err then
		mw.logObject(err)
	end
	if doc.sections then
		doc.functions = {}
		doc.snippets = h.snippets(modulePage)
		return doc, templates
	end
	local functionNamesInSource = h.functionNamesInSource(modulePage)
	local functionNamesInDoc = {}
	for k, v in pairs(doc) do
		table.insert(functionNamesInDoc, k)
		if doc._params then
			table.insert(functionNamesInDoc, "_" .. k)
		end
	end
	local functionNames = utilsTable.intersection(functionNamesInSource, functionNamesInDoc)
	local undefinedFunctions = utilsTable.difference(functionNamesInDoc, functionNames)
	if #undefinedFunctions > 0 then
		local msg = string.format("Documentation references functions that do not exist: <code>%s</code>", utilsTable.print(undefinedFunctions, true))
		mw.addWarning(msg)
	end
	local functions = {}
	doc.snippets = h.snippets(modulePage)
	for _, functionName in ipairs(functionNames) do
		table.insert(functions, h.resolveFunctionDoc(module, doc, schemas, functionName))
		doc[functionName] = nil
	end
	doc.functions = functions
	return doc, templates
end
function h.functionNamesInSource(modulePage)
	local source = mw.title.new(modulePage):getContent()
	local lexLines = lex(source)
	local functionNames = {}
	for _, tokens in ipairs(lexLines) do
		tokens = utilsTable.filter(tokens, function(token) 
			return token.type ~= "whitespace" 
		end)
		tokens = utilsTable.map(tokens, "data")
		if utilsTable.isEqual(
			utilsTable.slice(tokens, 1, 3),
			{"function", "p", "."}
		) then
			table.insert(functionNames, tokens[4])
		end
	end
	return functionNames
end

function h.resolveFunctionDoc(module, moduleDoc, schemas, functionName)
	local functionDoc = moduleDoc[functionName]
	functionDoc.name = functionName
	functionDoc.fn = module[functionDoc.name]
	functionDoc.cases = functionDoc.cases or {}
	functionDoc.snippets = h.getFunctionSnippets(moduleDoc.snippets, functionDoc.name)
	if type(functionDoc.returns) ~= "table" then
		functionDoc.returns = {functionDoc.returns}
		for i, case in ipairs(functionDoc.cases) do
			case.expect = {case.expect}
		end
	end
	local paramSchemas = schemas[functionDoc.name] or {}
	local resolvedParams = utilsTable.map(functionDoc.params or {}, function(param)
		return { name = param, schema = paramSchemas[param] }
	end)
	if functionDoc._params then
		functionDoc.fp = mw.clone(functionDoc)
		functionDoc.fp.name = "_" .. functionDoc.name
		functionDoc.fp.fn = module[functionDoc.fp.name]
		for _, case in ipairs(functionDoc.fp.cases) do
			case.args = case.args and utilsTable.map(functionDoc._params, function(paramGroup)
				return utilsTable.map(paramGroup, function(param)
					return case.args[utilsTable.keyOf(functionDoc.params, param)]
				end)
			end)
		end
		functionDoc.fp.params = utilsTable.map(functionDoc._params, function(paramGroup)
			return utilsTable.map(paramGroup, function(param)
				return resolvedParams[utilsTable.keyOf(functionDoc.params, param)]
			end)
		end)
		functionDoc.fp.snippets = h.getFunctionSnippets(moduleDoc.snippets, functionDoc.fp.name)
	end
	functionDoc.params = {resolvedParams}
	if not functionDoc.frameParams then
		for _, case in ipairs(functionDoc.cases) do
			case.args = {case.args}
		end
	end
	return functionDoc
end
function h.getFunctionSnippets(moduleSnippets, functionName)
	local functionSnippets = {}
	for k, v in pairs(moduleSnippets or {}) do
		local s, e = k:find(functionName)
		if e then
			local snippetKey = string.sub(k, e + 1)
			functionSnippets[snippetKey] = v
		end
	end
	return functionSnippets
end

function h.printFunctionDoc(functionDoc, modulePage)
	local result = ""
	local moduleName = modulePage and string.gsub(modulePage, "Module:", "")
	result = result .. h.printFunctionSyntax(functionDoc, moduleName)
	result = result .. h.printFunctionDescription(functionDoc)
	result = result .. h.printParamsDescription(functionDoc)
	result = result .. h.printReturnsDescription(functionDoc.returns)
	result = result .. h.printFrameParams(functionDoc)
	result = result .. h.printFunctionCases(functionDoc, moduleName)
	return result
end

function h.printFunctionSyntax(functionDoc, moduleName)
	local result = ""
	if functionDoc.frameParams then
		result = string.format("{{#invoke:%s|%s%s%s}}", moduleName, functionDoc.name, h.printFrameParamsSyntax(functionDoc), functionDoc.frameParamsFormat == "multiLine" and "\n" or "")
	else
		for _, params in ipairs(functionDoc.params) do
			result = functionDoc.name .. "(" .. h.printParamsSyntax(params) .. ")"
		end
	end
	if functionDoc.frameParams then
		return '<pre class="module-documentation__invoke-syntax">'..result..'</pre>'
	else
		return utilsMarkup.code(result) .. "\n"
	end
end
function h.printParamsSyntax(params)
	local paramsSyntax = {}
	for _, param in ipairs(params or {}) do
		local paramSyntax = param.name
		if param.schema and not param.schema.required then
			paramSyntax = "[" .. paramSyntax .. "]"
		end
		table.insert(paramsSyntax, paramSyntax)
	end
	return table.concat(paramsSyntax, ", ") 
end
function h.printFrameParamsSyntax(functionDoc)
	local paramsSyntax = ""
	
	local orderedParams = h.sortFrameParams(functionDoc, functionDoc.frameParams)

	for i, param in ipairs(orderedParams) do
		if functionDoc.frameParamsFormat == "multiLine" and not param.inline then
			paramsSyntax = paramsSyntax .. "\n"
		end
		if functionDoc.frameParamsFormat == "multiLine" and param.spaceBefore then
			paramsSyntax = paramsSyntax .. "\n"
		end
		paramsSyntax = paramsSyntax.."|"
		if type(param.param) == "string" then
			paramsSyntax = paramsSyntax .. param.param .. "="
		else
			paramsSyntax = paramsSyntax.."<"..(param.name or "")..">"
		end
	end
	return paramsSyntax
end
function h.sortFrameParams(functionDoc, frameParams)
	local orderedParams = {}
	local seenParams = {}
	
	-- First the positional parameters 
	for i, param in ipairs(frameParams) do
		param = utilsTable.merge({}, param, {
			param = i -- needs to have "param" key for Module:Documentation/Template to print the parameter table
		})
		table.insert(orderedParams, param)
		seenParams[i] = true
	end
	
	-- Then, according to frameParamsOrder
	for i, paramKey in ipairs(functionDoc.frameParamsOrder  or {}) do
		local param = frameParams[paramKey]
		if param then
			param = utilsTable.merge({}, param, {
				param = paramKey
			})
			table.insert(orderedParams, param)
		end
		seenParams[paramKey] = true
	end
	
	-- Then, any remaining params
	local paramKeys = utilsTable.keys(frameParams or {})
	local seenParamKeys = utilsTable.keys(seenParams)
	local remainingParamNames = utilsTable.difference(paramKeys, seenParamKeys)
	for i, paramKey in ipairs(remainingParamNames) do
		local param = frameParams[paramKey]
		param = utilsTable.merge({}, param, {
			param = paramKey
		})
		table.insert(orderedParams, param)
	end

	return orderedParams
end

function h.printFunctionDescription(functionDoc)
	local result = ""
	if functionDoc.desc then
		result = "\n" .. mw.getCurrentFrame():preprocess(functionDoc.desc) .. "\n"
	end
	return result
end

function h.printParamsDescription(functionDoc)
	if not functionDoc.params or #functionDoc.params == 0 then
		return ""
	end
	local allParams = utilsTable.flatten(functionDoc.params)
	local paramDefinitions = {}
	for _, param in ipairs(allParams) do
		if param.schema then
			table.insert(paramDefinitions, p.schema(param.schema, param.name))
		end
	end
	if #paramDefinitions == 0 then
		return ""
	end
	local paramList = utilsMarkup.list(paramDefinitions)
	local heading = "" .. '<p class="module-documentation__function-subheading">' .. s("headers.parameters") .. "</p>\n"
	return heading .. paramList
end

function h.printReturnsDescription(returns)
	if not returns or #returns == 0 then
		return ""
	end
	local returnsList = utilsMarkup.bulletList(returns)
	local heading = "\n" .. '<p class="module-documentation__function-subheading">' .. s("headers.returns") .. "</p>\n"
	local result = heading .. mw.getCurrentFrame():preprocess(returnsList)
	return result
end

function h.printFrameParams(doc)
	if not doc.frameParams then
		return ""
	end
	local params = h.sortFrameParams(doc, doc.frameParams)

	local heading = '<p class="module-documentation__function-subheading">' .. s("headers.parameters") .. "</p>\n"
	local paramTable = DocumentationTemplate.params(params)
	return heading..paramTable
end

function h.printFunctionCases(doc, moduleName)
	if not doc.cases or #doc.cases == 0 then
		return ""
	end
	local result = "\n" .. '<p class="module-documentation__function-subheading">' .. s("headers.examples") .. "</p>\n"
	
	local inputColumn = s("headers.input")
	local outputColumn = s("headers.output")
	local resultColumn = s("headers.result")
	local statusColumn = utilsMarkup.tooltip(s("headers.status"), s("explainStatusColumn"))
	
	local headerCells = utilsTable.compact({
		"#",
		inputColumn, 
		not doc.cases.resultOnly and outputColumn or nil, 
		not doc.cases.outputOnly and resultColumn or nil,
		doc.frameParams and "Categories added" or nil,
		statusColumn,
	})
	local tableData = {
		hideEmptyColumns = true,
		rows = {
			{
				header = true,
				cells = headerCells,
			}
		},
	}
	for _, case in ipairs(doc.cases) do
		local caseRows = h.case(doc, case, moduleName, doc.cases)
		tableData.rows = utilsTable.concat(tableData.rows, caseRows)
	end
	result = result .. utilsLayout.table(tableData) .. "\n"
	return result
end

h.snippets = utilsFunction.memoize(function(modulePage)
	local snippetPagename = modulePage .. "/Documentation/Snippets"
	if not utilsPage.exists(snippetPagename) then
		return nil
	end
	local snippets = {}
	local snippetPage = mw.title.new(snippetPagename)
	local module = require(snippetPagename)
	local text = snippetPage:getContent()
	local lexLines = lex(text)
	local names = {}
	local starts = {}
	local ends = {}
	for i, line in ipairs(lexLines) do
		if line[1] and line[1].type == "keyword" and line[1].data == "function" then
			local isOpenParens = function(token)
				return utilsString.startsWith(token.data, "(")
			end
			local fnName = line[utilsTable.findIndex(line, isOpenParens) - 1].data
			table.insert(starts, i + 1)
			table.insert(names, fnName)
		end
		if #line == 1 and line[1].type == "keyword" and line[1].data == "end" then
			table.insert(ends, i - 1)
		end
	end
	local lines = utilsString.split(text, "\n")
	for i, fnName in ipairs(names) do
		local fnLines = utilsTable.slice(lines, starts[i], ends[i])
		fnLines = utilsTable.map(fnLines, function(line)
			line = string.gsub(line, "^\t", "")
			line = string.gsub(line, "\t", "  ")
			return line
		end)
		local fnCode = table.concat(fnLines, "\n")
		snippets[fnName] = {
			fn = module[fnName],
			code = fnCode,
		}
	end
	return snippets
end)

function h.case(doc, case, moduleName, options)
	local caseId = caseCounter.increment()
	local rows = {}
	local input, outputs
	local snippet = case.snippet and doc.snippets[tostring(case.snippet)]
	if snippet then
		input = utilsMarkup.lua(snippet.code, { wrapLines = false })
		outputs = {snippet.fn()}
	elseif doc.frameParams and case.input then
		input = utilsMarkup.pre(case.input)
		outputs = {mw.getCurrentFrame():preprocess(case.input)}
	elseif doc.frameParams and case.args then
		local orderedParams = h.sortFrameParams(doc, doc.frameParams)
		input, rawinput = h.printFrameInput(moduleName, doc.name, orderedParams, case.args)
		local output = mw.getCurrentFrame():preprocess(rawinput)
		if output == "" then
			output = " " -- forces output column to always show for #invoke examples
		end
		outputs = {output}
	elseif case.args then
		input = h.printInput(doc, case.args)
		outputs = h.evaluateFunction(doc.fn, case.args)
	else
		return {}
	end
	
	-- Raw output doesn't make as much sense for #invoke functions so we disable it by default
	if doc.frameParams and options.resultOnly == nil then
		options.resultOnly = true
	end
	
	local outputCount = doc.frameParams and 1 or #doc.returns
	if options.outputOnly == nil then
		-- showing result for any type other than string or nil is pretty much useless compared to showing output
		options.outputOnly = outputCount == 1 and type(outputs[1]) ~= "string" and outputs[1] ~= nil
	end
	for i = 1, outputCount do
		local outputData, resultData, statusData, categories = h.evaluateOutput(outputs[i], case.expect and case.expect[i])
		local categoryList = categories and utilsMarkup.bulletList(categories)
		table.insert(rows, utilsTable.compact({
			not options.resultOnly and outputData or nil,
			not options.outputOnly and resultData or nil,
			doc.frameParams and categoryList or nil,
			case.expect and statusData,
		}))
	end
	rows[1] = rows[1] or {}
	table.insert(rows[1], 1, {
		content = input,
		rowspan = outputCount,
		styles = {
			["word-break"] = "break-all",
		},
	})
	table.insert(rows[1], 1, {
		content = utilsMarkup.anchor("case-"..tostring(caseId), caseId),
		rowspan = outputCount,
	})
	if case.desc then
		table.insert(rows, 1, {
			{
				header = true,
				colspan = -1,
				styles = {
					["text-align"] = "left"
				},
				content = case.desc,
			}
		})
	end
	return rows
end

function h.printFrameInput(moduleName, functionName, params, args)
	local result = string.format("{{#invoke:%s|%s", moduleName, functionName)
	local seenArgs = {}
	-- First, arguments of known parameters
	for i, param in ipairs(params) do
		local paramKey = param.param
		local argValue = args[paramKey]
		
		if argValue then
			seenArgs[paramKey] = true
			result = result..h.printFrameArg(paramKey, argValue)
		end
	end
	-- Then arguments of unknown parameters
	-- This was used by Module:Franchise List - unclear if still needed 
	for argKey, argValue in pairs(args) do
		if not seenArgs[argKey] then
			result = result..h.printFrameArg(argKey, argValue)
		end
	end
	result = result.."}}"
	return utilsMarkup.pre(result), result
end
function h.printFrameArg(param, arg)
	local result = "|"
	if type(param) == "string" then
		if arg ~= "" then
			arg = " "..arg
		end
		result = result..param.."="..arg
	else
		result = result..arg
	end
	return result
end

function h.printInput(doc, argsList)
	local result = doc.name
	local allArgs = utilsTable.flatten(argsList)
	local lineWrap = #allArgs == 1 and type(allArgs[1]) == "string"
	for i, args in ipairs(argsList) do
		result = result .. "(" .. h.printInputArgs(args, doc.params[i], lineWrap) .. ")"
	end
	return utilsMarkup.lua(result, {
		wrapLines = lineWrap
	})
end
function h.printInputArgs(args, params)
	args = args or {}
	local argsText = {}
	for i = 1, math.max(#params, #args) do
		local argText = args[i] == nil and "nil" or utilsTable.print(args[i])
		if not (#args == 1 and type(args[i]) == "table") then
			argText = string.gsub(argText, "\n", "\n  ") --ensures proper indentation of multiline table args
		end
		table.insert(argsText, argText)
	end
	
	-- Trim nil arguments off the end so long as they're optional
	local argsText = utilsTable.dropRightWhile(argsText, function(argText, i)
		return argText == "nil" and params[i] and params[i].schema and not params[i].schema.required
	end)
	
	local result = table.concat(argsText, ", ")
	local lines = mw.text.split(result, "\n")
	-- print multiline if there's multiple args with at least one table or a line longer than the max length
	if #args > 1 and (#lines > 1 or #lines[1] > MAX_ARGS_LENGTH) then
		result = "\n  " .. table.concat(argsText, ",\n  ") .. "\n"
	end
	
	return result
end

function h.evaluateFunction(fn, args)
	for i = 1, #args - 1 do
		fn = fn(unpack(args[i]))
	end
	return {fn(unpack(args[#args]))}
end

function h.evaluateOutput(output, expected)
	local formattedOutput = h.formatValue(output)
	local outputData = formattedOutput
	local resultData = nil
	local categories
	if type(output) == "string" then
		resultData = utilsMarkup.killBacklinks(output)
		resultData, categories = utilsMarkup.stripCategories(output)
	elseif output == nil then
		resultData = " " -- #invoke treats nil as empty space, so we want to show blank as the output
	else
		resultData = tostring(output)
	end
	if type(expected) == "string" then
		expected = string.gsub(expected, "\t", "")
	end
	
	local passed, expectedOutput
	if type(expected) == "function" then 
		local assertionOutput = expected(output)
		passed = assertionOutput == nil
		expectedOutput = assertionOutput and assertionOutput.expected
	else
		passed = utilsTable.isEqual(expected, output)
		expectedOutput = expected
	end
	
	local statusData = (expected ~= nil or output == nil) and h.printStatus(passed) or nil
	if statusData and not passed then
		outputData = utilsLayout.table({
			hideEmptyColumns = true,
			styles = { width = "100%" },
			rows = {
				{ 
					{ 
						header = true, 
						content = "Expected", 
						styles = { width = "1em"}, -- "shrink-wraps" this column
					}, 
					{ content = h.formatValue(expectedOutput) },
				},
				{
					{ header = true, content = "Actual" }, 
					{ content = formattedOutput },
				},
			}
		})
	end
	return outputData, resultData, statusData, categories
end

function h.formatValue(val)
	if type(val) ~= "string" then
		return utilsMarkup.lua(val)
	elseif string.find(val, "UNIQ--") then
		-- If the return value has strip markers the string itself probably won't have much meaning
		-- Might as well save an expensive parser function call to SyntaxHighlight
		return utilsMarkup.pre(val)
	else
		val = string.gsub(val, "&#", "&#38;#") -- show entity codes
		val = utilsTable.print(val)
		val = string.gsub(val, "\n", "\\n\n") -- Show newlines	
		return utilsMarkup.lua(val)
	end
end

function h.printStatus(success)
	local img = success and "[[File:Green check.svg|16px|center|link=]]" or "[[File:ZW Bad.png|48px|center|link=]]"
	local msg = success and s("explainStatusGood") or s("explainStatusBad")
	img = utilsMarkup.tooltip(img, msg)
	local cat = ""
	if not success and mw.title.getCurrentTitle().subpageText ~= "Documentation" then
		cat = utilsMarkup.category(s("failingTestsCategory"))
	end
	return img .. cat
end

function h.getCategories(type)
	local title = mw.title.getCurrentTitle()
	local isDoc = title.subpageText == "Documentation"
	local moduleTitle = isDoc and mw.title.new(title.baseText) or title
	local isData = moduleTitle.subpageText == "Data"
	local isUtil = utilsString.startsWith(moduleTitle.text, "Utils")
	local isSubmodule = moduleTitle.subpageText ~= moduleTitle.text
	if type == "submodule" then
		isSubmodule = true
	end
	
	-- Note that the new [[Module:Util]] subpages are a separate concept than the old Utils* modules
	-- They require a different approach. Not sure how best to categorize them yet.
	if title.rootText == "Util" and not isDoc then
		return {}
	end

	if isDoc and isData then
		return {s("cat.dataDoc")}
	end
	if isDoc and isSubmodule then
		return {s("cat.submoduleDoc")}
	end
	if isDoc then
		return  {s("cat.moduleDoc")}
	end
	
	if isData then
		return {s("cat.data")}
	end
	if isSubmodule then
		return {s("cat.submodules")}
	end
	if isUtil then
		return {s("cat.modules"), s("cat.utilityModules")}
	end

	return {s("cat.modules")}
end

i18n.loadStrings({
	en = {
		failingTestsCategory = "Category:Modules with failing tests",
		explainStatusColumn = "Indicates whether a feature is working as expected",
		explainStatusGood = "This feature is working as expected",
		explainStatusBad = "This feature is not working as expected",
		headers = {
			parameters = "Parameters",
			returns = "Returns",
			examples = "Examples",
			input = "Input",
			output = "Output",
			result = "Result",
			categories = "Categories",
			categoriesAdded = "Categories added",
			status = "Status",
		},
		cat = {
			modules = "Category:Modules",
			submodules = "Category:Submodules",
			utilityModules = "Category:Utility Modules",
			moduleDoc = "Category:Module Documentation",
			submoduleDoc = "Category:Submodule Documentation",
			data = "Category:Module Data",
			dataDoc = "Category:Module Data Documentation",
		}
	}
})

function p.Schemas() 
	return {
		Documentation = {
			required = true,
			oneOf = {
				{ _ref = "#/definitions/functions" },
				{ _ref = "#/definitions/sections" },
			},
			definitions = {
				functions = {
					desc = "Map of function names to function documentation. Functions are printed in the order in which they appear in the source code. A function's documentation object has the following properties, depending on whether it is a template function or a module function.",
					type = "map",
					keyPlaceholder = "function name",
					keys = { type = "string" },
					values = {
						oneOf = {
							{
								_tabName = "Module function",
								type = "record",
								desc = "A function which is to be invoked by other modules.",
								properties = {
									{
										name = "wip",
										type = "boolean",
										desc = "Tags the function doc with [[Template:WIP]]."
									},
									{
										name = "desc",
										type = "string",
										desc = "Description of the function. Use only when clarification is needed—usually the param/returns/cases doc speaks for itself.",
									},
									{
										name = "params",
										type = "array",
										items = { type = "string" },
										desc = "An array of parameter names. Integrates with [[Module:Documentation#Schemas|Schemas]].",
									},
									{
										name = "_params",
										type = "array",
										items = {
											type = "array", 
											items = { type = "string" },
										},
										desc = "To be specified for functions with an alternative [[Guidelines:Modules#Higher Order Function|higher-order function]]."
									},
									{
										name = "returns",
										desc = "A string describing the return value of the function, or an array of such strings if the function returns multiple values",
										oneOf = { 
											{ type = "string" },
											{ type = "array", items = { type = "string" } },
										},
									},
									{
										name = "cases",
										desc = "A collection of use cases that double as test cases and documented examples.",
										allOf = {
											{
												_ref = "#/definitions/casesOptions"
											},
											{
												type = "array",
												items = {
													oneOf = {
														["snippet"] = {
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the use case.",
																},
																{
																	name = "snippet",
																	required = true,
																	oneOf = {
																		{ type = "number" },
																		{ type = "string" }
																	},
																	desc = "See [[Module:UtilsTable]] and [[Module:UtilsTable/Documentation/Snippets]] for examples of usage.",
																},
																{
																	name = "expect",
																	type = "any",
																	desc = "<p>The expected return value, which is deep-compared against the actual value to determine pass/fail status. Or, an array of such items if there are multiple return values.</p><p>It is also possible to specify a function here to perform a custom assertion other than a simple equality check. See [[Module:Util/args/parse/Documentation/Spec]] for example.</p>",
																},
															}
														},
														["args"] = {
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the use case.",
																},
																{
																	name = "args",
																	_ref = "#/definitions/args",
																},
																{
																	name = "expect",
																	type = "any",
																	desc ="<p>The expected return value, which is deep-compared against the actual value to determine pass/fail status. Or, an array of such items if there are multiple return values.</p><p>It is also possible to specify a function here to perform a custom assertion other than a simple equality check. See [[Module:Util/args/parse/Documentation/Spec]] for example.</p>",
																},
															},
														},
													}
												},
											}
									},
								},
								},
							},
							{
								_tabName = "Template function",
								type = "record",
								desc = "A function which is to be invoked by templates using the #invoke parser function.",
								properties = {
									{
										name = "wip",
										type = "boolean",
										desc = "Tags the function doc with [[Template:WIP]]."
									},
									{
										name = "desc",
										type = "string",
										desc = "Description of the function - which templates should use it and when.",
									},
									{
										name = "frameParamsFormat",
										type = "string",
										enum = {"singleLine", "multiLine"},
										desc = "Indicates how the #invoke parameters should be laid out.",
									},
									{
										name = "frameParamsOrder",
										desc = "Determines the order that <code>frameParams</code> should appear in.",
										type = "array",
										items = {
											type = "string",
										},
									},
									{
										name = "frameParams",
										desc = "Use this instead of <code>params</code> when documenting a template-based function. See [[Module:Error]] for example.",
										type = "map",
										keys = {
											oneOf = {
												{ type = "number" },
												{ type = "string" }
											},
										},
										values = {
											type = "record",
											properties = {
												{
													name = "name",
													type = "string",
													desc = "Use this to assign names to positional parameters.",
												},
												{
													name = "required",
													type = "boolean",
													desc = "Indicates a required parameter.",
												},
												{
													name = "enum",
													type = "array",
													items = { type = "string" },
													desc = "Indicates which string values are allowed for this parameter.",
												},
												{
													name = "default",
													type = "string",
													desc = "Default value for the parameter."
												},
												{
													name = "desc",
													type = "string",
													desc = "Description of the parameter."
												},
												{
													name = "inline",
													type = "boolean",
													desc = 'If true, then the parameter will be printed on the same line as the previous parameter, even if <code>frameParamsFormat</code> is set to <code>multiLine</code>. See [[Module:Comment]] for example.',
												},
												{
													name = "spaceBefore",
													type = "boolean",
													desc = "If true, adds an extra newline before printing the parameter. See [[Module:Comment]] for a usage example.",
												},
											}
										},
									},
									{
										name = "cases",
										desc = "A collection of use cases that double as test cases and documented examples.",
										allOf = {
											{
												_ref = "#/definitions/casesOptions"
											},
											{
												type = "array",
												items = {
													oneOf = {
														{
															_tabName = "args",
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the example/test case.",
																},
																{
																	name = "args",
																	_ref = "#/definitions/args",
																},
															},
														},
														{
															_tabName = "input",
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the example/test case.",
																},
																{
																	name = "input",
																	type = "string",
																	desc = "Raw input for the example.",
																},
															},
														},
													},
												},
											},
										}
									},
								},
							},
						},
					},
				},
				sections = {
					type = "record",
					properties = {
						{
							desc = "Divides the documentation into sections. See [[Module:UtilsTable]] for a usage example.",
							name = "sections",
							type = "array",
							required = true,
							items = {
								type = "record",
								properties = {
									{
										name = "heading",
										type = "string",
										desc = "Section heading"
									},
									{
										name = "section",
										required = true,
										oneOf = {
											{ type = "string" },
											{
												_ref = "#/definitions/functions",
												required = true,
											},
										},
									}
								}
							},
						},
					},
				},
				args = {
					name = "args",
					allOf = {
						{
							type = "array",
							items = { type = "any" },
						},
						{
							type = "map",
							keys = {
								oneOf = {
									{
										type = "string"
									},
									{
										type = "number"
									},
								},
							},
							values = {
								type = "string"
							},
						},
					},
					desc = "An array of arguments to pass to the function.",
				},
				casesOptions = {
					type = "record",
					properties = {
						{
							name = "resultOnly",
							type = "boolean",
							desc = "When <code>true</code>, displays only rendered wikitext as opposed to raw function output. Useful for functions returning strings of complex wikitext.",
						},
						{
							name = "outputOnly",
							type = "boolean",
							desc = "When <code>true</code>, displays only the raw output of the function (opposite of <code>resultOnly</code>). Enabled by default for functions returning data of type other than <code>string</code>."
						},
					},
				},
			}
		},
	}
end

return p