From c1f5cb42762406300057abc044bae51061e74f72 Mon Sep 17 00:00:00 2001 From: Bilal2453 Date: Sun, 24 Apr 2022 05:21:18 +0300 Subject: [PATCH] Initial documentation --- docgen.lua | 449 ++++++++++++++++++++++++++++++++++ docs/Modal.md | 69 ++++++ docs/TextInput.md | 121 +++++++++ init.lua | 1 - libs/components/TextInput.lua | 54 +++- libs/containers/Modal.lua | 38 ++- 6 files changed, 726 insertions(+), 6 deletions(-) create mode 100644 docgen.lua create mode 100644 docs/Modal.md create mode 100644 docs/TextInput.md diff --git a/docgen.lua b/docgen.lua new file mode 100644 index 0000000..43d882a --- /dev/null +++ b/docgen.lua @@ -0,0 +1,449 @@ +--[[ +The MIT License (MIT) + +Copyright (c) 2016-2021 SinisterRectus +Copyright (c) 2021-2022 Bilal2453 (heavily modified to partially support EmmyLua) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local fs = require('fs') +local pathjoin = require('pathjoin') + +local insert, sort, concat = table.insert, table.sort, table.concat +local format = string.format +local pathJoin = pathjoin.pathJoin + +local function scan(dir) + for fileName, fileType in fs.scandirSync(dir) do + local path = pathJoin(dir, fileName) + if fileType == 'file' then + coroutine.yield(path) + else + scan(path) + end + end +end + +local function match(s, pattern) -- only useful for one capture + return assert(s:match(pattern), s) +end + +local function gmatch(s, pattern, hash) -- only useful for one capture + local tbl = {} + if hash then + for k in s:gmatch(pattern) do + tbl[k] = true + end + else + for v in s:gmatch(pattern) do + insert(tbl, v) + end + end + return tbl +end + +local function matchType(c) + local line + for _, s in ipairs(c) do + if s:find '' then return 'ignore' end + end + for _, s in ipairs(c) do + local m = s:match('%-%-%-%s*@(%S+)') + if m then line = m; break end + end + return line +end + +local function matchComments(s) + local lines = {} + local last_line = {} + for l in s:gmatch('[^\n]*\n?') do + if l:match '^%-%-' then + last_line[#last_line + 1] = l + elseif #last_line > 0 then + last_line[#last_line + 1] = l + lines[#lines+1] = last_line + last_line = {} + end + end + return lines +end + +local function matchClassName(s) + return match(s, '@class ([^%s:]+)') +end + +local function matchMethodName(s) + local m = s:match 'function%s*.-[:.]%s*([_%w]+)' + or s:match 'function%s*([_%w]+)' + or s:match '([_%w]+)%s*=%s*function' + if not m then error(s) end + return m +end + +local function matchDescription(c) + local desc = {} + for _, v in ipairs(c) do + local n, m = v:match('%-%-%-*%s*@'), v:match('%-%-+%s*(.+)') + if not n and m then + m = m:gsub('', '') -- invisible custom tags + desc[#desc+1] = m + end + end + return table.concat(desc):gsub('^%s+', ''):gsub('%s+$', '') +end + +local function matchParents(c) + local line + for _, s in ipairs(c) do + local m = s:match('@class [%a_][%a_%-%.%*]+%s*:%s*([^\n#@%-]+)') + if m then line = m; break end + end + if not line then return {} end + local ret = {} + for s in line:gmatch('[^,]+') do + ret[#ret + 1] = s:match('%S+'):gsub('%s+', '') + end + return ret +end + +local function matchReturns(s) + return gmatch(s, '@return (%S+)') +end + +local function matchTags(s) + local ret = {} + for m in s:gmatch '' do + ret[m] = true + end + return ret +end + +local function matchMethodTags(s) + local ret = {} + for m in s:gmatch '' do + ret[m] = true + end + return ret +end + +local function matchProperties(s) + local ret = {} + for n, t, d in s:gmatch '@field%s*(%S+)%s*(%S+)%s*([^\n]*)' do + ret[#ret+1] = { + name = n, + type = t, + desc = d or '', + } + end + return ret +end + +local function matchParameters(c) + local ret = {} + for _, s in ipairs(c) do + local param_name, optional, param_type = s:match('@param%s*([^%s%?]+)%s*(%??)%s*(%S+)') + if param_name then + ret[#ret+1] = {param_name, param_type, optional == '?'} + end + end + if #ret > 0 then return ret end + + for _, s in ipairs(c) do + local params = s:match('@type%s*fun%s*%((.-)%)') + if not params then goto continue end + for pp in params:gmatch('[^,]+') do + local param_name, optional = pp:match('([%w_%-]+)%s*(%??)') + local param_type = pp:match(':%s*(.+)') + if param_name then + ret[#ret+1] = {param_name, param_type, optional == '?'} + end + end + ::continue:: + end + return ret +end + +local function matchMethod(s, c) + return { + name = matchMethodName(c[#c]), + desc = matchDescription(c), + parameters = matchParameters(c), + returns = matchReturns(s), + tags = matchTags(s), + } +end + +---- + +local docs = {} + +local function newClass() + + local class = { + methods = {}, + statics = {}, + } + + local function init(s, c) + class.name = matchClassName(s) + class.parents = matchParents(c) + class.desc = matchDescription(c) + class.parameters = matchParameters(c) + class.tags = matchTags(s) + class.methodTags = matchMethodTags(s) + class.properties = matchProperties(s) + assert(not docs[class.name], 'duplicate class: ' .. class.name) + docs[class.name] = class + end + + return class, init + +end + +for f in coroutine.wrap(scan), './libs' do + local d = assert(fs.readFileSync(f)) + + local class, initClass = newClass() + local comments = matchComments(d) + for i = 1, #comments do + local s = table.concat(comments[i], '\n') + local t = matchType(comments[i]) + if t == 'ignore' then + goto continue + elseif t == 'class' then + initClass(s, comments[i]) + elseif t == 'param' or t == 'return' then + local method = matchMethod(s, comments[i]) + for k, v in pairs(class.methodTags) do + method.tags[k] = v + end + method.class = class + insert(method.tags.static and class.statics or class.methods, method) + end + ::continue:: + end +end + +---- + +local output = 'docs' + +local function link(str) + if type(str) == 'table' then + local ret = {} + for i, v in ipairs(str) do + ret[i] = link(v) + end + return concat(ret, ', ') + else + local ret = {} + for t in str:gmatch('[^/]+') do + insert(ret, docs[t] and format('[[%s]]', t) or t) + end + return concat(ret, '/') + end +end + +local function sorter(a, b) + return a.name < b.name +end + +local function writeHeading(f, heading) + f:write('## ', heading, '\n\n') +end + +local function writeProperties(f, properties) + sort(properties, sorter) + f:write('| Name | Type | Description |\n') + f:write('|-|-|-|\n') + for _, v in ipairs(properties) do + f:write('| ', v.name, ' | ', link(v.type), ' | ', v.desc, ' |\n') + end + f:write('\n') +end + +local function writeParameters(f, parameters) + f:write('(') + local optional + if #parameters > 0 then + for i, param in ipairs(parameters) do + f:write(param[1]) + if i < #parameters then + f:write(', ') + end + optional = param[3] + param[2] = param[2]:gsub('|', '/') + end + f:write(')\n\n') + if optional then + f:write('| Parameter | Type | Optional |\n') + f:write('|-|-|:-:|\n') + for _, param in ipairs(parameters) do + local o = param[3] and '✔' or '' + f:write('| ', param[1], ' | ', link(param[2]), ' | ', o, ' |\n') + end + f:write('\n') + else + f:write('| Parameter | Type |\n') + f:write('|-|-|\n') + for _, param in ipairs(parameters) do + f:write('| ', param[1], ' | ', link(param[2]), ' |\n') + end + f:write('\n') + end + else + f:write(')\n\n') + end +end + +local methodTags = {} + +methodTags['http'] = 'This method always makes an HTTP request.' +methodTags['http?'] = 'This method may make an HTTP request.' +methodTags['ws'] = 'This method always makes a WebSocket request.' +methodTags['mem'] = 'This method only operates on data in memory.' + +local function checkTags(tbl, check) + for i, v in ipairs(check) do + if tbl[v] then + for j, w in ipairs(check) do + if i ~= j then + if tbl[w] then + return error(string.format('mutually exclusive tags encountered: %s and %s', v, w), 1) + end + end + end + end + end +end + +local function writeMethods(f, methods) + + sort(methods, sorter) + for _, method in ipairs(methods) do + + f:write('### ', method.name) + writeParameters(f, method.parameters) + f:write(method.desc, '\n\n') + + local tags = method.tags + checkTags(tags, {'http', 'http?', 'mem'}) + checkTags(tags, {'ws', 'mem'}) + + for k in pairs(tags) do + if k ~= 'static' then + assert(methodTags[k], k) + f:write('*', methodTags[k], '*\n\n') + end + end + + f:write('**Returns:** ', link(method.returns), '\n\n----\n\n') + + end + +end + +if not fs.existsSync(output) then + fs.mkdirSync(output) +end + +local function collectParents(parents, k, ret, seen) + ret = ret or {} + seen = seen or {} + for _, parent in ipairs(parents) do + parent = docs[parent] + if parent then + for _, v in ipairs(parent[k]) do + if not seen[v] then + seen[v] = true + insert(ret, v) + end + end + end + if parent and parent.parents then + collectParents(parent.parents, k, ret, seen) + end + end + return ret +end + +for _, class in pairs(docs) do + + local f = io.open(pathJoin(output, class.name .. '.md'), 'w') + + local parents = class.parents + local parentLinks = link(parents) + + if next(parents) then + f:write('#### *extends ', parentLinks, '*\n\n') + end + + f:write(class.desc, '\n\n') + + checkTags(class.tags, {'interface', 'abstract', 'patch'}) + if class.tags.interface then + writeHeading(f, 'Constructor') + f:write('### ', class.name) + writeParameters(f, class.parameters) + elseif class.tags.abstract then + f:write('*This is an abstract base class. Direct instances should never exist.*\n\n') + elseif class.tags.patch then + f:write("*This is a patched class.\nFor full usage refer to the Discordia Wiki, only patched methods and properities are documented here.*\n\n") + else + f:write('*Instances of this class should not be constructed by users.*\n\n') + end + + local properties = collectParents(parents, 'properties') + if next(properties) then + writeHeading(f, 'Properties Inherited From ' .. parentLinks) + writeProperties(f, properties) + end + + if next(class.properties) then + writeHeading(f, 'Properties') + writeProperties(f, class.properties) + end + + local statics = collectParents(parents, 'statics') + if next(statics) then + writeHeading(f, 'Static Methods Inherited From ' .. parentLinks) + writeMethods(f, statics) + end + + local methods = collectParents(parents, 'methods') + if next(methods) then + writeHeading(f, 'Methods Inherited From ' .. parentLinks) + writeMethods(f, methods) + end + + if next(class.statics) then + writeHeading(f, 'Static Methods') + writeMethods(f, class.statics) + end + + if next(class.methods) then + writeHeading(f, 'Methods') + writeMethods(f, class.methods) + end + + f:close() + +end diff --git a/docs/Modal.md b/docs/Modal.md new file mode 100644 index 0000000..bd30f1f --- /dev/null +++ b/docs/Modal.md @@ -0,0 +1,69 @@ +Represents a Discord modal used to display a GUI prompt for end-users. +This is main builder for modals, the entry point of this library and where to create modals. + +## Constructor + +### Modal() + +## Properties + +| Name | Type | Description | +|-|-|-| +| textInputs | ArrayIterable | A cache of all constructed [[TextInput]] classes in this instance. | + +## Methods + +### raw() + +Returns the raw table structure of this modal that is accepted by Discord. + +*This method only operates on data in memory.* + +**Returns:** table + +---- + +### removeTextInput(id) + +| Parameter | Type | +|-|-| +| id | string | + +Removes a previously assigned [[TextInput]] component from the current instance. + +*This method only operates on data in memory.* + +**Returns:** [[Modal]] + +---- + +### textInput(data, label, style) + +| Parameter | Type | +|-|-| +| data | TextInput-Resolvable | +| label | string | +| style | Style-Resolvable | + +Creates and assign a new [[TextInput]] component. + +*This method only operates on data in memory.* + +**Returns:** [[Modal]] + +---- + +### title(title) + +| Parameter | Type | +|-|-| +| title | string | + +Sets the title of the modal. A modal title is displayed as a big text in the top-center of a modal. + +*This method only operates on data in memory.* + +**Returns:** [[Modal]] + +---- + diff --git a/docs/TextInput.md b/docs/TextInput.md new file mode 100644 index 0000000..635cd3d --- /dev/null +++ b/docs/TextInput.md @@ -0,0 +1,121 @@ +#### *extends Component* + +A component that represents [TextInput](https://discord.com/developers/docs/interactions/message-components#text-inputs). +A TextInput is a GUI component where the user can input a piece of text. +TextInput component can only be used in a [[Modal]] context. + +## Constructor + +### TextInput() + +## Methods + +### label(label) + +| Parameter | Type | +|-|-| +| label | string | + +Sets the label of the TextInput. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### maxLength(max) + +| Parameter | Type | +|-|-| +| max | number | + +Sets the maximum possible amount of characters in the user input. Must be a value between 0 and 4000 inclusive. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### minLength(min) + +| Parameter | Type | +|-|-| +| min | number | + +Sets the minmum required amount of characters in the user input. Must be a value between 0 and 4000 inclusive. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### optional() + +Sets this TextInput as optiona. +An optional TextInput may or may not be provided on user modal submit. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### placeholder(placeholder) + +| Parameter | Type | +|-|-| +| placeholder | string | + +Sets a placeholder for the TextInput. A placeholder is a shaded text displayed when the TextInput value is empty. +Must be a value between 0 and 100 inclusive. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### required() + +Sets this TextInput as required. +A required TextInput must be provided for the user to be able to submit the modal. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### style(style) + +| Parameter | Type | +|-|-| +| style | Style-Resolvable | + +Sets the style of the TextInput. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + +### value(value) + +| Parameter | Type | +|-|-| +| value | string | + +Sets a pre-provided value for the TextInput. +The pre-provided value can be then optionally changed by the user. +Must be a value between 1 and 4000 inclusive. + +*This method only operates on data in memory.* + +**Returns:** [[TextInput]] + +---- + diff --git a/init.lua b/init.lua index 5155f8e..ebb6a8b 100644 --- a/init.lua +++ b/init.lua @@ -7,7 +7,6 @@ require('discordia-components') -- Patch client require('client/Client') --- TODO: doc comments local enums = discordia.enums local new_enums = require('enums') local interactionType = enums.interactionType diff --git a/libs/components/TextInput.lua b/libs/components/TextInput.lua index 3369045..bfc407a 100644 --- a/libs/components/TextInput.lua +++ b/libs/components/TextInput.lua @@ -7,11 +7,25 @@ local textInputStyle = enums.textInputStyle local Component = class.classes.Component +---@alias Style-Resolvable 'short'|'paragraph' | 1|2 +---@alias TextInput-Resolvable string|{id: string, label: string, style: Style-Resolvable, minLength: number, maxLength: number, required: boolean, value: string, placeholder: string} + +---A component that represents [TextInput](https://discord.com/developers/docs/interactions/message-components#text-inputs). +---A TextInput is a GUI component where the user can input a piece of text. +---TextInput component can only be used in a [[Modal]] context. +---@class TextInput: Component +---@overload fun(data: TextInput-Resolvable, label?: string, style?: Style-Resolvable) +--- local TextInput = class('TextInput', Component) -function TextInput:__init(data, ...) +--- +---Creates a new instance of the component TextInput. +---@param data TextInput-Resolvable +---@param label string +---@param style Style-Resolvable +function TextInput:__init(data, label, style) -- Validate arguments into appropriate structure - data = self._validate(data, ...) + data = self._validate(data, label, style) assert(data.id, 'an id must be supplied') -- Default style to short if not provided @@ -35,11 +49,20 @@ function TextInput:__init(data, ...) Component.__init(self, data, componentType.textInput) end +--- +---Decides whether a row can be used or not. +---@return boolean function TextInput._isEligible() -- seems to always be accepted, since there is only a single cell in each row return true end +--- +---Resolves the parameters into an appropriate structure. +---@param data TextInput-Resolvable +---@param label string|nil +---@param style Style-Resolvable|nil +---@return table function TextInput._validate(data, label, style) if type(data) ~= 'table' then data = {id = data} @@ -53,6 +76,9 @@ function TextInput._validate(data, label, style) return data end +---Sets the style of the TextInput. +---@param style Style-Resolvable +---@return TextInput function TextInput:style(style) -- resolve the style local resolved_style @@ -69,38 +95,62 @@ function TextInput:style(style) return self:_set('style', resolved_style or textInputStyle.short) end +---Sets the label of the TextInput. +---@param label string +---@return TextInput function TextInput:label(label) label = tostring(label) assert(#label >= 1 and #label <= 45, 'label must be a string in the range 1-45 inclusive') return self:_set('label', label) end +---Sets the minmum required amount of characters in the user input. Must be a value between 0 and 4000 inclusive. +---@param min number +---@return TextInput function TextInput:minLength(min) min = tonumber(min) assert(min >= 0 and min <= 4000, 'minLength must be in the range 0-4000 inclusive') return self:_set('minLength', min) end +---Sets the maximum possible amount of characters in the user input. Must be a value between 0 and 4000 inclusive. +---@param max number +---@return TextInput function TextInput:maxLength(max) max = tonumber(max) assert(max >= 1 and max <= 4000, 'maxLength must be in the range 1-4000 inclusive') return self:_set('maxLength', max) end +---Sets this TextInput as required. +---A required TextInput must be provided for the user to be able to submit the modal. +---@return TextInput function TextInput:required() return self:_set('required', true) end +---Sets this TextInput as optiona. +---An optional TextInput may or may not be provided on user modal submit. +---@return TextInput function TextInput:optional() return self:_set('required', false) end +---Sets a pre-provided value for the TextInput. +---The pre-provided value can be then optionally changed by the user. +---Must be a value between 1 and 4000 inclusive. +---@param value string +---@return TextInput function TextInput:value(value) value = tostring(value) assert(#value >= 1 and #value <= 4000, 'value must be in the range 1-4000 inclusive') return self:_set('value', value) end +---Sets a placeholder for the TextInput. A placeholder is a shaded text displayed when the TextInput value is empty. +---Must be a value between 0 and 100 inclusive. +---@param placeholder string +---@return TextInput function TextInput:placeholder(placeholder) placeholder = tostring(placeholder) assert(#placeholder >= 0 and #placeholder <= 100, 'placeholder must be in the range 0-100 inclusive') diff --git a/libs/containers/Modal.lua b/libs/containers/Modal.lua index a541dcd..b73a84a 100644 --- a/libs/containers/Modal.lua +++ b/libs/containers/Modal.lua @@ -13,8 +13,20 @@ local MAX_ROWS = 5 local MAX_ROW_CELLS = 1 local COMPONENTS = {TextInput} +---@alias Modal-Resolvable string|{id: string, title: string, [number]: TextInput-Resolvable} + +---Represents a Discord modal used to display a GUI prompt for end-users. +---This is main builder for modals, the entry point of this library and where to create modals. +---@class Modal +---@field textInputs ArrayIterable A cache of all constructed [[TextInput]] classes in this instance. +---@overload fun(data?: string|Modal-Resolvable): Modal +--- local Modal = class('Modal', ComponentsContainer) +--- +---Creates a new [[Modal]] instance. +---@param data string|table +---@return Modal function Modal:__init(data) -- validate and resolve argument data = self:_validate(data) @@ -33,7 +45,7 @@ function Modal:__init(data) end --- ---- A simple helper function to get the names of support components in a modal for error messages. +--- A simple helper function to get the names of supported components in a modal for error messages. ---@return string # a string of components names separated by , (comma) local function getSupportedComponents() local supported_components = {} @@ -43,6 +55,10 @@ local function getSupportedComponents() return table.concat(supported_components, ', ') end +--- +---Resolves the data parameter for a Modal instance. +---@param data Modal-Resolvable +---@return Modal-Resolvable function Modal:_validate(data) local data_type = type(data) if data_type ~= 'table' then @@ -51,6 +67,9 @@ function Modal:_validate(data) return data end +--- +---Loads the provided data into the Modal instance. +---@param data Modal-Resolvable function Modal:_load(data) -- make sure we got a table local data_type = type(data) @@ -95,22 +114,35 @@ function Modal:_load(data) end end +---Sets the title of the modal. A modal title is displayed as a big text in the top-center of a modal. +---@param title string +---@return Modal function Modal:title(title) title = tostring(title) self._title = assert(title, 'title must be in the range 1-45 inclusive') return self end -function Modal:textInput(...) - self:_buildComponent(TextInput, ...) +---Creates and assign a new [[TextInput]] component. +---@param data TextInput-Resolvable +---@param label string +---@param style Style-Resolvable +---@return Modal +function Modal:textInput(data, label, style) + self:_buildComponent(TextInput, data, label, style) return self end +---Removes a previously assigned [[TextInput]] component from the current instance. +---@param id string +---@return Modal function Modal:removeTextInput(id) return self:_remove(TextInput, id) end local component_raw = Modal.raw +---Returns the raw table structure of this modal that is accepted by Discord. +---@return table function Modal:raw() -- get raw representation of the components local components = component_raw(self)