Module:Docbunto Explained

--- 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('&#32;', '\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', '&#32;') 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%$%$', '%1') str = str:gsub('%$%$([^\n$]+)%$%$', '%1')

-- 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] .. '

' end

item[target] = item[target] .. item.tags['param'].value:match('^(%S+)')

if (item.tags['param'].modifiers or)['opt'] then item[target] = item[target] .. '

' end

elseif 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] .. '

' end

item[target] = item[target] .. (index > 1 and ', ' or ) .. tag.value:match('^(%S+)')

if (tag.modifiers or)['opt'] then item[target] = item[target] .. '

' end end end end

item[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+')

'...' and 0 or select(2, item.value:match('^%S+'):gsub('[.[]', )) indentation = indent_symbol:rep(indent_level) item.value = indentation .. item.value:gsub('^(%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 `` tag.-- @function preop_usage_highlight-- @param item Item documentation data.-- @param options Configuration options.-- @locallocal function preop_usage_highlight(item, options) if item.value then item.value = unindent(mw.text.trim(item.value)) if item.value:find('^$') then item.value = item.value:gsub('=', mw.text.nowiki) local multi_line = item.value:find('\n') and '|m = 1|' or '|'

if item.value:match('^