Jump to content
Toggle menu
  • 1 articles
  • 7 files
  • 3 users
  • 102 edits
Shroudforge
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
Revision as of 22:22, 4 November 2026 by Bonsaibauer (talk | contribs) (1 revision imported: Import Star Citizen Modules)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:Dependencies/doc

require('strict')

--- @module Dependencies
--- The folded "Technical details" panel Module:Documentation shows on module and
--- template pages: which pages use the page, what it uses, and the functions a
--- module defines; and the `dependencies` Bucket row each page writes so the
--- modules and stylesheets it uses can list it. Forward lists come from the
--- page's own source; reverse lists come from the rows other pages wrote.

local parse = require('Module:Dependencies/Parse')
local mbox = require('Module:Mbox')
local icon = require('Module:Icon')
local yesno = require('Module:Yesno')

local p = {}

local BUCKET = 'dependencies'
local NS_TEMPLATE = 10
local NS_MODULE = 828
local HEADING = 'Technical details'
local ICON = 'WikimediaUI-Code.svg'
local ROW_ICONS = {
	source = 'CdxIconReference.svg',
	usedBy = 'CdxIconArrowPrevious.svg',
	uses = 'CdxIconArrowNext.svg',
	functions = 'CdxIconFunction.svg',
}

local UNUSED_TEXT = 'No template invokes it and no module requires or loads it. '
	.. 'If an article invokes it directly, call it through a template instead.'
local UNAVAILABLE_TEXT = 'Unavailable: looking up the pages that use it failed on this render. '
	.. 'Purge the page to retry.'

--- @class DependenciesContext
--- @field title string the documented page, e.g. `Module:Mbox`
--- @field prefix string its namespace with the colon, e.g. `Module:`
--- @field namespace integer
--- @field content string|nil its source
--- @field isCurrent boolean the render is the page itself, not its /doc or another page
--- @field excluded boolean a sandbox or testcases page, whose dependencies are not real usage

--- @class DependenciesGroup
--- @field label string e.g. `Required by`
--- @field items string[] wikitext, one per list item
--- @field note string|nil wikitext under the list
--- @field modifier string|nil the list's `t-dependencies__list--` modifier

--- @class DependenciesReport
--- @field usedBy DependenciesGroup[]|nil nil when the reverse lookup failed
--- @field uses DependenciesGroup[]
--- @field usedByCount integer distinct pages across `usedBy`
--- @field usesCount integer entries across `uses`
--- @field unused boolean a module nothing invokes, requires or loads
--- @field functions ModuleTocFunction[]

--- @class DependenciesSource
--- @field label string plain text for the folded header, e.g. `wiki-tools`
--- @field text string wikitext, one line of the Source row

--- @class DependenciesPanelOptions
--- @field page string|nil the page to describe; default the current page, or the page a /doc documents
--- @field addCategories boolean|string|nil defaults to true except on a /doc render
--- @field sources DependenciesSource[]|nil

--- @param title table mw.title
--- @return boolean
local function isExcluded(title)
	local text = title.text:lower()
	return text:find('^sandbox/') ~= nil or text:find('/sandbox') ~= nil or text:find('/testcases') ~= nil
end

--- @param pageName string|nil
--- @return DependenciesContext|nil
local function context(pageName)
	local current = mw.title.getCurrentTitle()
	local target = pageName and mw.title.new(pageName) or current
	if target == nil then
		return nil
	end
	if target.isSubpage and target.subpageText:lower() == 'doc' then
		target = target.basePageTitle
	end
	if target.namespace ~= NS_TEMPLATE and target.namespace ~= NS_MODULE then
		return nil
	end
	return {
		title = target.prefixedText,
		prefix = target.prefixedText:match('^[^:]+:'),
		namespace = target.namespace,
		content = target:getContent(),
		isCurrent = current.prefixedText == target.prefixedText,
		excluded = isExcluded(target),
	}
end

--- @param list any
--- @param value string
--- @return boolean
local function contains(list, value)
	for _, item in ipairs(type(list) == 'table' and list or {}) do
		if item == value then
			return true
		end
	end
	return false
