--- Docbunto is an automatic documentation generator for Scribunto modules.-- The module is based on LuaDoc and LDoc. It produces documentation in-- the form of MediaWiki markup, using `@tag`-prefixed comments embedded-- in the source code of a Scribunto module. The taglet parser & doclet-- renderer Docbunto uses are also publicly exposed to other modules.-- -- Docbunto code items are introduced by a block comment (`----`), an-- inline comment with three hyphens (`---`), or an inline `@tag` comment.-- The module can use static code analysis to infer variable names, item-- privacy (`local` keyword), tables (`` constructor) and functions-- (`function` keyword). MediaWiki and Markdown formatting is supported.-- -- Items are usually rendered in the order they are defined, if they are-- public items, or emulated classes extending the Lua primitives. There-- are many customisation options available to change Docbunto behaviour.-- -- @module docbunto-- @alias p-- @require Module:I18n-- @require Module:Lua_lexer-- @require Module:Unindent-- @require Module:Yesno-- @require Module:Arguments-- @author 8nml (Fandom Dev Wiki)-- @attribution @stevedonovan (GitHub)-- @release alpha--
local p = {}
-- Module dependencies.
local title = mw.title.getCurrentTitle
local i18n = require("Module:I18n").loadMessages("Docbunto")
local references = mw.loadData('Module:Docbunto/references')
local lexer = require('Module:Lua lexer')
local unindent = require('Module:Unindent')
local yesno = require('Module:Yesno')
local doc = require('Module:Documentation')
local modname
local DEFAULT_TITLE = title.namespace == 828 and doc.getEnvironment({}).templateTitle.text or ''
local frame, gsub, match
--------------------------------------------------------------------------------
-- Argument processing
--------------------------------------------------------------------------------
local function makeInvokeFunc(funcName)
return function (f)
local args = require("Module:Arguments").getArgs(f, {
valueFunc = function (key, value)
if type(value) == 'string' then
value = value:match('^%s*(.-)%s*$') -- Remove whitespace.
if key == 'heading' or value ~= '' then
return value
else
return nil
end
else
return value
end
end
})
return p[funcName](args)
end
end
-- Docbunto variables & tag tokens.
local TAG_MULTI = 'M'
local TAG_ID = 'ID'
local TAG_SINGLE = 'S'
local TAG_TYPE = 'T'
local TAG_FLAG = 'N'
local TAG_MULTI_LINE = 'ML'
-- Docbunto processing patterns.
local DOCBUNTO_SUMMARY, DOCBUNTO_TYPE, DOCBUNTO_CONCAT
local DOCBUNTO_TAG, DOCBUNTO_TAG_VALUE, DOCBUNTO_TAG_MOD_VALUE
-- Docbunto private logic.
--- @{string.find} optimisation for @{string} functions.
-- Resets patterns for each documentation build.
-- @function strfind_wrap
-- @param {function} strfunc String library function.
-- @return {function} Function wrapped in @{string.find} check.
-- @local
function strfind_wrap(func)
return function(...)
local arg = {...}
if string.find(arg[1], arg[2]) then
return func(...);
end
end
end
--- Pattern configuration function.
-- Resets patterns for each documentation build.
-- @function configure_patterns
-- @param {table} options Configuration options.
-- @param {boolean} options.colon Colon mode.
-- @local
local function configure_patterns(options)
-- Setup Unicode or ASCII character encoding (optimisation).
gsub = strfind_wrap(options.unicode
and mw.ustring.gsub
or string.gsub
)
match = strfind_wrap(options.unicode
and mw.ustring.match
or string.match
)
DOCBUNTO_SUMMARY =
options.iso639_th
and '^[^ ]+'
or
options.unicode
and '^[^.։。।෴۔።]+[.։。।෴۔።]?'
or '^[^.]+%.?'
DOCBUNTO_CONCAT = ' '
-- Setup parsing tag patterns with colon mode support.
DOCBUNTO_TAG = options.colon and '^%s*(%w+):' or '^%s*@(%w+)'
DOCBUNTO_TAG_VALUE = DOCBUNTO_TAG .. '(.*)'
DOCBUNTO_TAG_MOD_VALUE = DOCBUNTO_TAG .. '%[([^%]]*)%](.*)'
DOCBUNTO_TYPE = '^{({*[^}]+}*)}%s*'
end
--- Tag processor function.
-- @function process_tag
-- @param {string} str Tag string to process.
-- @return {table} Tag object.
-- @local
local function process_tag(str)
local tag = {}
if str:find(DOCBUNTO_TAG_MOD_VALUE) then
tag.name, tag.modifiers, tag.value = str:match(DOCBUNTO_TAG_MOD_VALUE)
local modifiers = {}
for mod in tag.modifiers:gmatch('[^%s,]+') do
modifiers[mod] = true
end
if modifiers.optchain then
modifiers.opt = true
modifiers.optchain = nil
end
tag.modifiers = modifiers
else
tag.name, tag.value = str:match(DOCBUNTO_TAG_VALUE)
end
tag.value = mw.text.trim(tag.value)
if p.tags._type_alias[tag.name] then
if p.tags._type_alias[tag.name] ~= 'variable' then
tag.value = p.tags._type_alias[tag.name] .. ' ' .. tag.value
tag.name = 'field'
end
if tag.value:match('^%S+') ~= '...' then
tag.value = tag.value:gsub('^(%S+)', '{%1}')
end
end
tag.name = p.tags._alias[tag.name] or tag.name
if tag.name ~= 'usage' and tag.value:find(DOCBUNTO_TYPE) then
tag.type = tag.value:match(DOCBUNTO_TYPE)
if tag.type:find('^%?') then
tag.type = tag.type:sub(2) .. '|nil'
end
tag.value = tag.value:gsub(DOCBUNTO_TYPE, '')
end
if p.tags[tag.name] == TAG_FLAG then
tag.value = true
end
return tag
end
--- Module info extraction utility.
-- @function extract_info
-- @param {table} documentation Package doclet info.
-- @return {table} Information name-value map.
-- @local
local function extract_info(documentation)
local info = {}
for _, tag in ipairs(documentation.tags) do
if p.tags._module_info[tag.name] then
if info[tag.name] then
if not info[tag.name]:find('^%* ') then
info[tag.name] = '* ' .. info[tag.name]
end
info[tag.name] = info[tag.name] .. '\n* ' .. tag.value
else
info[tag.name] = tag.value
end
end
end
return info
end
--- Type extraction utility.
-- @function extract_type
-- @param {table} item Item documentation data.
-- @return {string} Item type.
-- @local
local function extract_type(item)
local item_type
for _, tag in ipairs(item.tags) do
if p.tags[tag.name] == TAG_TYPE then
item_type = tag.name
if tag.name == 'variable' then
local implied_local = process_tag('@local')
table.insert(item.tags, implied_local)
item.tags['local'] = implied_local
end
if p.tags._generic_tags[item_type] and not p.tags._project_level[item_type] and tag.type then
item_type = item_type .. i18n:msg('separator-colon') .. tag.type
end
break
end
end
return item_type
end
--- Name extraction utility.
-- @function extract_name
-- @param {table} item Item documentation data.
-- @param {boolean} project Whether the item is project-level.
-- @return {string} Item name.
-- @local
local function extract_name(item, opts)
opts = opts or {}
local item_name
for _, tag in ipairs(item.tags) do
if p.tags[tag.name] == TAG_TYPE then
item_name = tag.value; break;
end
end
if item_name or not opts.project then
return item_name
end
item_name = item.code:match('\nreturn%s+([%w_]+)')
if item_name == 'p' and not item.tags['alias'] then
local implied_alias = { name = 'alias', value = 'p' }
item.tags['alias'] = implied_alias
table.insert(item.tags, implied_alias)
end
item_name = (item_name and item_name ~= 'p')
and item_name
or item.filename
:gsub('^' .. mw.site.namespaces[828].name .. ':', '')
:gsub('^(%u)', mw.ustring.lower)
:gsub('/', '.'):gsub(' ', '_')
return item_name
end
--- Source code utility for item name detection.
-- @function deduce_name
-- @param {string} tokens Stream tokens for first line.
-- @param {string} index Stream token index.
-- @param {table} opts Configuration options.
-- @param[opt] {boolean} opts.lookahead Whether a variable name succeeds the index.
-- @param[opt] {boolean} opts.lookbehind Whether a variable name precedes the index.
-- @return {string} Item name.
-- @local
local function deduce_name(tokens, index, opts)
local name = ''
if opts.lookbehind then
for i2 = index, 1, -1 do
if tokens[i2].type ~= 'keyword' then
name = tokens[i2].data .. name
else
break
end
end
elseif opts.lookahead then
for i2 = index, #tokens do
if tokens[i2].type ~= 'keyword' and not tokens[i2].data:find('^%(') then
name = name .. tokens[i2].data
else
break
end
end
end
return name
end
--- Code analysis utility.
-- @function code_static_analysis
-- @param {table} item Item documentation data.
-- @local
local function code_static_analysis(item)
local tokens = lexer(item.code:match('^[^\n]*'))[1]
local t, i = tokens[1], 1
local item_name, item_type
while t do
if t.type == 'whitespace' then
table.remove(tokens, i)
end
t, i = tokens[i + 1], i + 1
end
t, i = tokens[1], 1
while t do
if t.data == '=' then
item_name = deduce_name(tokens, i - 1, { lookbehind = true })
end
if t.data == 'function' then
item_type = 'function'
if tokens[i + 1].data ~= '(' then
item_name = deduce_name(tokens, i + 1, { lookahead = true })
end
end
if t.data == '{' or t.data == '{}' then
item_type = 'table'
end
if t.data == 'local' and not (item.tags['private'] or item.tags['local'] or item.type == 'type') then
local implied_local = process_tag('@local')
table.insert(item.tags, implied_local)
item.tags['local'] = implied_local
end
t, i = tokens[i + 1], i + 1
end
item.name = item.name or item_name or ''
item.type = item.type or item_type
end
--- Array hash map conversion utility.
-- @function hash_map
-- @param {table} item Item documentation data array.
-- @return {table} Item documentation data map.
-- @local
local function hash_map(array)
local map = array
for _, element in ipairs(array) do
if map[element.name] and not map[element.name].name then
table.insert(map[element.name], mw.clone(element))
elseif map[element.name] and map[element.name].name then
map[element.name] = { map[element.name], mw.clone(element) }
else
map[element.name] = mw.clone(element)
end
end
return map
end
--- Item export utility.
-- @function export_item
-- @param {table} documentation Package documentation data.
-- @param {string} item_reference Identifier name for item.
-- @param {string} item_index Identifier name for item.
-- @param {string} item_alias Export alias for item.
-- @param {boolean} factory_item Whether the documentation item is a factory function.
-- @local
local function export_item(documentation, item_reference, item_index, item_alias, factory_item)
for _, item in ipairs(documentation.items) do
if item_reference == item.name then
item.tags['local'] = nil
item.tags['private'] = nil
for index, tag in ipairs(item.tags) do
if p.tags._privacy_tags[tag.name] then
table.remove(item.tags, index)
end
end
item.type = item.type:gsub('variable', 'member')
if factory_item then
item.alias =
documentation.items[item_index].tags['factory'].value ..
(item_alias:find('^%[') and '' or (not item.tags['static'] and ':' or '.')) ..
item_alias
else
item.alias =
((documentation.tags['alias'] or {}).value or documentation.name) ..
(item_alias:find('^%[') and '' or (documentation.type == 'classmod' and not item.tags['static'] and ':' or '.')) ..
item_alias
end
item.hierarchy = mw.text.split((item.alias:gsub('["\']?%]', '')), '[.:%[\'""]+')
end
end
end
--- Subitem tag correction utility.
-- @function correct_subitem_tag
-- @param {table} item Item documentation data.
-- @local
local function correct_subitem_tag(item)
local field_tag = item.tags['field']
if item.type ~= 'function' or not field_tag then
return
end
if field_tag.name then
field_tag.name = 'param'
else
for _, tag_el in ipairs(field_tag) do
tag_el.name = 'param'
end
end
local param_tag = item.tags['param']
if param_tag and not param_tag.name then
if field_tag.name then
table.insert(param_tag, field_tag)
else
for _, tag_el in ipairs(field_tag) do
table.insert(param_tag, tag_el)
end
end
elseif param_tag and param_tag.name then
if field_tag.name then
param_tag = { param_tag, field_tag }
else
for i, tag_el in ipairs(field_tag) do
if i == 1 then
param_tag = { param_tag }
end
for _, tag_el in ipairs(field_tag) do
table.insert(param_tag, tag_el)
end
end
end
else
param_tag = field_tag
end
item.tags['field'] = nil
end
--- Item override tag utility.
-- @function override_item_tag
-- @param {table} item Item documentation data.
-- @param {string} name Tag name.
-- @param[opt] {string} alias Target alias for tag.
-- @local
local function override_item_tag(item, name, alias)
if item.tags[name] then
item[alias or name] = item.tags[name].value
end
end
--- Markdown header converter.
-- @function markdown_header
-- @param {string} hash Leading hash.
-- @param {string} text Header text.
-- @return {string} MediaWiki header.
-- @local
local function markdown_header(hash, text)
local symbol = '='
return
'\n' .. symbol:rep(#hash) ..
' ' .. text ..
' ' .. symbol:rep(#hash) ..
'\n'
end
--- Item reference formatting.
-- @function item_reference
-- @param {string} ref Item reference.
-- @return {string} Internal MediaWiki link to article item.
-- @local
local function item_reference(ref)
local temp = mw.text.split(ref, '|')
local item = temp[1]
local text = temp[2] or temp[1]
if references.items[item] then
item = references.items[item]
else
item = '#' .. item
end
return '<code>' .. '[[' .. item .. '|' .. text .. ']]' .. '</code>'
end
--- Doclet type reference preprocessor.
-- Formats types with links to the [[mw:Extension:Scribunto/Lua reference manual|Lua reference manual]].
-- @function preop_type
-- @param {table} item Item documentation data.
-- @param {table} options Configuration options.
-- @local
local function type_reference(item, options)
if
not options.noluaref and
item.value and
item.value:match('^%S+') == '<code>...</code>'
then
item.value = item.value:gsub('^(%S+)', mw.text.tag{
name = 'code',
content = '[[mw:Extension:Scribunto/Lua reference manual#varargs|...]]'
})
end
if not item.type then
return
end
item.type = item.type:gsub(' ', '\26')
local space_ptn = '[;|][%s\26]*'
local types, t = mw.text.split(item.type, space_ptn)
local spaces = {}
for space in item.type:gmatch(space_ptn) do
table.insert(spaces, space)
end
for index, type in ipairs(types) do
t = types[index]
local data = references.types[type]
local name = data and data.name or t
if not name:match('%.') and not name:match('^%u') and data then
name = i18n:msg('type-' .. name)
end
if data and not options.noluaref then
types[index] = '[[' .. data.link .. '|' .. name .. ']]'
elseif
not options.noluaref and
not t:find('^line') and
not p.tags._generic_tags[t]
then
types[index] = '[[#' .. t .. '|' .. name .. ']]'
end
end
for index, space in ipairs(spaces) do
types[index] = types[index] .. space
end
item.type = table.concat(types)
if item.alias then
mw.log(item.type)
end
item.type = item.type:gsub('\26', ' ')
end
--- Markdown preprocessor to MediaWiki format.
-- @function markdown
-- @param {string} str Unprocessed Markdown string.
-- @return {string} MediaWiki-compatible markup with HTML formatting.
-- @local
local function markdown(str)
-- Bold & italic tags.
str = str:gsub('%*%*%*([^\n*]+)%*%*%*', '<b><i>%1<i></b>')
str = str:gsub('%*%*([^\n*]+)%*%*', '<b>%1</b>')
str = str:gsub('%*([^\n*]+)%*', '<i>%1</i>')
-- Self-closing header support.
str = str:gsub('%f[^\n%z](#+) *([^\n#]+) *#+%s', markdown_header)
-- External and internal links.
str = str:gsub('%[([^\n%]]+)%]%(([^\n][^\n)]-)%)', '[%2 %1]')
str = str:gsub('%@{([^\n}]+)}', item_reference)
-- Programming & scientific notation.
str = str:gsub('%f["`]`([^\n`]+)`%f[^"`]', '<code><nowiki>%1') str = str:gsub('%$%$\\ce%$%$', '
-- Strikethroughs and superscripts. str = str:gsub('~~([^\n~]+)~~', '%1') str = str:gsub('%^%(([^)]+)%)', '%1') str = str:gsub('%^%s*([^%s%p]+)', '%1')
-- HTML output. return strend
--- Doclet item renderer.-- @function render_item-- @param stream Wikitext documentation stream.-- @param item Item documentation data.-- @param options Configuration options.-- @param[opt] preop Item data preprocessor.-- @locallocal function render_item(stream, item, options, preop) local item_id = item.alias or item.name if preop then preop(item, options) end local item_name = item.alias or item.name
type_reference(item, options)
local item_type = item.type
for _, name in ipairs(p.tags._subtype_hierarchy) do if item.tags[name] then item_type = item_type .. i18n:msg('separator-dot') .. name end end item_type = i18n:msg('parentheses', item_type)
if options.strip and item.export and item.hierarchy then item_name = item_name:gsub('^[%w_]+[.[]?', ) end
stream:wikitext(';' .. item_name .. '
' .. item_type):newline
if (#(item.summary or ) + #item.description) ~= 0 then local separator = #(item.summary or ) ~= 0 and #item.description ~= 0 and (item.description:find('^[{:#*]+%s+') and '\n' or ' ') or local intro = (item.summary or ) .. separator .. item.description stream:wikitext(':' .. intro:gsub('\n([{:#*])', '\n:%1'):gsub('\n\n([^=])', '\n:%1')):newline endend
--- Doclet tag renderer.-- @function render_tag-- @param stream Wikitext documentation stream.-- @param name Item tag name.-- @param tag Item tag data.-- @param options Configuration options.-- @param[opt] preop Item data preprocessor.-- @locallocal function render_tag(stream, name, tag, options, preop) if preop then preop(tag, options) end if tag.value then type_reference(tag, options) local tag_name = i18n:msg('tag-' .. name, '1') stream:wikitext(':' .. tag_name .. '' .. i18n:msg('separator-semicolon') .. mw.text.trim(tag.value):gsub('\n([{:#*])', '\n:%1'))
if tag.value:find('\n[{:#*]') and (tag.type or (tag.modifiers or)['opt']) then stream:newline:wikitext(':') end if tag.type and (tag.modifiers or)['opt'] then stream:wikitext(i18n:msg)
elseif tag.type then stream:wikitext(i18n:msg)
elseif (tag.modifiers or)['opt'] then stream:wikitext(i18n:msg) end
stream:newline
else local tag_name = i18n:msg('tag-' .. name, tostring(#tag)) stream:wikitext(':' .. tag_name .. '' .. i18n:msg('separator-semicolon')):newline
for _, tag_el in ipairs(tag) do type_reference(tag_el, options) stream:wikitext(':' .. (options.ulist and '*' or ':') .. tag_el.value:gsub('\n([{:#*])', '\n:' .. (options.ulist and '*' or ':') .. '%1'))
if tag_el.value:find('\n[{:#*]') and (tag_el.type or (tag_el.modifiers or)['opt']) then stream:newline:wikitext(':' .. (options.ulist and '*' or ':') .. (tag_el.value:match('^[*:]+') or )) end
if tag_el.type and (tag_el.modifiers or)['opt'] then stream:wikitext(i18n:msg)
elseif tag_el.type then stream:wikitext(i18n:msg)
elseif (tag_el.modifiers or)['opt'] then stream:wikitext(i18n:msg) end
stream:newline end endend
--- Doclet function preprocessor.-- Formats item name as a function call with top-level arguments.-- @function preop_function_name-- @param item Item documentation data.-- @param options Configuration options.-- @locallocal function preop_function_name(item, options) local target = item.alias and 'alias' or 'name'
item[target] = item[target] .. '('
if item.tags['param'] and item.tags['param'].value and not item.tags['param'].value:find('^[%w_]+[.[]') then if (item.tags['param'].modifiers or)['opt'] then item[target] = item[target] .. '
' enditem[target] = item[target] .. item.tags['param'].value:match('^(%S+)')
if (item.tags['param'].modifiers or)['opt'] then item[target] = item[target] .. '
' endelseif item.tags['param'] then for index, tag in ipairs(item.tags['param']) do if not tag.value:find('^[%w_]+[.[]') then if (tag.modifiers or)['opt'] then item[target] = item[target] .. '
' enditem[target] = item[target] .. (index > 1 and ', ' or ) .. tag.value:match('^(%S+)')
if (tag.modifiers or)['opt'] then item[target] = item[target] .. '
' end end end enditem[target] = item[target] .. ')'end
--- Doclet parameter/field subitem preprocessor.-- Indents and wraps variable prefix with `code` tag.-- @function preop_variable_prefix-- @param item Item documentation data.-- @param options Configuration options.-- @locallocal function preop_variable_prefix(item, options) local indent_symbol = options.ulist and '*' or ':' local indent_level, indentation
if item.value then indent_level = item.value:match('^%S+')
%1
')elseif item then for _, item_el in ipairs(item) do preop_variable_prefix(item_el, options) end endend
--- Doclet usage subitem preprocessor.-- Formats usage example with `
if item.value:match('^