local M = {} local query = vim.treesitter.query.parse( "lua", [[ ;; top-level locals ((variable_declaration ( assignment_statement (variable_list name: (identifier) @local_name) (expression_list value: (_) @local_value) (#match? @local_value "(setmetatable|\\{)") )) @local (#any-of? @local_name "M" "defaults" "config") (#has-parent? @local chunk)) ;; top-level functions/methods (function_declaration name: (_) @fun_name (#match? @fun_name "^M") parameters: (_) @fun_params ) @fun ;; styles (function_call name: (dot_index_expression) @_sf (#eq? @_sf "Snacks.config.style") arguments: (arguments (string content: (string_content) @style_name) (table_constructor) @style_config) ) @style ]] ) ---@class snacks.docs.Capture ---@field name string ---@field line number ---@field node TSNode ---@field text string ---@field comment string ---@field fields table ---@class snacks.docs.Parse ---@field captures snacks.docs.Capture[] ---@field comments string[] ---@class snacks.docs.Info ---@field config? string ---@field mod? string ---@field methods {name: string, args: string, comment?: string, types?: string, type: "method"|"function"}[] ---@field types string[] ---@field styles {name:string, opts:string}[] ---@param lines string[] function M.parse(lines) local source = table.concat(lines, "\n") local parser = vim.treesitter.get_string_parser(source, "lua") parser:parse() local comments = {} ---@type string[] for l, line in ipairs(lines) do if line:find("^%-%-") then comments[l] = line if comments[l - 1] then comments[l] = comments[l - 1] .. "\n" .. comments[l] comments[l - 1] = nil end end end ---@type snacks.docs.Parse local ret = { captures = {}, comments = {} } for id, node in query:iter_captures(parser:trees()[1]:root(), source) do local name = query.captures[id] if not name:find("_") then -- add fields local fields = {} for id2, node2 in query:iter_captures(node, source) do local c = query.captures[id2] if c:find(".+_") then fields[c:gsub("^.*_", "")] = vim.treesitter.get_node_text(node2, source) end end -- add comments local comment = "" ---@type string if comments[node:start()] then comment = comments[node:start()] comments[node:start()] = nil end table.insert(ret.captures, { text = vim.treesitter.get_node_text(node, source), name = name, comment = comment, line = node:start() + 1, node = node, fields = fields, }) end end -- remove comments that are followed by code for l in pairs(comments) do if lines[l + 1] and lines[l + 1]:find("^.+$") then comments[l] = nil end end for l in ipairs(lines) do if comments[l] then table.insert(ret.comments, comments[l]) end end return ret end ---@param lines string[] function M.extract(lines) local parse = M.parse(lines) ---@type snacks.docs.Info local ret = { methods = {}, types = vim.tbl_filter(function(c) return not c:find("@private") end, parse.comments), styles = {}, } for _, c in ipairs(parse.captures) do if c.comment:find("@private") then -- skip private elseif c.name == "local" then if vim.tbl_contains({ "defaults", "config" }, c.fields.name) then ret.config = vim.trim(c.comment .. "\n" .. c.fields.value) elseif c.fields.name == "M" then ret.mod = c.comment end elseif c.name == "fun" then local name = c.fields.name:sub(2) local args = (c.fields.params or ""):sub(2, -2) local comment = c.comment local type = name:sub(1, 1) name = name:sub(2) if not name:find("^_") then table.insert(ret.methods, { name = name, args = args, comment = comment, type = type }) end elseif c.name == "style" then table.insert(ret.styles, { name = c.fields.name, opts = c.fields.config }) end end return ret end ---@param tag string ---@param readme string ---@param content string function M.replace(tag, readme, content) content = vim.trim(content) local pattern = "(<%!%-%- " .. tag .. ":start %-%->).*(<%!%-%- " .. tag .. ":end %-%->)" if not readme:find(pattern) then error("tag " .. tag .. " not found") end return readme:gsub(pattern, "%1\n\n" .. content .. "\n\n%2") end ---@param str string ---@param opts? {extract_comment: boolean} -- default true function M.md(str, opts) str = str or "" opts = opts or {} if opts.extract_comment == nil then opts.extract_comment = true end str = str:gsub("\n%s*%-%-%s*stylua: ignore\n", "\n") local comments = {} ---@type string[] local lines = vim.split(str, "\n", { plain = true }) if opts.extract_comment then while lines[1] and lines[1]:find("^%-%-") and not lines[1]:find("^%-%-%-%s*@") do local line = table.remove(lines, 1):gsub("^[%-]*%s*", "") table.insert(comments, line) end end local ret = {} ---@type string[] if #comments > 0 then table.insert(ret, vim.trim(table.concat(comments, "\n"))) table.insert(ret, "") end if #lines > 0 then table.insert(ret, "```lua") table.insert(ret, vim.trim(table.concat(lines, "\n"))) table.insert(ret, "```") end return vim.trim(table.concat(ret, "\n")) .. "\n" end ---@param name string ---@param info snacks.docs.Info function M.render(name, info) local lines = {} ---@type string[] local function add(line) table.insert(lines, line) end local prefix = ("Snacks.%s"):format(name) if name == "init" then prefix = "Snacks" end if info.config then add("## ⚙️ Config\n") add(M.md(info.config)) end if #info.styles > 0 then table.sort(info.styles, function(a, b) return a.name < b.name end) add("## 🎨 Styles\n") for _, style in pairs(info.styles) do add(("### `%s`\n"):format(style.name)) add(M.md(style.opts)) end end if #info.types > 0 then add("## 📚 Types\n") for _, t in ipairs(info.types) do add(M.md(t)) end end if info.mod or #info.methods > 0 then add("## 📦 Module\n") end if info.mod then local mod_lines = vim.split(info.mod, "\n") mod_lines = vim.tbl_filter(function(line) local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]] if overload then table.insert(info.methods, { name = "", args = "", type = "", comment = "---@type " .. overload, }) return false elseif line:find("^%s*$") then return false end return true end, mod_lines) local hide = #mod_lines == 1 and mod_lines[1]:find("@class") if not hide then table.insert(mod_lines, prefix .. " = {}") add(M.md(table.concat(mod_lines, "\n"))) end end table.sort(info.methods, function(a, b) if a.type == b.type then return a.name < b.name end return a.type < b.type end) for _, method in ipairs(info.methods) do add(("### `%s%s%s()`\n"):format(method.type == ":" and name or prefix, method.type, method.name)) local code = ("%s\n%s%s%s(%s)"):format( method.comment or "", method.type == ":" and name or prefix, method.type, method.name, method.args ) add(M.md(code)) end lines = vim.split(vim.trim(table.concat(lines, "\n")), "\n") return lines end function M.write(name, lines) local path = ("docs/%s.md"):format(name) local ok, text = pcall(vim.fn.readfile, path) local docgen = "" local top = {} ---@type string[] if not ok then table.insert(top, "# 🍿 " .. name) table.insert(top, "") else for _, line in ipairs(text) do if line == docgen then break end table.insert(top, line) end end table.insert(top, docgen) table.insert(top, "") vim.list_extend(top, lines) vim.fn.writefile(top, path) end function M._build() local skip = { "docs", "health" } for file, t in vim.fs.dir("lua/snacks", { depth = 1 }) do local name = vim.fn.fnamemodify(file, ":t:r") if t == "file" and not vim.tbl_contains(skip, name) then print(name .. ".md") local path = ("lua/snacks/%s"):format(file) local lines = vim.fn.readfile(path) local info = M.extract(lines) M.write(name, M.render(name, info)) if name == "init" then local readme = table.concat(vim.fn.readfile("README.md"), "\n") local example = table.concat(vim.fn.readfile("docs/example.lua"), "\n") example = example:gsub(".*\nreturn {", "{", 1) readme = M.replace("config", readme, M.md(info.config)) readme = M.replace("example", readme, M.md(example)) vim.fn.writefile(vim.split(readme, "\n"), "README.md") end end end vim.cmd.checktime() end function M.fix_titles() for file, t in vim.fs.dir("doc", { depth = 1 }) do if t == "file" and file:find("%.txt$") then local lines = vim.fn.readfile("doc/" .. file) --[[@as string[] ]] for i, line in ipairs(lines) do -- Example: SNACKS.GIT.BLAME_LINE() *snacks-git-module-snacks.git.blame_line()* local func = line:gsub("^SNACKS.*module%-snacks(.+%(%))%*$", "Snacks%1") if func ~= line then local left = ("`%s`"):format(func) local right = ("*%s*"):format(func) line = left .. string.rep(" ", #line - #left - #right) .. right lines[i] = line end end vim.fn.writefile(lines, "doc/" .. file) end end vim.cmd.helptags("doc") end function M.build() local ok, err = pcall(M._build) if not ok then vim.api.nvim_err_writeln(err) os.exit(1) end end return M