end

--- `files` with the files `extra` names added.
--- @param files DependenciesFiles
--- @param extra DependenciesFiles
--- @return DependenciesFiles
local function mergeFiles(files, extra)
	local merged = { names = {}, urls = {}, dynamic = {} }
	for _, key in ipairs({ 'names', 'dynamic' }) do
		local seen = {}
		for _, list in ipairs({ files[key], extra[key] }) do
			for _, value in ipairs(list) do
				if not seen[value] then
					seen[value] = true
					merged[key][#merged[key] + 1] = value
				end
			end
		end
		table.sort(merged[key])
	end
	local seen = {}
	for _, list in ipairs({ files.urls, extra.urls }) do
		for _, url in ipairs(list) do
			local key = url.name .. '|' .. url.path
			if not seen[key] then
				seen[key] = true
				merged.urls[#merged.urls + 1] = url
			end
		end
	end
	return merged
end

--- What the page itself uses: a module's requires, loads, stylesheets and
--- files, a template's invokes, stylesheets and files. The files include those
--- the page's own `/styles.css` names when the page loads it; any other
--- stylesheet lists its files on the page that owns it.
--- @param ctx DependenciesContext
--- @return table
local function forward(ctx)
	local found
	if ctx.namespace == NS_MODULE then
		found = parse.lua(ctx.content)
		found.invokes = {}
	else
		found = {
			requires = {},
			loads = {},
			dynamic = {},
			strict = false,
			invokes = parse.invokes(ctx.content),
			styles = parse.styles(ctx.content),
			files = parse.files(ctx.content),
		}
	end
	local own = ctx.title .. '/styles.css'
	local stylesheet = contains(found.styles, own) and mw.title.new(own)
	local css = stylesheet and stylesheet:getContent()
	if css then
		found.files = mergeFiles(found.files, parse.stylesheetFiles(css))
	end
	return found
end

--- The page's `dependencies` row, or nil when it uses nothing.
--- @param found table
--- @return table|nil
local function rowFor(found)
	local invokes, calls, seen = {}, {}, {}
	for _, call in ipairs(found.invokes) do
		if not seen[call.module] then
			seen[call.module] = true
			invokes[#invokes + 1] = call.module
		end
		calls[#calls + 1] = call.module .. '|' .. call.func
	end
	local row = {}
	local function set(field, list)
		if list[1] ~= nil then
			row[field] = list
		end
	end
	set('requires', found.requires)
	set('loads', found.loads)
	set('invokes', invokes)
	set('invoke_calls', calls)
	set('styles', found.styles)
	if next(row) == nil then
		return nil
	end
	return row
end

--- @param ctx DependenciesContext
--- @param found table
local function write(ctx, found)
	if not ctx.isCurrent or ctx.excluded then
		return
	end
	local row = rowFor(found)
	if row == nil then
		return
	end
	pcall(function()
		mw.ext.bucket(BUCKET).put(row)
	end)
end

--- Splits the rows naming `title` into the pages that invoke, require and load
--- it, and the pages that load its `/styles.css`.
--- @param rows table[] raw Bucket rows keyed by field name (page_name, requires, loads, invokes, invoke_calls, styles)
--- @param title string
--- @return { invokedBy: { page: string, funcs: string[] }[], requiredBy: string[], loadedBy: string[], styledBy: string[] }
local function group(rows, title)
	local back = { invokedBy = {}, requiredBy = {}, loadedBy = {}, styledBy = {} }
	local prefix = title .. '|'
	local stylesheet = title .. '/styles.css'
	for _, row in ipairs(rows) do
		local page = row.page_name
		if page and page ~= title then
			if contains(row.invokes, title) then
				local funcs, seen = {}, {}
				for _, call in ipairs(type(row.invoke_calls) == 'table' and row.invoke_calls or {}) do
					local func = call:sub(1, #prefix) == prefix and call:sub(#prefix + 1) or nil
					if func and not seen[func] then
						seen[func] = true
						funcs[#funcs + 1] = func
					end
				end
				back.invokedBy[#back.invokedBy + 1] = { page = page, funcs = funcs }
			end
			if contains(row.requires, title) then
				back.requiredBy[#back.requiredBy + 1] = page
			end
			if contains(row.loads, title) then
				back.loadedBy[#back.loadedBy + 1] = page
			end
			if contains(row.styles, stylesheet) then
				back.styledBy[#back.styledBy + 1] = page
			end
		end
	end
	table.sort(back.invokedBy, function(a, b)
		return a.page < b.page
	end)
	table.sort(back.requiredBy)
	table.sort(back.loadedBy)
	table.sort(back.styledBy)
	return back
end

--- The pages that use `title`, or nil when the lookup failed.
--- Queries Bucket directly: Module:BucketQuery would load and link every
--- manifest registered ahead of this one on each documentation render.
--- @param title string
--- @return table|nil
local function reverse(title)
	local ok, rows = pcall(function()
		local bucket = mw.ext.bucket
		return bucket(BUCKET)
			.select('page_name', 'requires', 'loads', 'invokes', 'invoke_calls', 'styles')
			.where(
				bucket.Or(
					{ 'requires', title },
					{ 'loads', title },
					{ 'invokes', title },
					{ 'styles', title .. '/styles.css' }
				)
			)
			.limit(5000)
			.run()
	end)
	if not ok or type(rows) ~= 'table' then
		return nil
	end
	return group(rows, title)
end

--- @param text string
--- @return string
local function code(text)
	return '<code>' .. mw.text.nowiki(text) .. '</code>'
end

--- A link to `name` as a row shows it: one of the documented page's own subpages
--- relative (`/styles.css`), otherwise without `prefix`, the namespace the row
--- already implies.
--- @param name string
--- @param ctx DependenciesContext
--- @param prefix string
--- @return string
local function link(name, ctx, prefix)
	local own = ctx.title .. '/'
	local text = name
	if name:sub(1, #own) == own then
		text = name:sub(#own)
	elseif name:sub(1, #prefix) == prefix then
		text = name:sub(#prefix + 1)
	end
	if text == name then
		return '[[' .. name .. ']]'
	end
	return '[[' .. name .. '|' .. text .. ']]'
end

--- @param names string[]
--- @param ctx DependenciesContext
--- @param prefix string
--- @return string[]
local function links(names, ctx, prefix)
	local items = {}
	for i, name in ipairs(names) do
		items[i] = link(name, ctx, prefix)
	end
	return items
end

--- The templates that invoke the page, each with the functions it calls. When
--- every one calls the same single function, the note names it once instead.
--- @param entries { page: string, funcs: string[] }[]
--- @param ctx DependenciesContext
--- @return string[] items
--- @return string|nil note
local function invokers(entries, ctx)
	local shared = entries[1] and entries[1].funcs[1]
	for _, entry in ipairs(entries) do
		if #entry.funcs ~= 1 or entry.funcs[1] ~= shared then
			shared = nil
			break
		end
	end
	local items = {}
	for i, entry in ipairs(entries) do
		items[i] = link(entry.page, ctx, 'Template:')
		if shared == nil and entry.funcs[1] ~= nil then
			local funcs = {}
			for j, func in ipairs(entry.funcs) do
				funcs[j] = code(func)
			end
			items[i] = items[i] .. ' (' .. table.concat(funcs, ', ') .. ')'
		end
	end
	if shared == nil then
		return items, nil
	end
	return items, (#entries > 1 and 'All via ' or 'Via ') .. code(shared)
end

--- @param into DependenciesGroup[]
--- @param label string
--- @param items string[]
--- @param note string|nil
--- @param modifier string|nil
local function addGroup(into, label, items, note, modifier)
	if items[1] ~= nil then
		into[#into + 1] = { label = label, items = items, note = note, modifier = modifier }
	end
end

--- What is wrong with each media URL, by file name: a path that is not the
--- md5 directories of the name, or a file that has moved, which `[[File:]]`
--- follows but a URL does not.
--- @param urls DependenciesFileUrl[]
--- @return table<string, string[]>
local function urlProblems(urls)
	local problems, checked = {}, {}
	local function add(name, text)
		problems[name] = problems[name] or {}
		problems[name][#problems[name] + 1] = text
	end
	for _, url in ipairs(urls) do
		local md5 = mw.hash.hashValue('md5', (url.name:gsub(' ', '_')))
		local expected = md5:sub(1, 1) .. '/' .. md5:sub(1, 2)
		if url.path ~= expected then
			add(url.name, 'URL path ' .. code(url.path) .. ' should be ' .. code(expected))
		end
		if not checked[url.name] then
			checked[url.name] = true
			-- isRedirect is an expensive lookup: past the page's limit it throws.
			local ok, target = pcall(function()
				local title = mw.title.new('File:' .. url.name)
				return title and title.isRedirect and title.redirectTarget
			end)
			if ok and target then
				add(url.name, 'moved to [[:File:' .. target.text .. '|' .. target.text .. ']], update the URL')
			end
		end
	end
	return problems
end

--- The Files group's items. On the page itself each name follows a thumbnail,
--- which records the file as used by the page; anywhere else (its /doc, a
--- sandbox) the name alone, so only the page whose source names a file is
--- recorded as using it.
--- @param files DependenciesFiles
--- @param ctx DependenciesContext
--- @return string[]
local function fileItems(files, ctx)
	local thumbnails = ctx.isCurrent and not ctx.excluded
	local problems = urlProblems(files.urls)
	local items = {}
	for _, name in ipairs(files.names) do
		local item = '[[:File:' .. name .. '|' .. name .. ']]'
		if thumbnails then
			-- The wiki recolours most icons, so an SVG shows as a silhouette in the text
			-- colour: styles.css blackens it and `skin-invert` whitens it on a dark theme.
			local class = name:lower():find('%.svg$') and 't-dependencies__thumb t-dependencies__thumb--svg skin-invert'
				or 't-dependencies__thumb'
			item = '<span class="' .. class .. '">[[File:' .. name .. '|20x20px|link=|alt=]]</span>' .. item
		end
		if problems[name] then
			item = item
				.. ' <span class="t-dependencies__warning">('
				.. table.concat(problems[name], '; ')
				.. ')</span>'
		end
		items[#items + 1] = item
	end
	for _, pattern in ipairs(files.dynamic) do
		items[#items + 1] = code(pattern)
	end
	return items
end

--- @param back table
--- @return integer
local function countPages(back)
	local seen, count = {}, 0
	local function add(page)
		if not seen[page] then
			seen[page] = true
			count = count + 1
		end
	end
	for _, entry in ipairs(back.invokedBy) do
		add(entry.page)
	end
	for _, key in ipairs({ 'requiredBy', 'loadedBy', 'styledBy' }) do
		for _, page in ipairs(back[key]) do
			add(page)
		end
	end
	return count
end

--- What the panel shows for the page, before any markup.
--- @param ctx DependenciesContext
--- @param found table forward()'s result
--- @param back table|nil reverse()'s result; nil when the lookup failed
--- @return DependenciesReport
local function report(ctx, found, back)
	local uses, usesCount = {}, 0
	if ctx.namespace == NS_TEMPLATE then
		local calls = {}
		for i, call in ipairs(found.invokes) do
			calls[i] = code(call.func) .. ' in [[' .. call.module .. ']]'
		end
		addGroup(uses, 'Invokes', calls)
		usesCount = #calls
	else
		local requires = links(found.requires, ctx, 'Module:')
		for _, pattern in ipairs(found.dynamic) do
			requires[#requires + 1] = code(pattern)
		end
		addGroup(uses, 'Requires', requires)
		addGroup(uses, 'Loads', links(found.loads, ctx, 'Module:'))
		usesCount = #requires + #found.loads
	end
	addGroup(uses, 'Styles', links(found.styles, ctx, ctx.prefix))
	addGroup(uses, 'Files', fileItems(found.files, ctx), nil, 'files')
	usesCount = usesCount + #found.styles + #found.files.names + #found.files.dynamic

	local result = {
		uses = uses,
		usesCount = usesCount,
		usedByCount = 0,
		unused = false,
		functions = {},
	}
	if back ~= nil then
		local usedBy = {}
		local items, note = invokers(back.invokedBy, ctx)
		addGroup(usedBy, 'Invoked by', items, note)
		addGroup(usedBy, 'Required by', links(back.requiredBy, ctx, 'Module:'))
		addGroup(usedBy, 'Loaded by', links(back.loadedBy, ctx, 'Module:'))
		addGroup(usedBy, 'Styles used by', links(back.styledBy, ctx, ctx.prefix))
		result.usedBy = usedBy
		result.usedByCount = countPages(back)
		result.unused = ctx.namespace == NS_MODULE
			and not ctx.excluded
			and back.invokedBy[1] == nil
			and back.requiredBy[1] == nil
			and back.loadedBy[1] == nil
	end
	if ctx.namespace == NS_MODULE then
		-- Required here, not at the top: every page records the modules it loads
		-- and re-parses when one changes, and template pages never need this one.
		result.functions = require('Module:Module toc').functions(ctx.content)
	end
	return result
end

--- @param ctx DependenciesContext
--- @param found table
--- @param result DependenciesReport
--- @return string
local function categories(ctx, found, result)
	local out = {}
	if ctx.namespace == NS_TEMPLATE and found.invokes[1] ~= nil then
		out[#out + 1] = '[[Category:Lua-based templates]]'
	end
	if ctx.namespace == NS_MODULE and found.strict then
		out[#out + 1] = '[[Category:Strict mode modules]]'
	end
	if result.unused then
		out[#out + 1] = '[[Category:Unused modules]]'
	end
	return table.concat(out)
end

--- @param addCategories boolean|string|nil
--- @return boolean
local function categoriesEnabled(addCategories)
	-- Yesno's `default` only covers an unrecognised string, not a nil argument
	-- (a #invoke omitting the parameter, or a Lua caller passing nothing).
	local enabled = yesno(addCategories)
	if enabled == nil then
		local current = mw.title.getCurrentTitle()
		return not (current.isSubpage and current.subpageText:lower() == 'doc')
	end
	return enabled == true
end

--- @param items string[]
--- @param modifier string|nil
--- @return table mw.html
local function list(items, modifier)
	local root = mw.html.create('ul'):addClass('t-dependencies__list')
	if modifier then
		root:addClass('t-dependencies__list--' .. modifier)
	end
	for _, item in ipairs(items) do
		root:tag('li'):wikitext(item)
	end
	return root
end

--- @param groups DependenciesGroup[]
--- @return string
local function groupsHtml(groups)
	local root = mw.html.create('div'):addClass('t-dependencies__groups')
	for _, entry in ipairs(groups) do
		root:tag('div')
			:addClass('t-dependencies__sublabel')
			:wikitext(entry.label)
			:tag('span')
			:addClass('t-dependencies__count')
			:wikitext(#entry.items)
		local body = root:tag('div'):addClass('t-dependencies__items'):node(list(entry.items, entry.modifier))
		if entry.note then
			body:tag('div'):addClass('t-dependencies__note'):wikitext(entry.note)
		end
	end
	return tostring(root)
end

--- @param title string
--- @param functions ModuleTocFunction[]
--- @return string
local function functionsHtml(title, functions)
	local items = {}
	for i, func in ipairs(functions) do
		items[i] = string.format(
			'[[%s#L-%d|%s]]<span class="t-dependencies__line">L%d</span>',
			title,
			func.line,
			mw.text.nowiki(func.name),
			func.line
		)
	end
	return tostring(list(items, 'code'))
end

--- @param label string
--- @param file string
--- @param content string wikitext
--- @param modifier string|nil
--- @return string
local function row(label, file, content, modifier)
	local root = mw.html.create('div'):addClass('t-dependencies__row')
	if modifier then
		root:addClass('t-dependencies__row--' .. modifier)
	end
	root:tag('div')
		:addClass('t-dependencies__label')
		:wikitext(icon.render({ icon = file, mask = true, size = '1em', class = 't-dependencies__icon' }))
		:wikitext(label)
	root:tag('div'):addClass('t-dependencies__content'):wikitext(content)
	return tostring(root)
end

--- The folded header: the heading, then plain-text facts. It sits inside
--- <summary>, so it must hold no link.
--- @param facts string[]
--- @param unused boolean
--- @return string
local function summary(facts, unused)
	local root = mw.html.create('span'):addClass('t-dependencies__summary')
	root:tag('span'):addClass('t-dependencies__heading'):wikitext(HEADING)
	if unused or facts[1] ~= nil then
		local meta = root:tag('span'):addClass('t-dependencies__meta')
		if unused then
			meta:tag('span'):addClass('t-dependencies__chip'):wikitext('Unused')
		end
		if facts[1] ~= nil then
			meta:tag('span'):wikitext(table.concat(facts, ' · '))
		end
	end
	return tostring(root)
end

--- The panel for `options.page`, followed by its categories. Writes the page's
--- row when the render is the page itself.
--- @param options DependenciesPanelOptions|nil
--- @return string
function p.panel(options)
	options = options or {}
	local rows, facts, unused, cats = {}, {}, false, ''
	local lines = {}
	for _, source in ipairs(options.sources or {}) do
		lines[#lines + 1] = '<div>' .. source.text .. '</div>'
		facts[#facts + 1] = source.label
	end
	if lines[1] ~= nil then
		rows[#rows + 1] = row('Source', ROW_ICONS.source, table.concat(lines))
	end

	local ctx = context(options.page)
	if ctx ~= nil and ctx.content ~= nil then
		local found = forward(ctx)
		write(ctx, found)
		local back = reverse(ctx.title)
		local result = report(ctx, found, back)
		unused = result.unused
		if unused then
			local content = '<div>' .. UNUSED_TEXT .. '</div>'
			if result.usedBy[1] ~= nil then
				content = content .. groupsHtml(result.usedBy)
			end
			rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, content, 'warning')
		elseif back == nil then
			if ctx.namespace == NS_MODULE then
				rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, UNAVAILABLE_TEXT, 'muted')
			end
		elseif result.usedBy[1] ~= nil then
			rows[#rows + 1] = row('Used by', ROW_ICONS.usedBy, groupsHtml(result.usedBy))
			facts[#facts + 1] = 'used by ' .. result.usedByCount
		end
		if result.uses[1] ~= nil then
			rows[#rows + 1] = row('Uses', ROW_ICONS.uses, groupsHtml(result.uses))
			facts[#facts + 1] = 'uses ' .. result.usesCount
		end
		if result.functions[1] ~= nil then
			rows[#rows + 1] = row('Functions', ROW_ICONS.functions, functionsHtml(ctx.title, result.functions))
		end
		if categoriesEnabled(options.addCategories) then
			cats = categories(ctx, found, result)
		end
	end

	if rows[1] == nil then
		return cats
	end
	local frame = mw.getCurrentFrame()
	-- Module:Icon leaves its stylesheet to the caller; the row icons need it.
	local styles = frame:extensionTag({ name = 'templatestyles', args = { src = 'Module:Icon/styles.css' } })
		.. frame:extensionTag({ name = 'templatestyles', args = { src = 'Module:Dependencies/styles.css' } })
	return styles
		.. mbox.render({
			title = summary(facts, unused),
			text = table.concat(rows),
			icon = ICON,
			class = 't-dependencies',
		})
		.. cats
end

--- `{{#invoke:Dependencies|main}}`, for MediaWiki:Scribunto-doc-page-does-not-exist.
--- @param frame table
--- @return string
function p.main(frame)
	return p.panel()
end

-- Test-only exports. Not part of the public API.
p._internal = {
	group = group,
	report = report,
	rowFor = rowFor,
}

return p
Last modified
Copyright