-- 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
-- Check whether a template invokes a function from a module.
-- If no function name is provided, it will match any function name.
-- Wikitext is assumed to have been preprocessed.
function p.invocation_exists(wikitext, mod_name, func_name)
local func_name = func_name or "[%w_%-]+" -- Default function name (pattern for any function name)
-- Normalize module name according to how #invoke works
local function normalize_module_name(mod_name)
if not mod_name then return nil end
mod_name = mw.text.trim(mod_name) -- Trim whitespace
mod_name = mod_name:gsub(" ", "_") -- Replace spaces with underscores
-- Preserve original case
return mod_name
end
-- Escape special characters for Lua pattern
local safe_mod_name = normalize_module_name(mod_name)
safe_mod_name = safe_mod_name:gsub("([^%w_])", "%%%1")
-- Build pattern to match #invoke usage
local pattern = "{{%s*#invoke:%s*" .. safe_mod_name .. "%s*|%s*" .. func_name
-- Normalize again
local normalized_wikitext = mw.text.trim(wikitext):gsub("%s+", " ")
-- Search in the wikitext
return string.find(normalized_wikitext, pattern) ~= nil
end
return p