neovim

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

man.lua (24468B)


      1 local api, fn = vim.api, vim.fn
      2 
      3 local M = {}
      4 
      5 --- Run a system command and timeout after 10 seconds.
      6 --- @param cmd string[]
      7 --- @param silent boolean?
      8 --- @param env? table<string,string|number>
      9 --- @return string
     10 local function system(cmd, silent, env)
     11  if fn.executable(cmd[1]) == 0 then
     12    error(string.format('executable not found: "%s"', cmd[1]), 0)
     13  end
     14 
     15  local r = vim.system(cmd, { env = env, timeout = 10000 }):wait()
     16 
     17  if not silent then
     18    if r.code ~= 0 then
     19      local cmd_str = table.concat(cmd, ' ')
     20      error(string.format("command error '%s': %s", cmd_str, r.stderr))
     21    end
     22    assert(r.stdout ~= '')
     23  end
     24 
     25  return assert(r.stdout)
     26 end
     27 
     28 --- @enum Man.Attribute
     29 local Attrs = {
     30  None = 0,
     31  Bold = 1,
     32  Underline = 2,
     33  Italic = 3,
     34 }
     35 
     36 --- @param line string
     37 --- @param row integer
     38 --- @param hls {attr:Man.Attribute,row:integer,start:integer,final:integer}[]
     39 --- @return string
     40 local function render_line(line, row, hls)
     41  --- @type string[]
     42  local chars = {}
     43  local prev_char = ''
     44  local overstrike, escape, osc8 = false, false, false
     45 
     46  local attr = Attrs.None
     47  local byte = 0 -- byte offset
     48 
     49  local hls_start = #hls + 1
     50 
     51  --- @param code integer
     52  local function add_attr_hl(code)
     53    local continue_hl = true
     54    if code == 0 then
     55      attr = Attrs.None
     56      continue_hl = false
     57    elseif code == 1 then
     58      attr = Attrs.Bold
     59    elseif code == 22 then
     60      attr = Attrs.Bold
     61      continue_hl = false
     62    elseif code == 3 then
     63      attr = Attrs.Italic
     64    elseif code == 23 then
     65      attr = Attrs.Italic
     66      continue_hl = false
     67    elseif code == 4 then
     68      attr = Attrs.Underline
     69    elseif code == 24 then
     70      attr = Attrs.Underline
     71      continue_hl = false
     72    else
     73      attr = Attrs.None
     74      return
     75    end
     76 
     77    if continue_hl then
     78      hls[#hls + 1] = { attr = attr, row = row, start = byte, final = -1 }
     79    else
     80      for _, a in pairs(attr == Attrs.None and Attrs or { attr }) do
     81        for i = hls_start, #hls do
     82          if hls[i].attr == a and hls[i].final == -1 then
     83            hls[i].final = byte
     84          end
     85        end
     86      end
     87    end
     88  end
     89 
     90  -- Break input into UTF8 code points. ASCII code points (from 0x00 to 0x7f)
     91  -- can be represented in one byte. Any code point above that is represented by
     92  -- a leading byte (0xc0 and above) and continuation bytes (0x80 to 0xbf, or
     93  -- decimal 128 to 191).
     94  for char in line:gmatch('[^\128-\191][\128-\191]*') do
     95    if overstrike then
     96      local last_hl = hls[#hls]
     97      if char == prev_char then
     98        if char == '_' and attr == Attrs.Italic and last_hl and last_hl.final == byte then
     99          -- This underscore is in the middle of an italic word
    100          attr = Attrs.Italic
    101        else
    102          attr = Attrs.Bold
    103        end
    104      elseif prev_char == '_' then
    105        -- Even though underline is strictly what this should be. <bs>_ was used by nroff to
    106        -- indicate italics which wasn't possible on old typewriters so underline was used. Modern
    107        -- terminals now support italics so lets use that now.
    108        -- See:
    109        -- - https://unix.stackexchange.com/questions/274658/purpose-of-ascii-text-with-overstriking-file-format/274795#274795
    110        -- - https://cmd.inp.nsk.su/old/cmd2/manuals/unix/UNIX_Unleashed/ch08.htm
    111        -- attr = Attrs.Underline
    112        attr = Attrs.Italic
    113      elseif prev_char == '+' and char == 'o' then
    114        -- bullet (overstrike text '+^Ho')
    115        attr = Attrs.Bold
    116        char = '·'
    117      elseif prev_char == '·' and char == 'o' then
    118        -- bullet (additional handling for '+^H+^Ho^Ho')
    119        attr = Attrs.Bold
    120        char = '·'
    121      else
    122        -- use plain char
    123        attr = Attrs.None
    124      end
    125 
    126      -- Grow the previous highlight group if possible
    127      if last_hl and last_hl.attr == attr and last_hl.final == byte then
    128        last_hl.final = byte + #char
    129      else
    130        hls[#hls + 1] = { attr = attr, row = row, start = byte, final = byte + #char }
    131      end
    132 
    133      overstrike = false
    134      prev_char = ''
    135      byte = byte + #char
    136      chars[#chars + 1] = char
    137    elseif osc8 then
    138      -- eat characters until String Terminator or bell
    139      if (prev_char == '\027' and char == '\\') or char == '\a' then
    140        osc8 = false
    141      end
    142      prev_char = char
    143    elseif escape then
    144      -- Use prev_char to store the escape sequence
    145      prev_char = prev_char .. char
    146      -- We only want to match against SGR sequences, which consist of ESC
    147      -- followed by '[', then a series of parameter and intermediate bytes in
    148      -- the range 0x20 - 0x3f, then 'm'. (See ECMA-48, sections 5.4 & 8.3.117)
    149      --- @type string?
    150      local sgr = prev_char:match('^%[([\032-\063]*)m$')
    151      -- Ignore escape sequences with : characters, as specified by ITU's T.416
    152      -- Open Document Architecture and interchange format.
    153      if sgr and not sgr:find(':') then
    154        local match --- @type string?
    155        while sgr and #sgr > 0 do
    156          -- Match against SGR parameters, which may be separated by ';'
    157          --- @type string?, string?
    158          match, sgr = sgr:match('^(%d*);?(.*)')
    159          add_attr_hl(match + 0) -- coerce to number
    160        end
    161        escape = false
    162      elseif prev_char == ']8;' then
    163        osc8 = true
    164        escape = false
    165      elseif not prev_char:match('^[][][\032-\063]*$') then
    166        -- Stop looking if this isn't a partial CSI or OSC sequence
    167        escape = false
    168      end
    169    elseif char == '\027' then
    170      escape = true
    171      prev_char = ''
    172    elseif char == '\b' then
    173      overstrike = true
    174      prev_char = chars[#chars]
    175      byte = byte - #prev_char
    176      chars[#chars] = nil
    177    else
    178      byte = byte + #char
    179      chars[#chars + 1] = char
    180    end
    181  end
    182 
    183  return table.concat(chars, '')
    184 end
    185 
    186 local HlGroups = {
    187  [Attrs.Bold] = 'manBold',
    188  [Attrs.Underline] = 'manUnderline',
    189  [Attrs.Italic] = 'manItalic',
    190 }
    191 
    192 local function highlight_man_page()
    193  local mod = vim.bo.modifiable
    194  vim.bo.modifiable = true
    195 
    196  local lines = api.nvim_buf_get_lines(0, 0, -1, false)
    197 
    198  --- @type {attr:Man.Attribute,row:integer,start:integer,final:integer}[]
    199  local hls = {}
    200 
    201  for i, line in ipairs(lines) do
    202    lines[i] = render_line(line, i - 1, hls)
    203  end
    204 
    205  api.nvim_buf_set_lines(0, 0, -1, false, lines)
    206 
    207  for _, hl in ipairs(hls) do
    208    if hl.attr ~= Attrs.None then
    209      --- @diagnostic disable-next-line: deprecated
    210      api.nvim_buf_add_highlight(0, -1, HlGroups[hl.attr], hl.row, hl.start, hl.final)
    211    end
    212  end
    213 
    214  vim.bo.modifiable = mod
    215 end
    216 
    217 --- @param name? string
    218 --- @param sect? string
    219 local function get_path(name, sect)
    220  name = name or ''
    221  sect = sect or ''
    222  -- Some man implementations (OpenBSD) return all available paths from the
    223  -- search command. Previously, this function would simply select the first one.
    224  --
    225  -- However, some searches will report matches that are incorrect:
    226  -- man -w strlen may return string.3 followed by strlen.3, and therefore
    227  -- selecting the first would get us the wrong page. Thus, we must find the
    228  -- first matching one.
    229  --
    230  -- There's yet another special case here. Consider the following:
    231  -- If you run man -w strlen and string.3 comes up first, this is a problem. We
    232  -- should search for a matching named one in the results list.
    233  -- However, if you search for man -w clock_gettime, you will *only* get
    234  -- clock_getres.2, which is the right page. Searching the results for
    235  -- clock_gettime will no longer work. In this case, we should just use the
    236  -- first one that was found in the correct section.
    237  --
    238  -- Finally, we can avoid relying on -S or -s here since they are very
    239  -- inconsistently supported. Instead, call -w with a section and a name.
    240  local cmd --- @type string[]
    241  if sect == '' then
    242    cmd = { 'man', '-w', name }
    243  else
    244    cmd = { 'man', '-w', sect, name }
    245  end
    246 
    247  local lines = system(cmd, true)
    248  local results = vim.split(lines, '\n', { trimempty = true })
    249 
    250  if #results == 0 then
    251    return
    252  end
    253 
    254  -- `man -w /some/path` will return `/some/path` for any existent file, which
    255  -- stops us from actually determining if a path has a corresponding man file.
    256  -- Since `:Man /some/path/to/man/file` isn't supported anyway, we should just
    257  -- error out here if we detect this is the case.
    258  if sect == '' and #results == 1 and results[1] == name then
    259    return
    260  end
    261 
    262  -- find any that match the specified name
    263  --- @param v string
    264  local namematches = vim.tbl_filter(function(v)
    265    local tail = vim.fs.basename(v)
    266    return tail:find(name, 1, true) ~= nil
    267  end, results) or {}
    268  local sectmatches = {}
    269 
    270  if #namematches > 0 and sect ~= '' then
    271    --- @param v string
    272    sectmatches = vim.tbl_filter(function(v)
    273      return fn.fnamemodify(v, ':e') == sect
    274    end, namematches)
    275  end
    276 
    277  return (sectmatches[1] or namematches[1] or results[1]):gsub('\n+$', '')
    278 end
    279 
    280 --- Attempt to extract the name and sect out of 'name(sect)'
    281 --- otherwise just return the largest string of valid characters in ref
    282 --- @param ref string
    283 --- @return string? name
    284 --- @return string? sect
    285 --- @return string? err
    286 local function parse_ref(ref)
    287  if ref == '' or ref:sub(1, 1) == '-' then
    288    return nil, nil, ('invalid manpage reference "%s"'):format(ref)
    289  end
    290 
    291  -- match "<name>(<sect>)"
    292  -- note: name can contain spaces
    293  local name, sect = ref:match('([^()]+)%(([^()]+)%)')
    294  if name then
    295    -- see ':Man 3X curses' on why tolower.
    296    -- TODO(nhooyr) Not sure if this is portable across OSs
    297    -- but I have not seen a single uppercase section.
    298    return name, sect:lower()
    299  end
    300 
    301  name = ref:match('[^()]+')
    302  if not name then
    303    return nil, nil, ('invalid manpage reference "%s"'):format(ref)
    304  end
    305  return name
    306 end
    307 
    308 --- Attempts to find the path to a manpage based on the passed section and name.
    309 ---
    310 --- 1. If manpage could not be found with the given sect and name,
    311 ---    then try all the sections in b:man_default_sects.
    312 --- 2. If it still could not be found, then we try again without a section.
    313 --- 3. If still not found but $MANSECT is set, then we try again with $MANSECT
    314 ---    unset.
    315 --- 4. If a path still wasn't found, return nil.
    316 --- @param name string?
    317 --- @param sect string?
    318 --- @return string? path
    319 function M._find_path(name, sect)
    320  if sect and sect ~= '' then
    321    local ret = get_path(name, sect)
    322    if ret then
    323      return ret
    324    end
    325  end
    326 
    327  if vim.b.man_default_sects ~= nil then
    328    for sec in vim.gsplit(vim.b.man_default_sects, ',', { trimempty = true }) do
    329      local ret = get_path(name, sec)
    330      if ret then
    331        return ret
    332      end
    333    end
    334  end
    335 
    336  -- if none of the above worked, we will try with no section
    337  local ret = get_path(name)
    338  if ret then
    339    return ret
    340  end
    341 
    342  -- if that still didn't work, we will check for $MANSECT and try again with it
    343  -- unset
    344  if vim.env.MANSECT then
    345    --- @type string
    346    local mansect = vim.env.MANSECT
    347    vim.env.MANSECT = nil
    348    local res = get_path(name)
    349    vim.env.MANSECT = mansect
    350    if res then
    351      return res
    352    end
    353  end
    354 
    355  -- finally, if that didn't work, there is no hope
    356  return nil
    357 end
    358 
    359 --- Extracts the name/section from the 'path/name.sect', because sometimes the
    360 --- actual section is more specific than what we provided to `man`
    361 --- (try `:Man 3 App::CLI`). Also on linux, name seems to be case-insensitive.
    362 --- So for `:Man PRIntf`, we still want the name of the buffer to be 'printf'.
    363 --- @param path string
    364 --- @return string name
    365 --- @return string sect
    366 local function parse_path(path)
    367  local tail = vim.fs.basename(path)
    368  if
    369    path:match('%.[glx]z$')
    370    or path:match('%.bz2$')
    371    or path:match('%.lzma$')
    372    or path:match('%.Z$')
    373  then
    374    tail = fn.fnamemodify(tail, ':r')
    375  end
    376  return tail:match('^(.+)%.([^.]+)$')
    377 end
    378 
    379 --- @return boolean
    380 local function find_man()
    381  if vim.bo.filetype == 'man' then
    382    return true
    383  end
    384 
    385  local win = 1
    386  while win <= fn.winnr('$') do
    387    local buf = fn.winbufnr(win)
    388    if vim.bo[buf].filetype == 'man' then
    389      vim.cmd(win .. 'wincmd w')
    390      return true
    391    end
    392    win = win + 1
    393  end
    394  return false
    395 end
    396 
    397 local function set_options()
    398  vim.bo.swapfile = false
    399  vim.bo.buftype = 'nofile'
    400  vim.bo.bufhidden = 'unload'
    401  vim.bo.modified = false
    402  vim.bo.readonly = true
    403  vim.bo.modifiable = false
    404  vim.bo.filetype = 'man'
    405 end
    406 
    407 --- Always use -l if possible. #6683
    408 --- @type boolean?
    409 local localfile_arg
    410 
    411 --- @param path string
    412 --- @param silent boolean?
    413 --- @return string
    414 local function get_page(path, silent)
    415  -- Disable hard-wrap by using a big $MANWIDTH (max 1000 on some systems #9065).
    416  -- Soft-wrap: ftplugin/man.lua sets wrap/breakindent/….
    417  -- Hard-wrap: driven by `man`.
    418  local manwidth --- @type integer
    419  if (vim.g.man_hardwrap or 1) ~= 1 then
    420    manwidth = 999
    421  elseif vim.env.MANWIDTH then
    422    vim.env.MANWIDTH = tonumber(vim.env.MANWIDTH) or 0
    423    manwidth = math.min(vim.env.MANWIDTH, api.nvim_win_get_width(0) - vim.o.wrapmargin)
    424  else
    425    manwidth = api.nvim_win_get_width(0) - vim.o.wrapmargin
    426  end
    427 
    428  if localfile_arg == nil then
    429    local mpath = get_path('man')
    430    -- Check for -l support.
    431    localfile_arg = (mpath and system({ 'man', '-l', mpath }, true, { MANPAGER = 'cat' }) or '')
    432      ~= ''
    433  end
    434 
    435  local cmd = localfile_arg and { 'man', '-l', path } or { 'man', path }
    436 
    437  -- Force MANPAGER=cat to ensure Vim is not recursively invoked (by man-db).
    438  -- http://comments.gmane.org/gmane.editors.vim.devel/29085
    439  -- Set MAN_KEEP_FORMATTING so Debian man doesn't discard backspaces.
    440  return system(cmd, silent, {
    441    MANPAGER = 'cat',
    442    MANWIDTH = manwidth,
    443    MAN_KEEP_FORMATTING = 1,
    444  })
    445 end
    446 
    447 --- @param path string
    448 --- @param psect string
    449 local function format_candidate(path, psect)
    450  if vim.endswith(path, '.pdf') or vim.endswith(path, '.in') then
    451    -- invalid extensions
    452    return ''
    453  end
    454  local name, sect = parse_path(path)
    455  if sect == psect then
    456    return name
    457  elseif sect:match(psect .. '.+$') then -- invalid extensions
    458    -- We include the section if the user provided section is a prefix
    459    -- of the actual section.
    460    return ('%s(%s)'):format(name, sect)
    461  end
    462  return ''
    463 end
    464 
    465 --- @param name string
    466 --- @param sect? string
    467 --- @return string[] paths
    468 --- @return string? err
    469 local function get_paths(name, sect)
    470  -- Try several sources for getting the list man directories:
    471  --   1. `manpath -q`
    472  --   2. `man -w` (works on most systems)
    473  --   3. $MANPATH
    474  --
    475  -- Note we prefer `manpath -q` because `man -w`:
    476  -- - does not work on MacOS 14 and later.
    477  -- - only returns '/usr/bin/man' on MacOS 13 and earlier.
    478  --- @type string?
    479  local mandirs_raw = vim.F.npcall(system, { 'manpath', '-q' })
    480    or vim.F.npcall(system, { 'man', '-w' })
    481    or vim.env.MANPATH
    482 
    483  if not mandirs_raw then
    484    return {}, "Could not determine man directories from: 'man -w', 'manpath' or $MANPATH"
    485  end
    486 
    487  local mandirs = table.concat(vim.split(mandirs_raw, '[:\n]', { trimempty = true }), ',')
    488 
    489  sect = sect or ''
    490 
    491  --- @type string[]
    492  local paths = fn.globpath(mandirs, 'man[^\\/]*/' .. name .. '*.' .. sect .. '*', false, true)
    493 
    494  -- Prioritize the result from find_path as it obeys b:man_default_sects.
    495  local first = M._find_path(name, sect)
    496  if first then
    497    --- @param v string
    498    paths = vim.tbl_filter(function(v)
    499      return v ~= first
    500    end, paths)
    501    table.insert(paths, 1, first)
    502  end
    503 
    504  return paths
    505 end
    506 
    507 --- @param arg_lead string
    508 --- @param cmd_line string
    509 --- @return string? sect
    510 --- @return string? psect
    511 --- @return string? name
    512 local function parse_cmdline(arg_lead, cmd_line)
    513  local args = vim.split(cmd_line, '%s+', { trimempty = true })
    514  local cmd_offset = fn.index(args, 'Man')
    515  if cmd_offset > 0 then
    516    -- Prune all arguments up to :Man itself. Otherwise modifier commands like
    517    -- :tab, :vertical, etc. would lead to a wrong length.
    518    args = vim.list_slice(args, cmd_offset + 1)
    519  end
    520 
    521  if #args > 3 then
    522    return
    523  end
    524 
    525  if #args == 1 then
    526    -- returning full completion is laggy. Require some arg_lead to complete
    527    -- return '', '', ''
    528    return
    529  end
    530 
    531  if arg_lead:match('^[^()]+%([^()]*$') then
    532    -- cursor (|) is at ':Man printf(|' or ':Man 1 printf(|'
    533    -- The later is is allowed because of ':Man pri<TAB>'.
    534    -- It will offer 'priclass.d(1m)' even though section is specified as 1.
    535    local tmp = vim.split(arg_lead, '(', { plain = true })
    536    local name = tmp[1]
    537    -- See extract_sect_and_name_ref on why :lower()
    538    local sect = (tmp[2] or ''):lower()
    539    return sect, '', name
    540  end
    541 
    542  if not args[2]:match('^[^()]+$') then
    543    -- cursor (|) is at ':Man 3() |' or ':Man (3|' or ':Man 3() pri|'
    544    -- or ':Man 3() pri |'
    545    return
    546  end
    547 
    548  if #args == 2 then
    549    --- @type string, string
    550    local name, sect
    551    if arg_lead == '' then
    552      -- cursor (|) is at ':Man 1 |'
    553      name = ''
    554      sect = args[1]:lower()
    555    else
    556      -- cursor (|) is at ':Man pri|'
    557      if arg_lead:match('/') then
    558        -- if the name is a path, complete files
    559        -- TODO(nhooyr) why does this complete the last one automatically
    560        return fn.glob(arg_lead .. '*', false, true)
    561      end
    562      name = arg_lead
    563      sect = ''
    564    end
    565    return sect, sect, name
    566  end
    567 
    568  if not arg_lead:match('[^()]+$') then
    569    -- cursor (|) is at ':Man 3 printf |' or ':Man 3 (pr)i|'
    570    return
    571  end
    572 
    573  -- cursor (|) is at ':Man 3 pri|'
    574  local name, sect = arg_lead, args[2]:lower()
    575  return sect, sect, name
    576 end
    577 
    578 --- @param arg_lead string
    579 --- @param cmd_line string
    580 function M.man_complete(arg_lead, cmd_line)
    581  local sect, psect, name = parse_cmdline(arg_lead, cmd_line)
    582  if not (sect and psect and name) then
    583    return {}
    584  end
    585 
    586  local ok, pages = pcall(get_paths, name, sect)
    587  if not ok then
    588    return nil
    589  end
    590 
    591  -- We check for duplicates in case the same manpage in different languages
    592  -- was found.
    593  local pages_fmt = {} --- @type string[]
    594  local pages_fmt_keys = {} --- @type table<string,true>
    595  for _, v in ipairs(pages) do
    596    local x = format_candidate(v, psect)
    597    local xl = x:lower() -- ignore case when searching avoiding duplicates
    598    if not pages_fmt_keys[xl] then
    599      pages_fmt[#pages_fmt + 1] = x
    600      pages_fmt_keys[xl] = true
    601    end
    602  end
    603  table.sort(pages_fmt)
    604 
    605  return pages_fmt
    606 end
    607 
    608 --- @param pattern string
    609 --- @return {name:string,filename:string,cmd:string}[]
    610 function M.goto_tag(pattern, _, _)
    611  local name, sect, err = parse_ref(pattern)
    612  if err then
    613    error(err)
    614  end
    615 
    616  local paths, err2 = get_paths(assert(name), sect)
    617  if err2 then
    618    error(err2)
    619  end
    620 
    621  --- @type table[]
    622  local ret = {}
    623 
    624  for _, path in ipairs(paths) do
    625    local pname, psect = parse_path(path)
    626    ret[#ret + 1] = {
    627      name = pname,
    628      filename = ('man://%s(%s)'):format(pname, psect),
    629      cmd = '1',
    630    }
    631  end
    632 
    633  return ret
    634 end
    635 
    636 --- Called when Nvim is invoked as $MANPAGER.
    637 function M.init_pager()
    638  if fn.getline(1):match('^%s*$') then
    639    api.nvim_buf_set_lines(0, 0, 1, false, {})
    640  else
    641    vim.cmd('keepjumps 1')
    642  end
    643  highlight_man_page()
    644  -- Guess the ref from the heading (which is usually uppercase, so we cannot
    645  -- know the correct casing, cf. `man glDrawArraysInstanced`).
    646  --- @type string
    647  local ref = (fn.getline(1):match('^[^)]+%)') or ''):gsub(' ', '_')
    648  local _, sect, err = pcall(parse_ref, ref)
    649  vim.b.man_sect = err ~= nil and sect or ''
    650 
    651  local man_bufname = 'man://' .. fn.fnameescape(ref):lower()
    652 
    653  -- Raw manpage into (:Man!) overlooks `match('man://')` condition,
    654  -- so if the buffer already exists, create new with a non existing name.
    655  if fn.bufexists(man_bufname) == 1 then
    656    local new_bufname = man_bufname
    657    for i = 1, 100 do
    658      if fn.bufexists(new_bufname) == 0 then
    659        break
    660      end
    661      new_bufname = ('%s?new=%s'):format(man_bufname, i)
    662    end
    663    vim.cmd.file({ new_bufname, mods = { silent = true } })
    664  elseif not fn.bufname('%'):match('man://') then -- Avoid duplicate buffers, E95.
    665    vim.cmd.file({ man_bufname, mods = { silent = true } })
    666  end
    667 
    668  set_options()
    669 end
    670 
    671 --- Combine the name and sect into a manpage reference so that all
    672 --- verification/extraction can be kept in a single function.
    673 --- @param args string[]
    674 --- @return string? ref
    675 local function ref_from_args(args)
    676  if #args <= 1 then
    677    return args[1]
    678  elseif args[1]:match('^%d$') or args[1]:match('^%d%a') or args[1]:match('^%a$') then
    679    -- NB: Valid sections are not only digits, but also:
    680    --  - <digit><word> (see POSIX mans),
    681    --  - and even <letter> and <word> (see, for example, by tcl/tk)
    682    -- NB2: don't optimize to :match("^%d"), as it will match manpages like
    683    --    441toppm and others whose name starts with digit
    684    local sect = args[1]
    685    table.remove(args, 1)
    686    local name = table.concat(args, ' ')
    687    return ('%s(%s)'):format(name, sect)
    688  end
    689 
    690  return table.concat(args, ' ')
    691 end
    692 
    693 --- @param count integer
    694 --- @param args string[]
    695 --- @return string? err
    696 function M.open_page(count, smods, args)
    697  local ref = ref_from_args(args)
    698  if not ref then
    699    ref = vim.bo.filetype == 'man' and fn.expand('<cWORD>') or fn.expand('<cword>')
    700    if ref == '' then
    701      return 'no identifier under cursor'
    702    end
    703  end
    704 
    705  local name, sect, err = parse_ref(ref)
    706  if err then
    707    return err
    708  end
    709  assert(name)
    710 
    711  if count >= 0 then
    712    sect = tostring(count)
    713  end
    714 
    715  -- Try both spaces and underscores, use the first that exists.
    716  local path = M._find_path(name, sect)
    717  if not path then
    718    --- Replace spaces in a man page name with underscores
    719    --- intended for PostgreSQL, which has man pages like 'CREATE_TABLE(7)';
    720    --- while editing SQL source code, it's nice to visually select 'CREATE TABLE'
    721    --- and hit 'K', which requires this transformation
    722    path = M._find_path(name:gsub('%s', '_'), sect)
    723    if not path then
    724      return 'no manual entry for ' .. name
    725    end
    726  end
    727 
    728  name, sect = parse_path(path)
    729  local buf = api.nvim_get_current_buf()
    730  local save_tfu = vim.bo[buf].tagfunc
    731  vim.bo[buf].tagfunc = "v:lua.require'man'.goto_tag"
    732 
    733  local target = ('%s(%s)'):format(name, sect)
    734 
    735  local ok, ret = pcall(function()
    736    smods.silent = true
    737    smods.keepalt = true
    738    if smods.hide or (smods.tab == -1 and find_man()) then
    739      vim.cmd.tag({ target, mods = smods })
    740    else
    741      vim.cmd.stag({ target, mods = smods })
    742    end
    743  end)
    744 
    745  if api.nvim_buf_is_valid(buf) then
    746    vim.bo[buf].tagfunc = save_tfu
    747  end
    748 
    749  if not ok then
    750    error(ret)
    751  end
    752  set_options()
    753 
    754  vim.b.man_sect = sect
    755 end
    756 
    757 --- Called when a man:// buffer is opened.
    758 --- @return string? err
    759 function M.read_page(ref)
    760  local name, sect, err = parse_ref(ref)
    761  if err then
    762    return err
    763  end
    764 
    765  local path = M._find_path(name, sect)
    766  if not path then
    767    return 'no manual entry for ' .. name
    768  end
    769 
    770  local _, sect1 = parse_path(path)
    771  local page = get_page(path)
    772 
    773  vim.b.man_sect = sect1
    774  vim.bo.modifiable = true
    775  vim.bo.readonly = false
    776  vim.bo.swapfile = false
    777 
    778  api.nvim_buf_set_lines(0, 0, -1, false, vim.split(page, '\n'))
    779 
    780  while fn.getline(1):match('^%s*$') do
    781    api.nvim_buf_set_lines(0, 0, 1, false, {})
    782  end
    783  -- XXX: nroff justifies text by filling it with whitespace.  That interacts
    784  -- badly with our use of $MANWIDTH=999.  Hack around this by using a fixed
    785  -- size for those whitespace regions.
    786  -- Use try/catch to avoid setting v:errmsg.
    787  vim.cmd([[
    788    try
    789      keeppatterns keepjumps %s/\s\{199,}/\=repeat(' ', 10)/g
    790    catch
    791    endtry
    792  ]])
    793  vim.cmd('1') -- Move cursor to first line
    794  highlight_man_page()
    795  set_options()
    796 end
    797 
    798 function M.show_toc()
    799  local bufnr = api.nvim_get_current_buf()
    800  local bufname = api.nvim_buf_get_name(bufnr)
    801  local info = fn.getloclist(0, { winid = 1 })
    802  if info ~= '' and vim.w[info.winid].qf_toc == bufname then
    803    vim.cmd.lopen()
    804    return
    805  end
    806 
    807  --- @type {bufnr:integer, lnum:integer, text:string}[]
    808  local toc = {}
    809 
    810  local lnum = 2
    811  local last_line = fn.line('$') - 1
    812  while lnum > 0 and lnum < last_line do
    813    local text = fn.getline(lnum)
    814    if text:match('^%s+[-+]%S') or text:match('^   %S') or text:match('^%S') then
    815      toc[#toc + 1] = {
    816        bufnr = bufnr,
    817        lnum = lnum,
    818        text = text:gsub('^%s+', ''):gsub('%s+$', ''),
    819      }
    820    end
    821    lnum = fn.nextnonblank(lnum + 1)
    822  end
    823 
    824  fn.setloclist(0, toc, ' ')
    825  fn.setloclist(0, {}, 'a', { title = 'Table of contents' })
    826  vim.cmd.lopen()
    827  vim.w.qf_toc = bufname
    828  -- reload syntax file after setting qf_toc variable
    829  vim.bo.filetype = 'qf'
    830 end
    831 
    832 return M