Zelda Wiki

Want to contribute to this wiki?
Sign up for an account, and get started!

Come join the Zelda Wiki community Discord server!

READ MORE

Zelda Wiki
Register
Advertisement

local p = {}
local h = {}

local i18n = require("Module:I18n")
local s = i18n.getString
local lex = require("Module:Documentation/Lexer")
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 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
end

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

function p.Module(frame)
	local modulePage, subpage = getModulePage(frame)
	local categories = utilsMarkup.categories(h.getCategories(frame.args.type))
	if subpage == "Data" then
		return p.dataDoc(modulePage) .. categories
	end
	return p.moduleDoc(modulePage) .. categories
end

function p.dataDoc(modulePage)
	local module = require(modulePage)
	local tabs = {}
	if type(module.Data) == "function" then
		table.insert(tabs, {
			label = "Data",
			content = module.Data(mw.getCurrentFrame())
		})
	end
	if module.Schemas and module.Schemas.Data then
		table.insert(tabs, {
			label = "Schema",
			content = p.schema(module.Schemas.Data, "Data")
		})
	end
	return utilsLayout.tabs(tabs, { collapse = true })
end

function p.schema(schema, schemaName)
	local definitions = utilsSchema.getTypeDefinitions(schema, schemaName or "", function(keyDef)
		local key = keyDef.key
		local symbolicType = keyDef.symbolicType
		local rawType = keyDef.rawType
		local typeLabel = keyDef.typeLabel
		local desc = keyDef.desc
		local subkeys = keyDef.subkeys
		local parentType = keyDef.parentType
		local isSubschema = keyDef.isSubschema
		
		if schemaName == "..." and not parentType then
			symbolicType = "vararg" .. symbolicType
		end
		
		symbolicType = mw.text.nowiki(symbolicType)
		
		if subkeys and subkeys.allOf then
			subkeys = utilsTable.concat(subkeys, utilsTable.flatten(subkeys.allOf))
			subkeys.allOf = nil
		end
		
		if isSubschema then
			if not desc and not subkeys then
				return nil
			end
			local definition = utilsTable.flatten(subkeys or {})
			if desc then
				table.insert(definition, 1, {nil, desc})
			end
			return {{
				label = typeLabel or symbolicType,
				tooltip = utilsMarkup.code(symbolicType),
				content = utilsMarkup.definitionList(definition)
			}}
		end
		if subkeys and subkeys.oneOf and #subkeys.oneOf > 0 then
			local tabData = utilsTable.uniqueBy("content")(utilsTable.flatten(subkeys.oneOf))
			if #tabData == 1 then
				subkeys = utilsTable.concat(subkeys, tabData[1].content)
			else
				subkeys = utilsTable.concat({utilsLayout.tabs(tabData)}, subkeys)
			end
			subkeys.oneOf = nil
		end

		if key and key ~= "" then
			key = tostring(mw.html.create("span")
				:css("color", "#f8f8f2")
				:wikitext(utilsMarkup.code(key))
			)
			key = utilsMarkup.link("Module:Documentation/Documentation#Schemas", key)
			local type = typeLabel or symbolicType
			key = utilsMarkup.tooltip(key, utilsMarkup.code(type))
		end
		
		definition = {key}
		if desc then
			table.insert(definition, desc)
		end
		if subkeys then
			definition = utilsTable.concat(definition, subkeys)
		end
		if parentType == utilsSchema.TYPES.record then
			definition = {definition}
		end
		
		return definition
	end)
	if not schemaName then
		definitions = utilsTable.flatten(utilsTable.tail(definitions))
		definitions = {{nil, definitions}}
	else
		definitions = {definitions}
	end
	local definitionsList = utilsMarkup.definitionList(definitions)
	return definitionsList
end

