Module:Introspection utils

Revision as of 08:30, 30 October 2025 by Ganaram inukshuk (talk | contribs)
Module documentation[view] [edit] [history] [purge]
This module primarily serves as a library for other modules and has no corresponding template.
Module:Introspection utils is ready for use. This message indicates that a module is ready for use, or has recently been repaired. This message may be removed once this module has been used on several pages or once it is verified to work as intended.

Details: Functionally complete.

Introspection summary for Module:Introspection utils 
Functions provided (10)
Line Function Params
21 preprocess_code (raw_code)
66 preprocess_wikitext (raw_wikitext)
104 get_and_preprocess_content (namespace, pagename)
143 find_dependencies (code)
209 find_functions (code)
278 find_invokes (wikitext)
294 make_module_link (raw_module_name)
322 page_exists (fullpagename)
339 invocation_exists (invokes, mod_name)
355 tester none
Lua modules required (0)
Variable Module Functions used

No function descriptions were provided. The Lua code may have further information.

Todo: see TODO in code

-- This module follows [[User:Ganaram inukshuk/Provisional style guide for Lua]]
local p = {}

-- TODO:
-- - Detect whether a template invokes a specific module

-- Common functions for module and template introspection.

-- CURRENT BUGS DEEMED NON-ISSUES AT TIME OF WRITING:
-- If strings contain actual code, they will be treated as part of the code.
-- This is considered a non-issue since no modules should (nor did they at time
-- of writing) contain actual lua code as a literal string (code but enclosed in
-- quotes).

-- -----------------------------------------------------------------------------
-- --------------- CODE EXTRACTION AND PREPROCESSING FUNCTIONS -----------------
-- -----------------------------------------------------------------------------

