gen_vimdoc.lua (30961B)
1 #!/usr/bin/env -S nvim -l 2 --- Generates Nvim :help docs from Lua/C docstrings. 3 --- 4 --- Usage: 5 --- make doc 6 --- 7 --- The generated :help text for each function is formatted as follows: 8 --- - Max width of 78 columns (`TEXT_WIDTH`). 9 --- - Indent with spaces (not tabs). 10 --- - Indent of 4 columns for body text (`INDENTATION`). 11 --- - Function signature and helptag (right-aligned) on the same line. 12 --- - Signature and helptag must have a minimum of 8 spaces between them. 13 --- - If the signature is too long, it is placed on the line after the helptag. 14 --- Signature wraps with subsequent lines indented to the open parenthesis. 15 --- - Subsection bodies are indented an additional 4 spaces. 16 --- - Body consists of function description, parameters, return description, and 17 --- C declaration (`INCLUDE_C_DECL`). 18 --- - Parameters are omitted for the `void` and `Error *` types, or if the 19 --- parameter is marked as [out]. 20 --- - Each function documentation is separated by a single line. 21 22 local luacats_parser = require('gen.luacats_parser') 23 local cdoc_parser = require('gen.cdoc_parser') 24 local util = require('gen.util') 25 26 local fmt = string.format 27 28 local wrap = util.wrap 29 local md_to_vimdoc = util.md_to_vimdoc 30 31 local TEXT_WIDTH = 78 32 local INDENTATION = 4 33 34 --- @class (exact) nvim.gen_vimdoc.Config 35 --- 36 --- Generated documentation target, e.g. api.txt 37 --- @field filename string 38 --- 39 --- @field section_order string[] 40 --- 41 --- List of files/directories for doxygen to read, relative to `base_dir`. 42 --- @field files string[] 43 --- 44 --- Section name overrides. Key: filename (e.g., vim.c) 45 --- @field section_name? table<string,string> 46 --- 47 --- @field fn_name_pat? string 48 --- 49 --- @field fn_xform? fun(fun: nvim.luacats.parser.fun) 50 --- 51 --- For generated section names. 52 --- @field section_fmt fun(name: string): string 53 --- 54 --- @field helptag_fmt fun(name: string): string|string[] 55 --- 56 --- Per-function helptag. 57 --- @field fn_helptag_fmt? fun(fun: nvim.luacats.parser.fun): string 58 --- 59 --- @field append_only? string[] 60 61 local function contains(t, xs) 62 return vim.tbl_contains(xs, t) 63 end 64 65 --- @type {level:integer, prerelease:boolean}? 66 local nvim_api_info_ 67 68 --- @return {level: integer, prerelease:boolean} 69 local function nvim_api_info() 70 if not nvim_api_info_ then 71 --- @type integer?, boolean? 72 local level, prerelease 73 for l in io.lines('CMakeLists.txt') do 74 --- @cast l string 75 if level and prerelease then 76 break 77 end 78 local m1 = l:match('^set%(NVIM_API_LEVEL%s+(%d+)%)') 79 if m1 then 80 level = tonumber(m1) --[[@as integer]] 81 end 82 local m2 = l:match('^set%(NVIM_API_PRERELEASE%s+(%w+)%)') 83 if m2 then 84 prerelease = m2 == 'true' 85 end 86 end 87 nvim_api_info_ = { level = level, prerelease = prerelease } 88 end 89 90 return nvim_api_info_ 91 end 92 93 --- @param fun nvim.luacats.parser.fun 94 --- @return string 95 local function fn_helptag_fmt_common(fun) 96 local fn_sfx = fun.table and '' or '()' 97 if fun.classvar then 98 return fmt('%s:%s%s', fun.classvar, fun.name, fn_sfx) 99 end 100 if fun.module then 101 return fmt('%s.%s%s', fun.module, fun.name, fn_sfx) 102 end 103 return fun.name .. fn_sfx 104 end 105 106 --- @type table<string,nvim.gen_vimdoc.Config> 107 local config = { 108 api = { 109 filename = 'api.txt', 110 section_order = { 111 -- Sections at the top, in a specific order: 112 'events.c', 113 'vim.c', 114 'vimscript.c', 115 116 -- Sections in alphanumeric order: 117 'autocmd.c', 118 'buffer.c', 119 'command.c', 120 'extmark.c', 121 'options.c', 122 'tabpage.c', 123 'ui.c', 124 'win_config.c', 125 'window.c', 126 }, 127 fn_name_pat = 'nvim_.*', 128 files = { 'src/nvim/api' }, 129 section_name = { 130 ['vim.c'] = 'Global', 131 }, 132 section_fmt = function(name) 133 if name == 'Events' then 134 return 'Global Events' 135 end 136 137 return name .. ' Functions' 138 end, 139 helptag_fmt = function(name) 140 return fmt('api-%s', name:lower()) 141 end, 142 fn_helptag_fmt = function(fun) 143 local name = fun.name 144 if vim.endswith(name, '_event') then 145 return name 146 end 147 return fn_helptag_fmt_common(fun) 148 end, 149 }, 150 lua = { 151 filename = 'lua.txt', 152 section_order = { 153 -- Sections at the top, in a specific order: 154 'builtin.lua', 155 'options.lua', 156 'editor.lua', 157 '_inspector.lua', 158 'shared.lua', 159 160 -- Sections in alphanumeric order: 161 'base64.lua', 162 'filetype.lua', 163 'fs.lua', 164 'glob.lua', 165 'hl.lua', 166 'iter.lua', 167 'json.lua', 168 'keymap.lua', 169 'loader.lua', 170 'lpeg.lua', 171 'mpack.lua', 172 'net.lua', 173 'pos.lua', 174 'range.lua', 175 're.lua', 176 'regex.lua', 177 'secure.lua', 178 'snippet.lua', 179 'spell.lua', 180 'system.lua', 181 'text.lua', 182 'ui.lua', 183 'uri.lua', 184 'version.lua', 185 186 -- Sections at the end, in a specific order: 187 'ui2.lua', 188 }, 189 files = { 190 'runtime/lua/vim/_core/editor.lua', 191 'runtime/lua/vim/_core/options.lua', 192 'runtime/lua/vim/_core/shared.lua', 193 'runtime/lua/vim/_core/system.lua', 194 'runtime/lua/vim/_core/ui2.lua', 195 'runtime/lua/vim/_inspector.lua', 196 'runtime/lua/vim/_meta/base64.lua', 197 'runtime/lua/vim/_meta/builtin.lua', 198 'runtime/lua/vim/_meta/json.lua', 199 'runtime/lua/vim/_meta/lpeg.lua', 200 'runtime/lua/vim/_meta/mpack.lua', 201 'runtime/lua/vim/_meta/re.lua', 202 'runtime/lua/vim/_meta/regex.lua', 203 'runtime/lua/vim/_meta/spell.lua', 204 'runtime/lua/vim/filetype.lua', 205 'runtime/lua/vim/fs.lua', 206 'runtime/lua/vim/glob.lua', 207 'runtime/lua/vim/hl.lua', 208 'runtime/lua/vim/iter.lua', 209 'runtime/lua/vim/keymap.lua', 210 'runtime/lua/vim/loader.lua', 211 'runtime/lua/vim/net.lua', 212 'runtime/lua/vim/pos.lua', 213 'runtime/lua/vim/range.lua', 214 'runtime/lua/vim/secure.lua', 215 'runtime/lua/vim/snippet.lua', 216 'runtime/lua/vim/text.lua', 217 'runtime/lua/vim/ui.lua', 218 'runtime/lua/vim/uri.lua', 219 'runtime/lua/vim/version.lua', 220 }, 221 fn_xform = function(fun) 222 if contains(fun.module, { 'vim.uri', 'vim._core.shared', 'vim._core.editor' }) then 223 fun.module = 'vim' 224 end 225 226 if fun.module == 'vim' and contains(fun.name, { 'cmd', 'inspect' }) then 227 fun.table = nil 228 end 229 230 if fun.classvar or vim.startswith(fun.name, 'vim.') or fun.module == 'vim.iter' then 231 return 232 end 233 234 fun.name = fmt('%s.%s', fun.module, fun.name) 235 end, 236 section_name = { 237 ['_inspector.lua'] = 'inspector', 238 ['ui2.lua'] = 'ui2', 239 }, 240 section_fmt = function(name) 241 name = name:lower() 242 if name == 'editor' then 243 return 'Lua module: vim' 244 elseif name == 'system' then 245 return 'Lua module: vim.system' 246 elseif name == 'options' then 247 return 'LUA-VIMSCRIPT BRIDGE' 248 elseif name == 'builtin' then 249 return 'VIM' 250 elseif name == 'ui2' then 251 return 'UI2' 252 end 253 return 'Lua module: vim.' .. name 254 end, 255 helptag_fmt = function(name) 256 if name == 'Editor' then 257 return 'lua-vim' 258 elseif name == 'System' then 259 return 'lua-vim-system' 260 elseif name == 'Options' then 261 return 'lua-vimscript' 262 elseif name == 'ui2' then 263 return 'ui2' 264 end 265 return 'vim.' .. name:lower() 266 end, 267 fn_helptag_fmt = function(fun) 268 local name = fun.name 269 270 if vim.startswith(name, 'vim.') then 271 local fn_sfx = fun.table and '' or '()' 272 return name .. fn_sfx 273 elseif fun.classvar == 'Option' then 274 return fmt('vim.opt:%s()', name) 275 end 276 277 return fn_helptag_fmt_common(fun) 278 end, 279 append_only = { 280 'shared.lua', 281 }, 282 }, 283 lsp = { 284 filename = 'lsp.txt', 285 section_order = { 286 -- Sections at the top, in a specific order: 287 'lsp.lua', 288 289 -- Sections in alphanumeric order: 290 'buf.lua', 291 'client.lua', 292 'codelens.lua', 293 'completion.lua', 294 'diagnostic.lua', 295 'document_color.lua', 296 'folding_range.lua', 297 'handlers.lua', 298 'inlay_hint.lua', 299 'inline_completion.lua', 300 'linked_editing_range.lua', 301 'log.lua', 302 'on_type_formatting.lua', 303 'rpc.lua', 304 'semantic_tokens.lua', 305 'tagfunc.lua', 306 307 -- Sections at the end, in a specific order: 308 'util.lua', 309 'protocol.lua', 310 }, 311 files = { 312 'runtime/lua/vim/lsp', 313 'runtime/lua/vim/lsp.lua', 314 }, 315 fn_xform = function(fun) 316 fun.name = fun.name:gsub('result%.', '') 317 if fun.module == 'vim.lsp.protocol' then 318 fun.classvar = nil 319 end 320 end, 321 section_fmt = function(name) 322 if name:lower() == 'lsp' then 323 return 'Lua module: vim.lsp' 324 end 325 return 'Lua module: vim.lsp.' .. name:lower() 326 end, 327 helptag_fmt = function(name) 328 if name:lower() == 'lsp' then 329 return 'lsp-core' 330 end 331 return fmt('lsp-%s', name:lower()) 332 end, 333 }, 334 diagnostic = { 335 filename = 'diagnostic.txt', 336 section_order = { 337 'diagnostic.lua', 338 }, 339 files = { 'runtime/lua/vim/diagnostic.lua' }, 340 section_fmt = function() 341 return 'Lua module: vim.diagnostic' 342 end, 343 helptag_fmt = function() 344 return 'diagnostic-api' 345 end, 346 }, 347 treesitter = { 348 filename = 'treesitter.txt', 349 section_order = { 350 -- Sections at the top, in a specific order: 351 'tstree.lua', 352 'tsnode.lua', 353 'treesitter.lua', 354 355 -- Sections in alphanumeric order: 356 'dev.lua', 357 'highlighter.lua', 358 'language.lua', 359 'languagetree.lua', 360 'query.lua', 361 'tsquery.lua', 362 }, 363 append_only = { 'tsquery.lua' }, 364 files = { 365 'runtime/lua/vim/treesitter/_meta/', 366 'runtime/lua/vim/treesitter.lua', 367 'runtime/lua/vim/treesitter/', 368 }, 369 section_fmt = function(name) 370 if name:lower() == 'treesitter' then 371 return 'Lua module: vim.treesitter' 372 elseif name:lower() == 'tstree' then 373 return 'TREESITTER TREES' 374 elseif name:lower() == 'tsnode' then 375 return 'TREESITTER NODES' 376 end 377 return 'Lua module: vim.treesitter.' .. name:lower() 378 end, 379 helptag_fmt = function(name) 380 if name:lower() == 'treesitter' then 381 return 'lua-treesitter-core' 382 elseif name:lower() == 'query' then 383 return 'lua-treesitter-query' 384 elseif name:lower() == 'tstree' then 385 return { 'treesitter-tree', 'TSTree' } 386 elseif name:lower() == 'tsnode' then 387 return { 'treesitter-node', 'TSNode' } 388 end 389 return 'treesitter-' .. name:lower() 390 end, 391 }, 392 health = { 393 filename = 'health.txt', 394 files = { 395 'runtime/lua/vim/health.lua', 396 }, 397 section_order = { 398 'health.lua', 399 }, 400 section_fmt = function(_name) 401 return 'Checkhealth' 402 end, 403 helptag_fmt = function() 404 return { 'vim.health', 'health' } 405 end, 406 }, 407 pack = { 408 filename = 'pack.txt', 409 files = { 'runtime/lua/vim/pack.lua' }, 410 section_order = { 'pack.lua' }, 411 section_fmt = function(_name) 412 return 'Plugin manager' 413 end, 414 helptag_fmt = function() 415 return { 'vim.pack' } 416 end, 417 }, 418 plugins = { 419 filename = 'plugins.txt', 420 section_order = { 421 'difftool.lua', 422 'editorconfig.lua', 423 'spellfile.lua', 424 'tohtml.lua', 425 'undotree.lua', 426 }, 427 files = { 428 'runtime/lua/editorconfig.lua', 429 'runtime/lua/tohtml.lua', 430 'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua', 431 'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua', 432 'runtime/lua/nvim/spellfile.lua', 433 }, 434 fn_xform = function(fun) 435 if fun.module == 'editorconfig' then 436 -- Example: "editorconfig.properties.root()" => "editorconfig.root" 437 fun.table = true 438 fun.name = vim.split(fun.name, '.', { plain = true })[2] or fun.name 439 end 440 if vim.startswith(fun.module, 'nvim.') then 441 fun.module = fun.module:sub(#'nvim.' + 1) 442 end 443 end, 444 section_fmt = function(name) 445 return 'Builtin plugin: ' .. name:lower() 446 end, 447 helptag_fmt = function(name) 448 name = name:lower() 449 if name == 'spellfile' then 450 name = 'spellfile.lua' 451 elseif name == 'undotree' then 452 name = 'undotree-plugin' 453 end 454 return name 455 end, 456 }, 457 } 458 459 --- @param ty string 460 --- @param generics table<string,string> 461 --- @return string 462 local function replace_generics(ty, generics) 463 if ty:sub(-2) == '[]' then 464 local ty0 = ty:sub(1, -3) 465 if generics[ty0] then 466 return generics[ty0] .. '[]' 467 end 468 elseif ty:sub(-1) == '?' then 469 local ty0 = ty:sub(1, -2) 470 if generics[ty0] then 471 return generics[ty0] .. '?' 472 end 473 end 474 475 return generics[ty] or ty 476 end 477 478 --- @param name string 479 local function fmt_field_name(name) 480 local name0, opt = name:match('^([^?]*)(%??)$') 481 return fmt('{%s}%s', name0, opt) 482 end 483 484 --- @param ty string 485 --- @param generics? table<string,string> 486 --- @param default? string 487 local function render_type(ty, generics, default) 488 ty = ty:gsub('vim%.lsp%.protocol%.Method.[%w.]+', 'string') 489 490 if generics then 491 ty = replace_generics(ty, generics) 492 end 493 ty = ty:gsub('%s*|%s*nil', '?') 494 ty = ty:gsub('nil%s*|%s*(.*)', '%1?') 495 ty = ty:gsub('%s*|%s*', '|') 496 if default then 497 return fmt('(`%s`, default: %s)', ty, default) 498 end 499 return fmt('(`%s`)', ty) 500 end 501 502 --- @param p nvim.luacats.parser.param|nvim.luacats.parser.field 503 local function should_render_field_or_param(p) 504 return not p.nodoc 505 and not p.access 506 and not contains(p.name, { '_', 'self' }) 507 and not vim.startswith(p.name, '_') 508 end 509 510 --- @param desc? string 511 --- @return string?, string? 512 local function get_default(desc) 513 if not desc then 514 return 515 end 516 517 local default = desc:match('\n%s*%([dD]efault: ([^)]+)%)') 518 if default then 519 desc = desc:gsub('\n%s*%([dD]efault: [^)]+%)', '') 520 end 521 522 return desc, default 523 end 524 525 --- @param ty string 526 --- @param classes? table<string,nvim.luacats.parser.class> 527 --- @return nvim.luacats.parser.class? 528 local function get_class(ty, classes) 529 if not classes then 530 return 531 end 532 533 local cty = ty:gsub('%s*|%s*nil', '?'):gsub('?$', ''):gsub('%[%]$', '') 534 535 return classes[cty] 536 end 537 538 --- @param obj nvim.luacats.parser.param|nvim.luacats.parser.return|nvim.luacats.parser.field 539 --- @param classes? table<string,nvim.luacats.parser.class> 540 local function inline_type(obj, classes) 541 local ty = obj.type 542 if not ty then 543 return 544 end 545 546 local cls = get_class(ty, classes) 547 548 if not cls or cls.nodoc then 549 return 550 end 551 552 if not cls.inlinedoc then 553 -- Not inlining so just add a: "See |tag|." 554 local tag = fmt('|%s|', cls.name) 555 if obj.desc and obj.desc:find(tag) then 556 -- Tag already there 557 return 558 end 559 560 -- TODO(lewis6991): Aim to remove this. Need this to prevent dead 561 -- references to types defined in runtime/lua/vim/lsp/_meta/protocol.lua 562 if not vim.startswith(cls.name, 'vim.') then 563 return 564 end 565 566 obj.desc = obj.desc or '' 567 local period = (obj.desc == '' or vim.endswith(obj.desc, '.')) and '' or '.' 568 obj.desc = obj.desc .. fmt('%s See %s.', period, tag) 569 return 570 end 571 572 local ty_isopt = (ty:match('%?$') or ty:match('%s*|%s*nil')) ~= nil 573 local ty_islist = (ty:match('%[%]$')) ~= nil 574 ty = ty_isopt and 'table?' or ty_islist and 'table[]' or 'table' 575 576 local desc = obj.desc or '' 577 if cls.desc then 578 desc = desc .. cls.desc 579 elseif desc == '' then 580 if ty_islist then 581 desc = desc .. 'A list of objects with the following fields:' 582 elseif cls.parent then 583 desc = desc .. fmt('Extends |%s| with the additional fields:', cls.parent) 584 else 585 desc = desc .. 'A table with the following fields:' 586 end 587 end 588 589 local desc_append = {} 590 for _, f in ipairs(cls.fields) do 591 if not f.access then 592 local fdesc, default = get_default(f.desc) 593 local fty = render_type(f.type, nil, default) 594 local fnm = fmt_field_name(f.name) 595 table.insert(desc_append, table.concat({ '-', fnm, fty, fdesc }, ' ')) 596 end 597 end 598 599 desc = desc .. '\n' .. table.concat(desc_append, '\n') 600 obj.type = ty 601 obj.desc = desc 602 end 603 604 --- @param xs (nvim.luacats.parser.param|nvim.luacats.parser.field)[] 605 --- @param generics? table<string,string> 606 --- @param classes? table<string,nvim.luacats.parser.class> 607 --- @param cfg nvim.gen_vimdoc.Config 608 local function render_fields_or_params(xs, generics, classes, cfg) 609 local ret = {} --- @type string[] 610 611 xs = vim.tbl_filter(should_render_field_or_param, xs) 612 613 local indent = 0 614 for _, p in ipairs(xs) do 615 if p.type or p.desc then 616 indent = math.max(indent, #p.name + 3) 617 end 618 end 619 620 for _, p in ipairs(xs) do 621 local pdesc, default = get_default(p.desc) 622 p.desc = pdesc 623 624 inline_type(p, classes) 625 local nm, ty = p.name, p.type 626 627 local desc = p.classvar and fmt('See |%s|.', cfg.fn_helptag_fmt(p)) or p.desc 628 629 local fnm = p.kind == 'operator' and fmt('op(%s)', nm) or fmt_field_name(nm) 630 local pnm = fmt(' • %-' .. indent .. 's', fnm) 631 632 if ty then 633 local pty = render_type(ty, generics, default) 634 635 if desc then 636 table.insert(ret, pnm) 637 if #pty > TEXT_WIDTH - indent then 638 vim.list_extend(ret, { ' ', pty, '\n' }) 639 table.insert(ret, md_to_vimdoc(desc, 9 + indent, 9 + indent, TEXT_WIDTH, true)) 640 else 641 desc = fmt('%s %s', pty, desc) 642 table.insert(ret, md_to_vimdoc(desc, 1, 9 + indent, TEXT_WIDTH, true)) 643 end 644 else 645 table.insert(ret, fmt('%s %s\n', pnm, pty)) 646 end 647 else 648 if desc then 649 table.insert(ret, pnm) 650 table.insert(ret, md_to_vimdoc(desc, 1, 9 + indent, TEXT_WIDTH, true)) 651 end 652 end 653 end 654 655 return table.concat(ret) 656 end 657 658 --- @param class nvim.luacats.parser.class 659 --- @param classes table<string,nvim.luacats.parser.class> 660 --- @param cfg nvim.gen_vimdoc.Config 661 local function render_class(class, classes, cfg) 662 if class.access or class.nodoc or class.inlinedoc then 663 return 664 end 665 666 local ret = {} --- @type string[] 667 668 table.insert(ret, fmt('*%s*\n', class.name)) 669 670 if class.parent then 671 local txt = fmt('Extends: |%s|', class.parent) 672 table.insert(ret, md_to_vimdoc(txt, INDENTATION, INDENTATION, TEXT_WIDTH)) 673 table.insert(ret, '\n') 674 end 675 676 if class.desc then 677 table.insert(ret, md_to_vimdoc(class.desc, INDENTATION, INDENTATION, TEXT_WIDTH)) 678 end 679 680 local fields_txt = render_fields_or_params(class.fields, nil, classes, cfg) 681 if not fields_txt:match('^%s*$') then 682 table.insert(ret, '\n Fields: ~\n') 683 table.insert(ret, fields_txt) 684 end 685 table.insert(ret, '\n') 686 687 return table.concat(ret) 688 end 689 690 --- @param classes table<string,nvim.luacats.parser.class> 691 --- @param cfg nvim.gen_vimdoc.Config 692 local function render_classes(classes, cfg) 693 local ret = {} --- @type string[] 694 695 for _, class in vim.spairs(classes) do 696 ret[#ret + 1] = render_class(class, classes, cfg) 697 end 698 699 return table.concat(ret) 700 end 701 702 --- @param fun nvim.luacats.parser.fun 703 --- @param cfg nvim.gen_vimdoc.Config 704 local function render_fun_header(fun, cfg) 705 local ret = {} --- @type string[] 706 707 local args = {} --- @type string[] 708 for _, p in ipairs(fun.params or {}) do 709 if p.name ~= 'self' then 710 args[#args + 1] = fmt_field_name(p.name) 711 end 712 end 713 714 local nm = fun.name 715 if fun.classvar then 716 nm = fmt('%s:%s', fun.classvar, nm) 717 end 718 if nm == 'vim.bo' then 719 nm = 'vim.bo[{bufnr}]' 720 end 721 if nm == 'vim.wo' then 722 nm = 'vim.wo[{winid}][{bufnr}]' 723 end 724 725 local proto = fun.table and nm or nm .. '(' .. table.concat(args, ', ') .. ')' 726 727 local tag = '*' .. cfg.fn_helptag_fmt(fun) .. '*' 728 729 if #proto + #tag > TEXT_WIDTH - 8 then 730 table.insert(ret, fmt('%78s\n', tag)) 731 local name, pargs = proto:match('([^(]+%()(.*)') 732 table.insert(ret, name) 733 table.insert(ret, wrap(pargs, 0, #name, TEXT_WIDTH)) 734 else 735 local pad = TEXT_WIDTH - #proto - #tag 736 table.insert(ret, proto .. string.rep(' ', pad) .. tag) 737 end 738 739 return table.concat(ret) 740 end 741 742 --- @param returns nvim.luacats.parser.return[] 743 --- @param generics? table<string,string> 744 --- @param classes? table<string,nvim.luacats.parser.class> 745 --- @return string? 746 local function render_returns(returns, generics, classes) 747 local ret = {} --- @type string[] 748 749 if #returns == 1 and returns[1].type == 'nil' then 750 return 751 end 752 753 if #returns > 1 then 754 table.insert(ret, ' Return (multiple): ~\n') 755 elseif #returns == 1 and next(returns[1]) then 756 table.insert(ret, ' Return: ~\n') 757 end 758 759 for _, p in ipairs(returns) do 760 inline_type(p, classes) 761 local rnm, ty, desc = p.name, p.type, p.desc 762 763 local blk = {} --- @type string[] 764 if ty then 765 blk[#blk + 1] = render_type(ty, generics) 766 end 767 blk[#blk + 1] = rnm 768 blk[#blk + 1] = desc 769 770 ret[#ret + 1] = md_to_vimdoc(table.concat(blk, ' '), 8, 8, TEXT_WIDTH, true) 771 end 772 773 return table.concat(ret) 774 end 775 776 --- @param fun nvim.luacats.parser.fun 777 --- @param classes table<string,nvim.luacats.parser.class> 778 --- @param cfg nvim.gen_vimdoc.Config 779 local function render_fun(fun, classes, cfg) 780 if fun.access or fun.deprecated or fun.nodoc then 781 return 782 end 783 784 if cfg.fn_name_pat and not fun.name:match(cfg.fn_name_pat) then 785 return 786 end 787 788 if vim.startswith(fun.name, '_') or fun.name:find('[:.]_') then 789 return 790 end 791 792 local ret = {} --- @type string[] 793 794 table.insert(ret, render_fun_header(fun, cfg)) 795 table.insert(ret, '\n') 796 797 if fun.since then 798 local since = assert(tonumber(fun.since), 'invalid @since on ' .. fun.name) 799 local info = nvim_api_info() 800 if since == 0 or (info.prerelease and since == info.level) then 801 -- Experimental = (since==0 or current prerelease) 802 local s = 'WARNING: This feature is experimental/unstable.' 803 table.insert(ret, md_to_vimdoc(s, INDENTATION, INDENTATION, TEXT_WIDTH)) 804 table.insert(ret, '\n') 805 else 806 local v = assert(util.version_level[since], 'invalid @since on ' .. fun.name) 807 fun.attrs = fun.attrs or {} 808 table.insert(fun.attrs, fmt('Since: %s', v)) 809 end 810 end 811 812 if fun.desc then 813 table.insert(ret, md_to_vimdoc(fun.desc, INDENTATION, INDENTATION, TEXT_WIDTH)) 814 end 815 816 if fun.notes then 817 table.insert(ret, '\n Note: ~\n') 818 for _, p in ipairs(fun.notes) do 819 table.insert(ret, ' • ' .. md_to_vimdoc(p.desc, 0, 8, TEXT_WIDTH, true)) 820 end 821 end 822 823 if fun.attrs then 824 table.insert(ret, '\n Attributes: ~\n') 825 for _, attr in ipairs(fun.attrs) do 826 local attr_str = ({ 827 textlock = 'not allowed when |textlock| is active or in the |cmdwin|', 828 textlock_allow_cmdwin = 'not allowed when |textlock| is active', 829 fast = '|api-fast|', 830 remote_only = '|RPC| only', 831 lua_only = 'Lua |vim.api| only', 832 })[attr] or attr 833 table.insert(ret, fmt(' %s\n', attr_str)) 834 end 835 end 836 837 if fun.params and #fun.params > 0 then 838 local param_txt = render_fields_or_params(fun.params, fun.generics, classes, cfg) 839 if not param_txt:match('^%s*$') then 840 table.insert(ret, '\n Parameters: ~\n') 841 ret[#ret + 1] = param_txt 842 end 843 end 844 845 if fun.overloads then 846 table.insert(ret, '\n Overloads: ~\n') 847 for _, p in ipairs(fun.overloads) do 848 table.insert(ret, fmt(' • `%s`\n', p)) 849 end 850 end 851 852 if fun.returns then 853 local txt = render_returns(fun.returns, fun.generics, classes) 854 if txt and not txt:match('^%s*$') then 855 table.insert(ret, '\n') 856 ret[#ret + 1] = txt 857 end 858 end 859 860 if fun.see then 861 table.insert(ret, '\n See also: ~\n') 862 for _, p in ipairs(fun.see) do 863 table.insert(ret, ' • ' .. md_to_vimdoc(p.desc, 0, 8, TEXT_WIDTH, true)) 864 end 865 end 866 867 table.insert(ret, '\n') 868 return table.concat(ret) 869 end 870 871 --- @param funs nvim.luacats.parser.fun[] 872 --- @param classes table<string,nvim.luacats.parser.class> 873 --- @param cfg nvim.gen_vimdoc.Config 874 local function render_funs(funs, classes, cfg) 875 local ret = {} --- @type string[] 876 877 for _, f in ipairs(funs) do 878 if cfg.fn_xform then 879 cfg.fn_xform(f) 880 end 881 ret[#ret + 1] = render_fun(f, classes, cfg) 882 end 883 884 -- Sort via prototype. Experimental API functions ("nvim__") sort last. 885 table.sort(ret, function(a, b) 886 local a1 = ('\n' .. a):match('\n[a-zA-Z_][^\n]+\n') 887 local b1 = ('\n' .. b):match('\n[a-zA-Z_][^\n]+\n') 888 889 local a1__ = a1:find('^%s*nvim__') and 1 or 0 890 local b1__ = b1:find('^%s*nvim__') and 1 or 0 891 if a1__ ~= b1__ then 892 return a1__ < b1__ 893 end 894 895 return a1:lower() < b1:lower() 896 end) 897 898 return table.concat(ret) 899 end 900 901 --- @return string 902 local function get_script_path() 903 local str = debug.getinfo(2, 'S').source:gsub('^@', '') 904 return str:match('(.*[/\\])') or './' 905 end 906 907 local script_path = get_script_path() 908 local base_dir = vim.fs.dirname(vim.fs.dirname(vim.fs.dirname(script_path))) 909 910 local function delete_lines_below(doc_file, tokenstr) 911 local lines = {} --- @type string[] 912 local found = false 913 for line in io.lines(doc_file) do 914 if line:find(vim.pesc(tokenstr)) then 915 found = true 916 break 917 end 918 lines[#lines + 1] = line 919 end 920 if not found then 921 error(fmt('not found: %s in %s', tokenstr, doc_file)) 922 end 923 lines[#lines] = nil 924 local fp = assert(io.open(doc_file, 'w')) 925 fp:write(table.concat(lines, '\n')) 926 fp:write('\n') 927 fp:close() 928 end 929 930 --- @param x string 931 local function mktitle(x) 932 if x == 'ui' then 933 return 'UI' 934 end 935 return x:sub(1, 1):upper() .. x:sub(2) 936 end 937 938 --- @class nvim.gen_vimdoc.Section 939 --- @field name string 940 --- @field title string 941 --- @field help_tag string 942 --- @field funs_txt string 943 --- @field classes_txt string 944 --- @field briefs string[] 945 946 --- @param filename string 947 --- @param cfg nvim.gen_vimdoc.Config 948 --- @param briefs string[] 949 --- @param funs_txt string 950 --- @param classes_txt string 951 --- @return nvim.gen_vimdoc.Section? 952 local function make_section(filename, cfg, briefs, funs_txt, classes_txt) 953 -- filename: e.g., 'autocmd.c' 954 -- name: e.g. 'autocmd' 955 local name = filename:match('(.*)%.[a-z]+') 956 957 -- Formatted (this is what's going to be written in the vimdoc) 958 -- e.g., "Autocmd Functions" 959 local sectname = cfg.section_name and cfg.section_name[filename] or mktitle(name) 960 961 -- section tag: e.g., "*api-autocmd*" 962 local help_labels = cfg.helptag_fmt(sectname) 963 if type(help_labels) == 'table' then 964 help_labels = table.concat(help_labels, '* *') 965 end 966 local help_tags = '*' .. help_labels .. '*' 967 968 if funs_txt == '' and classes_txt == '' and #briefs == 0 then 969 return 970 end 971 972 return { 973 name = sectname, 974 title = cfg.section_fmt(sectname), 975 help_tag = help_tags, 976 funs_txt = funs_txt, 977 classes_txt = classes_txt, 978 briefs = briefs, 979 } 980 end 981 982 --- @param section nvim.gen_vimdoc.Section 983 --- @param add_header? boolean 984 local function render_section(section, add_header) 985 local doc = {} --- @type string[] 986 987 if add_header ~= false then 988 vim.list_extend(doc, { 989 string.rep('=', TEXT_WIDTH), 990 '\n', 991 section.title, 992 fmt('%' .. (TEXT_WIDTH - section.title:len()) .. 's', section.help_tag), 993 }) 994 end 995 996 if next(section.briefs) then 997 local briefs_txt = {} --- @type string[] 998 for _, b in ipairs(section.briefs) do 999 briefs_txt[#briefs_txt + 1] = md_to_vimdoc(b, 0, 0, TEXT_WIDTH) 1000 end 1001 1002 local sdoc = '\n\n' .. table.concat(briefs_txt, '\n') 1003 if sdoc:find('[^%s]') then 1004 doc[#doc + 1] = sdoc 1005 end 1006 end 1007 1008 if section.classes_txt ~= '' then 1009 table.insert(doc, '\n\n') 1010 table.insert(doc, (section.classes_txt:gsub('\n+$', '\n'))) 1011 end 1012 1013 if section.funs_txt ~= '' then 1014 table.insert(doc, '\n\n') 1015 table.insert(doc, section.funs_txt) 1016 end 1017 1018 return table.concat(doc) 1019 end 1020 1021 local parsers = { 1022 lua = luacats_parser.parse, 1023 c = cdoc_parser.parse, 1024 h = cdoc_parser.parse, 1025 } 1026 1027 --- @param files string[] 1028 local function expand_files(files) 1029 for k, f in pairs(files) do 1030 if vim.fn.isdirectory(f) == 1 then 1031 table.remove(files, k) 1032 for path, ty in vim.fs.dir(f) do 1033 if ty == 'file' then 1034 table.insert(files, vim.fs.joinpath(f, path)) 1035 end 1036 end 1037 end 1038 end 1039 end 1040 1041 --- @param classes table<string,nvim.luacats.parser.class> 1042 --- @return string? 1043 local function find_module_class(classes, modvar) 1044 for nm, cls in pairs(classes) do 1045 local _, field = next(cls.fields or {}) 1046 if cls.desc and field and field.classvar == modvar then 1047 return nm 1048 end 1049 end 1050 end 1051 1052 --- @param cfg nvim.gen_vimdoc.Config 1053 local function gen_target(cfg) 1054 cfg.fn_helptag_fmt = cfg.fn_helptag_fmt or fn_helptag_fmt_common 1055 print('Target:', cfg.filename) 1056 local sections = {} --- @type table<string,nvim.gen_vimdoc.Section> 1057 1058 expand_files(cfg.files) 1059 1060 --- @type table<string,[table<string,nvim.luacats.parser.class>, nvim.luacats.parser.fun[], string[]]> 1061 local file_results = {} 1062 1063 --- @type table<string,nvim.luacats.parser.class> 1064 local all_classes = {} 1065 1066 --- First pass so we can collect all classes 1067 for _, f in vim.spairs(cfg.files) do 1068 local ext = f:match('%.([^.]+)$') 1069 local parser = parsers[ext] 1070 if parser then 1071 local classes, funs, briefs = parser(f) 1072 file_results[f] = { classes, funs, briefs } 1073 all_classes = vim.tbl_extend('error', all_classes, classes) 1074 end 1075 end 1076 1077 for f, r in vim.spairs(file_results) do 1078 local classes, funs, briefs = r[1], r[2], r[3] 1079 1080 local mod_cls_nm = find_module_class(classes, 'M') 1081 if mod_cls_nm then 1082 local mod_cls = classes[mod_cls_nm] 1083 classes[mod_cls_nm] = nil 1084 -- If the module documentation is present, add it to the briefs 1085 -- so it appears at the top of the section. 1086 briefs[#briefs + 1] = mod_cls.desc 1087 end 1088 1089 print(' Processing file:', f) 1090 1091 -- FIXME: Using f_base will confuse `_meta/protocol.lua` with `protocol.lua` 1092 local f_base = vim.fs.basename(f) 1093 sections[f_base] = make_section( 1094 f_base, 1095 cfg, 1096 briefs, 1097 render_funs(funs, all_classes, cfg), 1098 render_classes(classes, cfg) 1099 ) 1100 end 1101 1102 local first_section_tag = sections[cfg.section_order[1]].help_tag 1103 local docs = {} --- @type string[] 1104 for _, f in ipairs(cfg.section_order) do 1105 local section = sections[f] 1106 if section then 1107 print(fmt(" Rendering section: '%s'", section.title)) 1108 local add_sep_and_header = not vim.tbl_contains(cfg.append_only or {}, f) 1109 docs[#docs + 1] = render_section(section, add_sep_and_header) 1110 end 1111 end 1112 1113 table.insert( 1114 docs, 1115 fmt(' vim:tw=78:ts=8:sw=%d:sts=%d:et:ft=help:norl:\n', INDENTATION, INDENTATION) 1116 ) 1117 1118 local doc_file = vim.fs.joinpath(base_dir, 'runtime', 'doc', cfg.filename) 1119 1120 if vim.uv.fs_stat(doc_file) then 1121 delete_lines_below(doc_file, first_section_tag) 1122 end 1123 1124 local fp = assert(io.open(doc_file, 'a')) 1125 fp:write(table.concat(docs, '\n')) 1126 fp:close() 1127 end 1128 1129 local function run() 1130 for _, cfg in vim.spairs(config) do 1131 gen_target(cfg) 1132 end 1133 end 1134 1135 run()