Module:Introspection utils

From Xenharmonic Wiki
Revision as of 06:04, 29 October 2025 by Ganaram inukshuk (talk | contribs) (add page-existence function; add todo)
Jump to navigation Jump to search
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.

Lua error in Module:Module_introspection at line 145: attempt to call field 'get_and_preprocess_content' (a nil value).

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 template

-- 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).

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

-- 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
-- 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 -----------------------------
-- -----------------------------------------------------------------------------

-- 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:
	-- 1. Convert all newlines to spaces
	-- 2. Collapse multiple spaces/tabs/newlines
	-- 3. Trim leading/trailing whitespace
	local normalized = text_to_scan
		:gsub("[\r\n]", " ")
		:gsub("%s+", " ")
		:match("^%s*(.-)%s*$")
		
	-- Normalize more by removing subst, safesubst, and any extra curly brackets
	normalized = normalized
		:gsub("{{{|safesubst:}}}", "")  -- Remove {{{{|safesubst:}}} if used
		:gsub("{{{|subst:}}}", "")      -- Remove {{{{|subst:}}} if used
		:gsub("{{{{", "{{")             -- Collapse excessive curly brackets
		:gsub("}}}}", "}}")             -- Collapse excessive curly brackets

	return normalized
end

-- 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

-- -----------------------------------------------------------------------------
-- --------------------- EXISTENCE CHECKING FUNCTIONS --------------------------
-- -----------------------------------------------------------------------------

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

return p