-- Helper function: preprocess lua code
-- Blanks comments but preserves line numbers
function p.preprocess_code(raw_code)
	if not raw_code then return "" end

	local lines = {}
	local in_multiline = false
	local end_pattern

	for line in raw_code:gmatch("([^\n]*)\n") do
		local processed = line
	
		if in_multiline then
			-- Check for end of multi-line comment first
			local s, e = processed:find(end_pattern)
			if s then
				in_multiline = false
				-- Replace only the comment part with spaces
				processed = string.rep(" ", e) .. processed:sub(e + 1)
			else
				-- Entire line is inside comment
				processed = processed:gsub(".", " ")
			end
		else
			local start_eq = processed:match("%-%-%[(=*)%[")
			if start_eq then
				in_multiline = true
				end_pattern = "%]" .. start_eq .. "%]"
				-- Blank from the start of comment to the end of line
				local s, e = processed:find("%-%-%[" .. start_eq .. "%[")
				if s then
					processed = string.rep(" ", #processed)
				end
			else
				processed = processed:gsub("%-%-.*", function(s)
					return string.rep(" ", #s)
				end)
			end
		end
		table.insert(lines, processed)
	end
	return table.concat(lines, "\n")
end

-- Helper function: preprocess wikitext so that:
-- - Only text inside includeonly tags is captured.
-- - That captured text has normalized spacing and line breaks
function p.preprocess_wikitext(raw_wikitext)
	if not raw_wikitext or raw_wikitext == "" then
		return ""
	end

	-- Gather all includeonly sections, if any exist
	local includeonly_blocks = {}
	for block in raw_wikitext:gmatch("<includeonly>(.-)</includeonly>") do
		table.insert(includeonly_blocks, block)
	end

	local text_to_scan
	if #includeonly_blocks > 0 then
		text_to_scan = table.concat(includeonly_blocks, " ")
	else
		text_to_scan = raw_wikitext
	end

	-- Normalize tags and subst
	text_to_scan = text_to_scan
		:gsub("<noinclude>.-</noinclude>", "")
		:gsub("<noinclude%s*/>", "")
		:gsub("<includeonly>", "")
		:gsub("</includeonly>", "")
		:gsub("<onlyinclude>", "")
		:gsub("</onlyinclude>", "")
		:gsub("{{%s*safesubst:", "{{")
		:gsub("{{%s*subst:", "{{")

	-- Normalize:
	text_to_scan = text_to_scan
		:gsub("[\r\n]", " ")
		:gsub("%s+", " ")
		:match("^%s*(.-)%s*$")

	return text_to_scan
end

function p.get_and_preprocess_content(namespace, pagename)
	local title = mw.title.new(string.format("%s:%s", namespace, pagename))
	local content = title:getContent()
	
	if string.lower(namespace) == "module" then
		return p.preprocess_code(content)
	elseif string.lower(namespace) == "template" then
		return p.preprocess_wikitext(content)
	else
		return ""
	end
end

--[[
function p.get_and_preprocess_content(fullpagename)
	if not fullpagename then return "" end

	local title = mw.title.new(fullpagename)
	local content = title and title:getContent() or ""

	local lowercase_name = string.lower(fullpagename)
	if string.match(lowercase_name, "^module:") then
		return p.preprocess_code(content)
	elseif string.match(lowercase_name, "^template:") then
		return p.preprocess_wikitext(content)
	else
		return ""
	end
end
]]--

-- -----------------------------------------------------------------------------
-- ----------------------- LUA MODULE INTROSPECTION ----------------------------
-- -----------------------------------------------------------------------------

-- Helper function
-- Find dependencies for a module, given a preprocessed module's code, then use
-- that information to find every function call for each dependency.
-- Lua modules are considered as dependencies.
function p.find_dependencies(code)
	
	-- STEP 1
	-- For each require line, get the dependency name (dep), the variable used
	-- for that dependency (var), and, if applicable, the function used from
	-- that dependency.
	local raw_deps = {}		-- Dependencies used; unsorted and to be processed in step 2
	local pattern = [[local%s+([%w_]+)%s*=%s*require%(%s*["']([^"']+)["']%s*%)%.?([%w_%.]*)]]
	for var, dep, direct_func in code:gmatch(pattern) do
		if dep:match("^Module:") then
			if direct_func == "" then direct_func = nil end
			raw_deps[var] = {
				dep = dep,
				direct_func = direct_func
			}
		end
	end
	
	-- STEP 2
	-- For each dependency found, find all function calls that involve that
	-- dependency, as var.func(), or var(), without duplicates. If at least one
	-- function call was found, then that module is used.
	local deps = {}  -- Final array result; to be sorted
	for var, info in pairs(raw_deps) do
		local seen  = {}  -- For detecting whether a function call was already found
		local funcs = {}  -- For tracking all found function calls

		if info.direct_func then
			-- SPECIAL CASE 1: only one function imported from a package
			if code:match(var .. "%s*%(") then
				funcs = { info.direct_func }
			end
		else
			-- EXPECTED CASE: multiple functions from package used
			for func in code:gmatch(var .. "%.([%w_%.]+)%s*%(") do
				if not seen[func] then
					seen[func] = true
					table.insert(funcs, func)
				end
			end

			-- SPECIAL CASE 2: module returns a function and is callable
			if #funcs == 0 and code:match(var .. "%s*%(") then
				funcs = { var }
			end
		end

		-- Add data to table
		table.insert(deps, {
			var   = var,
			dep   = info.dep,
			funcs = funcs
		})
	end
	
	-- STEP 3: Sort alphabetically by dependency name
	table.sort(deps, function(a, b)
		return a.dep:lower() < b.dep:lower()
	end)

	return deps
end

-- Helper function
-- Find functions provided by a module, ignoring nested/local functions.
-- For each function found this way, find the params for that function.
function p.find_functions(code)
	local funcs = {}
	if not code then return funcs end

	local line_num = 0
	local module_name = nil

	-- Functions of the form module_name.func are considered.
	-- module_name is usually p, but it may be something else. Find that package
	-- name, or fall back to p.
	for var in code:gmatch([[local%s+([%w_]+)%s*=%s*{}]]) do
		module_name = var
		break
	end
	module_name = module_name or "p"

	-- Find all functions defined in the module
	for line in code:gmatch("([^\n]*)\n?") do
		line_num = line_num + 1

		-- CASE 1: Match functions defined as: 
		-- function p.name(param1, param2)
		local name, params_str = line:match("function%s+" .. module_name .. "%.([%w_]+)%s*%(([^)]*)%)")
		if name then
			local params = {}
			for param in params_str:gmatch("([%w_]+)") do
				table.insert(params, param)
			end
			table.insert(funcs, { name = name, line = line_num, params = params })
		end

		-- CASE 2: Match functions defined as:
		-- p.name = function(param1, param2)
		name, params_str = line:match(module_name .. "%.([%w_]+)%s*=%s*function%s*%(([^)]*)%)")
		if name then
			local params = {}
			for param in params_str:gmatch("([%w_]+)") do
				table.insert(params, param)
			end
			table.insert(funcs, { name = name, line = line_num, params = params })
		end
	end
	
	-- CASE 3: If no functions were found, check the code again to see whether
	-- it returns a single function.
	if #funcs == 0 then
		local return_line_num = 0
		for line in code:gmatch("([^\n]*)\n?") do
			return_line_num = return_line_num + 1
			local params_str = line:match("return%s+function%s*%(([^)]*)%)")
			if params_str then
				local params = {}
				for param in params_str:gmatch("([%w_]+)") do
					table.insert(params, param)
				end
				table.insert(funcs, { name = nil, line = return_line_num, params = params })
				break
			end
		end
	end

	return funcs
end

-- -----------------------------------------------------------------------------
-- ------------------------ TEMPLATE INTROSPECTION -----------------------------
-- -----------------------------------------------------------------------------

-- Find all invokes in a wikitext string
function p.find_invokes(wikitext)
	if not wikitext or wikitext == "" then
		return {}
	end

	-- Capture all invocations and store them in a table
	local results = {}
	local pattern = "{{%s*#invoke%s*:%s*([^|}%c]+)%s*|%s*([^|}%c]+)"
	for module, func in wikitext:gmatch(pattern) do
		table.insert(results, { module = module, func = func })
	end
	
	return results
end

-- Produce a link to an invoked module
function p.make_module_link(raw_module_name)
	if not raw_module_name or raw_module_name == "" then
		return nil
	end

	-- Trim and replace spaces with underscores
	local raw_module_name = mw.text.trim(raw_module_name):gsub(" ", "_")

	-- Add namespace if missing
	if not raw_module_name:find(":", 1, true) then
		raw_module_name = "Module:" .. raw_module_name
	end

	-- Create a normalized title object
	local title = mw.title.new(raw_module_name)
	if not title then
		return nil -- Invalid title (contains illegal characters)
	end

	-- Format a wiki link: [[Module:Name|Name]]
	return string.format("[[%s|%s]]", title.fullText, title.text)
end

-- -----------------------------------------------------------------------------
-- ------------------ FUNCTION AND PAGE CHECKING FUNCTIONS ---------------------
-- -----------------------------------------------------------------------------

-- Check whether a page exists
function p.page_exists(fullpagename)
	local title = mw.title.new(fullpagename)
	
	return title and title.exists
end

local function normalize_module_name(name)
	if not name then return "" end
	-- Trim, replace spaces with underscores, remove namespace
	name = mw.text.trim(name)
	name = name:gsub(" ", "_")
	name = name:gsub("^[Mm]odule:", "")
	return name
end

-- Check whether a template invokes a function from a module. Input is a table
-- of invocations, from find_invokes, and mod_name is the module name.
function p.invocation_exists(invokes, mod_name)
	if type(invokes) ~= "table" then
		return false
	end
	
	local target_mod = normalize_module_name(mod_name)

	for _, invoke in ipairs(invokes) do
		if normalize_module_name(invoke["module"]) == target_mod then
			return true
		end
	end

	return false
end

function p.tester()
	
	local wikitext = p.get_and_preprocess_content("Template", "MOS intro")
	local invokes = p.find_invokes(wikitext)
	
	return p.invocation_exists(invokes, "MOS intro")
end

return p