neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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()