function p.moduleDoc(modulePage, section)
	local headingLevel = section and 3 or 2
	local module, doc = h.resolveDoc(modulePage, section)
	if not doc then
		return ""
	end
	local output = ""
	if not section then
		output = "__TOC__\n"
	end
	for _, functionDoc in ipairs(doc.functions) do
		output = output .. utilsMarkup.heading(headingLevel, functionDoc.name) .. "\n"
		if functionDoc.wip then
			output = output .. frame:expandTemplate({title = "UC"}) .. "\n"
		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)
		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, section.section) .. "\n"
			else
				output = output .. p.moduleDoc(sectionModule, section.section)
			end
		end
	end
	return output
end

function h.resolveDoc(modulePage, section)
	local module = require(modulePage)
	local doc
	if type(section) == "table" then
		doc = section
	elseif type(module) == "table" then
		doc = module.Documentation
	end
	if not doc then
		return nil
	end
	local err = utilsSchema.validate(p.Schemas.Documentation, "Documentation", doc, "p.Documentation")
	if err then
		mw.logObject(err)
	end
	if doc.sections then
		doc.functions = {}
		return module, doc
	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 = {}
	for _, functionName in ipairs(functionNames) do
		table.insert(functions, h.resolveFunctionDoc(module, functionName, doc[functionName]))
		doc[functionName] = nil
	end
	doc.functions = functions
	return module, doc
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, functionName, functionDoc)
	functionDoc.name = functionName
	functionDoc.fn = module[functionDoc.name]
	functionDoc.cases = functionDoc.cases or {}
	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 = module.Schemas and module.Schemas[functionDoc.name] or {}
	local resolvedParams = utilsTable.map(functionDoc.params, 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)
	end
	functionDoc.params = {resolvedParams}
	for _, case in ipairs(functionDoc.cases) do
		case.args = {case.args}
	end
	return functionDoc
end

function h.printFunctionDoc(functionDoc)
	local result = ""
	result = result .. h.printFunctionSyntax(functionDoc)
	result = result .. h.printFunctionDescription(functionDoc)
	result = result .. h.printParamsDescription(functionDoc)
	result = result .. h.printReturnsDescription(functionDoc.returns)
	result = result .. h.printFunctionCases(functionDoc)
	return result
end

function h.printFunctionSyntax(functionDoc)
	local result = functionDoc.name
	for _, params in ipairs(functionDoc.params) do
		result = result .. "(" .. h.printParamsSyntax(params) .. ")"
	end
	return utilsMarkup.code(result) .. "\n"
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.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 = ";" .. s("headers.parameters") .. "\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;" .. s("headers.returns") .. "\n"
	local result = heading .. mw.getCurrentFrame():preprocess(returnsList)
	return result
end

function h.printFunctionCases(doc)
	if not doc.cases or #doc.cases == 0 then
		return ""
	end
	local result = "\n;" .. s("headers.examples") .. "\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, 
		statusColumn,
	})
	local tableData = {
		hideEmptyColumns = true,
		rows = {
			{
				header = true,
				cells = headerCells,
			}
		},
	}
	for _, case in ipairs(doc.cases) do
		local caseRows = h.case(doc, case, doc.cases)
		tableData.rows = utilsTable.concat(tableData.rows, caseRows)
	end
	result = result .. utilsLayout.table(tableData) .. "\n"
	return result
end

local snippets = {}
(function()
	local snippetPagename = mw.getCurrentFrame():getParent():getTitle() .. "/Snippets"
	if not utilsPage.exists(snippetPagename) then
		return nil
	end
	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, "(", true)
			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
end)()

function h.case(doc, case, options)
	local rows = {}
	local input, outputs 
	local snippet = case.snippet and snippets[doc.name .. case.snippet]
	if snippet then
		input = utilsMarkup.lua(snippet.code)
		outputs = {snippet.fn()}
	elseif case.args then
		input = h.printInput(doc, case.args)
		outputs = h.evaluateFunction(doc.fn, case.args)
	else
		return {}
	end
	local expected = case.expect or {}
	
	for i = 1, #doc.returns do
		local outputData, resultData, statusData = h.evaluateOutput(outputs[i], expected[i])
		table.insert(rows, utilsTable.compact({
			not options.resultOnly and outputData or nil,
			not options.outputOnly and resultData or nil,
			statusData
		}))
	end
	
	table.insert(rows[1], 1, {
		content = input,
		rowspan = #doc.returns,
	})
	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.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] and utilsTable.print(args[i]) or "nil"
		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 (but keep the first one).
	local argsText = utilsTable.dropRightWhile(function(argText, i)
		return i > 1 and argText == "nil" and params[i] and params[i].schema and not params[i].schema.required
	end)(argsText)
	
	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 = ""
	if type(output) == "string" then
		resultData = utilsMarkup.killBacklinks(output)
		resultData, categories = utilsMarkup.stripCategories(output)
		if (#categories > 0) then
			local categoryList = utilsMarkup.bold(s('headers.categoriesAdded')) .. utilsMarkup.bulletList(categories) 
			resultData = {
				{resultData},
				{categoryList},
			}
		end
	end
	if type(expected) == "string" then
		expected = string.gsub(expected, "\t", "")
	end
	local passed = utilsTable.isEqual(expected, output)
	local statusData = (expected ~= nil or output == nil) and h.printStatus(passed) or nil
	if statusData and not passed then
		local expectedOutput = h.formatValue(expected)
		outputData = utilsLayout.table({
			hideEmptyColumns = true,
			styles = { width = "100%" },
			rows = {
				{ 
					{ 
						header = true, 
						content = "Expected", 
						styles = { width = "1em"}, -- "shrink-wraps" this column
					}, 
					{ content = expectedOutput },
				},
				{
					{ header = true, content = "Actual" }, 
					{ content = formattedOutput },
				},
			}
		})
	end
	return outputData, resultData, statusData
end

function h.formatValue(val)
	if type(val) == "string" then
		val = string.gsub(val, "&#", "&#38;#") -- show entity codes
		val = utilsTable.print(val)
		val = string.gsub(val, "\n", "\\n\n") -- Show newlines	
		return utilsMarkup.pre(val) 
	end
	return utilsMarkup.lua(val)
end

function h.printStatus(success)
	local img = success and "[[File:Green check.svg|16px|center|link=]]" or "[[File:TFH Red Link desperate.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 or isData) 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

	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",
		}
	}
})

p.Schemas = {
	Documentation = {
		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.",
				type = "map",
				keys = { type = "string" },
				values = {
					type = "record",
					properties = {
						{
							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",
							required = true,
							type = "array",
							items = { type = "string" },
							desc = "An array of parameter names. Integrates with [[Module:Schema#Functions|Module:Schema]].",
						},
						{
							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, plus a couple flags.",
							allOf = {
								{
									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>."
										},
									},
								},
								{
									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]] for examples of usage.",
													},
													{
														name = "expect",
														type = "any",
														desc = "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.",
													},
												}
											},
											["args"] = {
												type = "record",
												properties = {
													{
														name = "desc",
														type = "string",
														desc = "A description of the use case.",
													},
													{
														name = "args",
														type = "array",
														items = { type = "any" },
														desc = "An array of arguments to pass to the function.",
													},
													{
														name = "expect",
														type = "any",
														desc = "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.",
													},
												},
											},
										}
									},
								}
							},
						},
						{
							name = "wip",
							type = "boolean",
							desc = "Tags the function doc with [[Template:UC]]."
						}
					},
				},
			},
			sections = {
				type = "record",
				properties = {
					{
						name = "sections",
						type = "array",
						required = true,
						items = {
							type = "record",
							properties = {
								{
									name = "heading",
									type = "string",
								},
								{
									name = "section",
									required = true,
									oneOf = {
										{ type = "string" },
										{
											_ref = "#/definitions/functions",
											required = true,
											_hideSubkeys = true,
										},
									},
								}
							}
						},
					},
				},
			},
		}
	},
}

return p
Advertisement