diff options
author | Lewis Russell <lewis6991@gmail.com> | 2022-09-02 15:20:29 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-09-02 15:20:29 +0100 |
commit | 2afcdbd63a5b0cbeaad9d83b096a3af5201c67a9 (patch) | |
tree | a5e44f3dba1287c398af66673fa926e4841c5343 | |
parent | e085d0be31c68921769c6c437920a3346caec69b (diff) | |
download | rneovim-2afcdbd63a5b0cbeaad9d83b096a3af5201c67a9.tar.gz rneovim-2afcdbd63a5b0cbeaad9d83b096a3af5201c67a9.tar.bz2 rneovim-2afcdbd63a5b0cbeaad9d83b096a3af5201c67a9.zip |
feat(Man): port to Lua (#19912)
Co-authored-by: zeertzjq <zeertzjq@outlook.com>
-rw-r--r-- | .luacheckrc | 7 | ||||
-rw-r--r-- | runtime/autoload/man.vim | 529 | ||||
-rw-r--r-- | runtime/doc/filetype.txt | 4 | ||||
-rw-r--r-- | runtime/doc/vim_diff.txt | 2 | ||||
-rw-r--r-- | runtime/ftplugin/man.vim | 4 | ||||
-rw-r--r-- | runtime/lua/man.lua | 601 | ||||
-rw-r--r-- | runtime/plugin/man.lua | 34 | ||||
-rw-r--r-- | runtime/plugin/man.vim | 15 | ||||
-rw-r--r-- | src/nvim/testdir/test_profile.vim | 6 | ||||
-rw-r--r-- | test/functional/plugin/man_spec.lua | 15 |
10 files changed, 651 insertions, 566 deletions
diff --git a/.luacheckrc b/.luacheckrc index 9bbd323e84..8c1f4cf41c 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -28,6 +28,13 @@ read_globals = { globals = { "vim.g", + "vim.b", + "vim.w", + "vim.o", + "vim.bo", + "vim.wo", + "vim.go", + "vim.env" } exclude_files = { diff --git a/runtime/autoload/man.vim b/runtime/autoload/man.vim deleted file mode 100644 index b8a73a64c9..0000000000 --- a/runtime/autoload/man.vim +++ /dev/null @@ -1,529 +0,0 @@ -" Maintainer: Anmol Sethi <hi@nhooyr.io> - -if exists('s:loaded_man') - finish -endif -let s:loaded_man = 1 - -let s:find_arg = '-w' -let s:localfile_arg = v:true " Always use -l if possible. #6683 - -function! man#init() abort - try - " Check for -l support. - call s:get_page(s:get_path('', 'man')) - catch /command error .*/ - let s:localfile_arg = v:false - endtry -endfunction - -function! man#open_page(count, mods, ...) abort - if a:0 > 2 - call s:error('too many arguments') - return - elseif a:0 == 0 - let ref = &filetype ==# 'man' ? expand('<cWORD>') : expand('<cword>') - if empty(ref) - call s:error('no identifier under cursor') - return - endif - elseif a:0 ==# 1 - let ref = a:1 - else - " Combine the name and sect into a manpage reference so that all - " verification/extraction can be kept in a single function. - " If a:2 is a reference as well, that is fine because it is the only - " reference that will match. - let ref = a:2.'('.a:1.')' - endif - try - let [sect, name] = s:extract_sect_and_name_ref(ref) - if a:count >= 0 - let sect = string(a:count) - endif - let path = s:verify_exists(sect, name) - let [sect, name] = s:extract_sect_and_name_path(path) - catch - call s:error(v:exception) - return - endtry - - let [l:buf, l:save_tfu] = [bufnr(), &tagfunc] - try - setlocal tagfunc=man#goto_tag - let l:target = l:name . '(' . l:sect . ')' - if a:mods !~# 'tab' && s:find_man() - execute 'silent keepalt tag' l:target - else - execute 'silent keepalt' a:mods 'stag' l:target - endif - call s:set_options(v:false) - finally - call setbufvar(l:buf, '&tagfunc', l:save_tfu) - endtry - - let b:man_sect = sect -endfunction - -" Called when a man:// buffer is opened. -function! man#read_page(ref) abort - try - let [sect, name] = s:extract_sect_and_name_ref(a:ref) - let path = s:verify_exists(sect, name) - let [sect, name] = s:extract_sect_and_name_path(path) - let page = s:get_page(path) - catch - call s:error(v:exception) - return - endtry - let b:man_sect = sect - call s:put_page(page) -endfunction - -" Handler for s:system() function. -function! s:system_handler(jobid, data, event) dict abort - if a:event is# 'stdout' || a:event is# 'stderr' - let self[a:event] .= join(a:data, "\n") - else - let self.exit_code = a:data - endif -endfunction - -" Run a system command and timeout after 30 seconds. -function! s:system(cmd, ...) abort - let opts = { - \ 'stdout': '', - \ 'stderr': '', - \ 'exit_code': 0, - \ 'on_stdout': function('s:system_handler'), - \ 'on_stderr': function('s:system_handler'), - \ 'on_exit': function('s:system_handler'), - \ } - let jobid = jobstart(a:cmd, opts) - - if jobid < 1 - throw printf('command error %d: %s', jobid, join(a:cmd)) - endif - - let res = jobwait([jobid], 30000) - if res[0] == -1 - try - call jobstop(jobid) - throw printf('command timed out: %s', join(a:cmd)) - catch /^Vim(call):E900:/ - endtry - elseif res[0] == -2 - throw printf('command interrupted: %s', join(a:cmd)) - endif - if opts.exit_code != 0 - throw printf("command error (%d) %s: %s", jobid, join(a:cmd), substitute(opts.stderr, '\_s\+$', '', &gdefault ? '' : 'g')) - endif - - return opts.stdout -endfunction - -function! s:set_options(pager) abort - setlocal noswapfile buftype=nofile bufhidden=hide - setlocal nomodified readonly nomodifiable - let b:pager = a:pager - setlocal filetype=man -endfunction - -function! s:get_page(path) abort - " Disable hard-wrap by using a big $MANWIDTH (max 1000 on some systems #9065). - " Soft-wrap: ftplugin/man.vim sets wrap/breakindent/…. - " Hard-wrap: driven by `man`. - let manwidth = !get(g:, 'man_hardwrap', 1) ? 999 : (empty($MANWIDTH) ? winwidth(0) : $MANWIDTH) - " Force MANPAGER=cat to ensure Vim is not recursively invoked (by man-db). - " http://comments.gmane.org/gmane.editors.vim.devel/29085 - " Set MAN_KEEP_FORMATTING so Debian man doesn't discard backspaces. - let cmd = ['env', 'MANPAGER=cat', 'MANWIDTH='.manwidth, 'MAN_KEEP_FORMATTING=1', 'man'] - return s:system(cmd + (s:localfile_arg ? ['-l', a:path] : [a:path])) -endfunction - -function! s:put_page(page) abort - setlocal modifiable noreadonly noswapfile - silent keepjumps %delete _ - silent put =a:page - while getline(1) =~# '^\s*$' - silent keepjumps 1delete _ - endwhile - " XXX: nroff justifies text by filling it with whitespace. That interacts - " badly with our use of $MANWIDTH=999. Hack around this by using a fixed - " size for those whitespace regions. - silent! keeppatterns keepjumps %s/\s\{199,}/\=repeat(' ', 10)/g - 1 - lua require("man").highlight_man_page() - call s:set_options(v:false) -endfunction - -function! man#show_toc() abort - let bufname = bufname('%') - let info = getloclist(0, {'winid': 1}) - if !empty(info) && getwinvar(info.winid, 'qf_toc') ==# bufname - lopen - return - endif - - let toc = [] - let lnum = 2 - let last_line = line('$') - 1 - while lnum && lnum < last_line - let text = getline(lnum) - if text =~# '^\%( \{3\}\)\=\S.*$' - " if text is a section title - call add(toc, {'bufnr': bufnr('%'), 'lnum': lnum, 'text': text}) - elseif text =~# '^\s\+\%(+\|-\)\S\+' - " if text is a flag title. we strip whitespaces and prepend two - " spaces to have a consistent format in the loclist. - let text = ' ' .. substitute(text, '^\s*\(.\{-}\)\s*$', '\1', '') - call add(toc, {'bufnr': bufnr('%'), 'lnum': lnum, 'text': text}) - endif - let lnum = nextnonblank(lnum + 1) - endwhile - - call setloclist(0, toc, ' ') - call setloclist(0, [], 'a', {'title': 'Man TOC'}) - lopen - let w:qf_toc = bufname -endfunction - -" attempt to extract the name and sect out of 'name(sect)' -" otherwise just return the largest string of valid characters in ref -function! s:extract_sect_and_name_ref(ref) abort - if a:ref[0] ==# '-' " try ':Man -pandoc' with this disabled. - throw 'manpage name cannot start with ''-''' - endif - let ref = matchstr(a:ref, '[^()]\+([^()]\+)') - if empty(ref) - let name = matchstr(a:ref, '[^()]\+') - if empty(name) - throw 'manpage reference cannot contain only parentheses' - endif - return ['', s:spaces_to_underscores(name)] - endif - let left = split(ref, '(') - " see ':Man 3X curses' on why tolower. - " TODO(nhooyr) Not sure if this is portable across OSs - " but I have not seen a single uppercase section. - return [tolower(split(left[1], ')')[0]), s:spaces_to_underscores(left[0])] -endfunction - -" replace spaces in a man page name with underscores -" intended for PostgreSQL, which has man pages like 'CREATE_TABLE(7)'; -" while editing SQL source code, it's nice to visually select 'CREATE TABLE' -" and hit 'K', which requires this transformation -function! s:spaces_to_underscores(str) - return substitute(a:str, ' ', '_', 'g') -endfunction - -function! s:get_path(sect, name) abort - " Some man implementations (OpenBSD) return all available paths from the - " search command. Previously, this function would simply select the first one. - " - " However, some searches will report matches that are incorrect: - " man -w strlen may return string.3 followed by strlen.3, and therefore - " selecting the first would get us the wrong page. Thus, we must find the - " first matching one. - " - " There's yet another special case here. Consider the following: - " If you run man -w strlen and string.3 comes up first, this is a problem. We - " should search for a matching named one in the results list. - " However, if you search for man -w clock_gettime, you will *only* get - " clock_getres.2, which is the right page. Searching the resuls for - " clock_gettime will no longer work. In this case, we should just use the - " first one that was found in the correct section. - " - " Finally, we can avoid relying on -S or -s here since they are very - " inconsistently supported. Instead, call -w with a section and a name. - if empty(a:sect) - let results = split(s:system(['man', s:find_arg, a:name])) - else - let results = split(s:system(['man', s:find_arg, a:sect, a:name])) - endif - - if empty(results) - return '' - endif - - " find any that match the specified name - let namematches = filter(copy(results), 'fnamemodify(v:val, ":t") =~ a:name') - let sectmatches = [] - - if !empty(namematches) && !empty(a:sect) - let sectmatches = filter(copy(namematches), 'fnamemodify(v:val, ":e") == a:sect') - endif - - return substitute(get(sectmatches, 0, get(namematches, 0, results[0])), '\n\+$', '', '') -endfunction - -" s:verify_exists attempts to find the path to a manpage -" based on the passed section and name. -" -" 1. If the passed section is empty, b:man_default_sects is used. -" 2. If manpage could not be found with the given sect and name, -" then another attempt is made with b:man_default_sects. -" 3. If it still could not be found, then we try again without a section. -" 4. If still not found but $MANSECT is set, then we try again with $MANSECT -" unset. -" -" This function is careful to avoid duplicating a search if a previous -" step has already done it. i.e if we use b:man_default_sects in step 1, -" then we don't do it again in step 2. -function! s:verify_exists(sect, name) abort - let sect = a:sect - - if empty(sect) - " no section specified, so search with b:man_default_sects - if exists('b:man_default_sects') - let sects = split(b:man_default_sects, ',') - for sec in sects - try - let res = s:get_path(sec, a:name) - if !empty(res) - return res - endif - catch /^command error (/ - endtry - endfor - endif - else - " try with specified section - try - let res = s:get_path(sect, a:name) - if !empty(res) - return res - endif - catch /^command error (/ - endtry - - " try again with b:man_default_sects - if exists('b:man_default_sects') - let sects = split(b:man_default_sects, ',') - for sec in sects - try - let res = s:get_path(sec, a:name) - if !empty(res) - return res - endif - catch /^command error (/ - endtry - endfor - endif - endif - - " if none of the above worked, we will try with no section - try - let res = s:get_path('', a:name) - if !empty(res) - return res - endif - catch /^command error (/ - endtry - - " if that still didn't work, we will check for $MANSECT and try again with it - " unset - if !empty($MANSECT) - try - let MANSECT = $MANSECT - call setenv('MANSECT', v:null) - let res = s:get_path('', a:name) - if !empty(res) - return res - endif - catch /^command error (/ - finally - call setenv('MANSECT', MANSECT) - endtry - endif - - " finally, if that didn't work, there is no hope - throw 'no manual entry for ' . a:name -endfunction - -" Extracts the name/section from the 'path/name.sect', because sometimes the actual section is -" more specific than what we provided to `man` (try `:Man 3 App::CLI`). -" Also on linux, name seems to be case-insensitive. So for `:Man PRIntf`, we -" still want the name of the buffer to be 'printf'. -function! s:extract_sect_and_name_path(path) abort - let tail = fnamemodify(a:path, ':t') - if a:path =~# '\.\%([glx]z\|bz2\|lzma\|Z\)$' " valid extensions - let tail = fnamemodify(tail, ':r') - endif - let sect = matchstr(tail, '\.\zs[^.]\+$') - let name = matchstr(tail, '^.\+\ze\.') - return [sect, name] -endfunction - -function! s:find_man() abort - let l:win = 1 - while l:win <= winnr('$') - let l:buf = winbufnr(l:win) - if getbufvar(l:buf, '&filetype', '') ==# 'man' - execute l:win.'wincmd w' - return 1 - endif - let l:win += 1 - endwhile - return 0 -endfunction - -function! s:error(msg) abort - redraw - echohl ErrorMsg - echon 'man.vim: ' a:msg - echohl None -endfunction - -" see s:extract_sect_and_name_ref on why tolower(sect) -function! man#complete(arg_lead, cmd_line, cursor_pos) abort - let args = split(a:cmd_line) - let cmd_offset = index(args, 'Man') - if cmd_offset > 0 - " Prune all arguments up to :Man itself. Otherwise modifier commands like - " :tab, :vertical, etc. would lead to a wrong length. - let args = args[cmd_offset:] - endif - let l = len(args) - if l > 3 - return - elseif l ==# 1 - let name = '' - let sect = '' - elseif a:arg_lead =~# '^[^()]\+([^()]*$' - " cursor (|) is at ':Man printf(|' or ':Man 1 printf(|' - " The later is is allowed because of ':Man pri<TAB>'. - " It will offer 'priclass.d(1m)' even though section is specified as 1. - let tmp = split(a:arg_lead, '(') - let name = tmp[0] - let sect = tolower(get(tmp, 1, '')) - return s:complete(sect, '', name) - elseif args[1] !~# '^[^()]\+$' - " cursor (|) is at ':Man 3() |' or ':Man (3|' or ':Man 3() pri|' - " or ':Man 3() pri |' - return - elseif l ==# 2 - if empty(a:arg_lead) - " cursor (|) is at ':Man 1 |' - let name = '' - let sect = tolower(args[1]) - else - " cursor (|) is at ':Man pri|' - if a:arg_lead =~# '\/' - " if the name is a path, complete files - " TODO(nhooyr) why does this complete the last one automatically - return glob(a:arg_lead.'*', 0, 1) - endif - let name = a:arg_lead - let sect = '' - endif - elseif a:arg_lead !~# '^[^()]\+$' - " cursor (|) is at ':Man 3 printf |' or ':Man 3 (pr)i|' - return - else - " cursor (|) is at ':Man 3 pri|' - let name = a:arg_lead - let sect = tolower(args[1]) - endif - return s:complete(sect, sect, name) -endfunction - -function! s:get_paths(sect, name, do_fallback) abort - " callers must try-catch this, as some `man` implementations don't support `s:find_arg` - try - let mandirs = join(split(s:system(['man', s:find_arg]), ':\|\n'), ',') - let paths = globpath(mandirs, 'man?/'.a:name.'*.'.a:sect.'*', 0, 1) - try - " Prioritize the result from verify_exists as it obeys b:man_default_sects. - let first = s:verify_exists(a:sect, a:name) - let paths = filter(paths, 'v:val !=# first') - let paths = [first] + paths - catch - endtry - return paths - catch - if !a:do_fallback - throw v:exception - endif - - " Fallback to a single path, with the page we're trying to find. - try - return [s:verify_exists(a:sect, a:name)] - catch - return [] - endtry - endtry -endfunction - -function! s:complete(sect, psect, name) abort - let pages = s:get_paths(a:sect, a:name, v:false) - " We remove duplicates in case the same manpage in different languages was found. - return uniq(sort(map(pages, 's:format_candidate(v:val, a:psect)'), 'i')) -endfunction - -function! s:format_candidate(path, psect) abort - if a:path =~# '\.\%(pdf\|in\)$' " invalid extensions - return - endif - let [sect, name] = s:extract_sect_and_name_path(a:path) - if sect ==# a:psect - return name - elseif sect =~# a:psect.'.\+$' - " We include the section if the user provided section is a prefix - " of the actual section. - return name.'('.sect.')' - endif -endfunction - -" Called when Nvim is invoked as $MANPAGER. -function! man#init_pager() abort - if getline(1) =~# '^\s*$' - silent keepjumps 1delete _ - else - keepjumps 1 - endif - lua require("man").highlight_man_page() - " Guess the ref from the heading (which is usually uppercase, so we cannot - " know the correct casing, cf. `man glDrawArraysInstanced`). - let ref = substitute(matchstr(getline(1), '^[^)]\+)'), ' ', '_', 'g') - try - let b:man_sect = s:extract_sect_and_name_ref(ref)[0] - catch - let b:man_sect = '' - endtry - if -1 == match(bufname('%'), 'man:\/\/') " Avoid duplicate buffers, E95. - execute 'silent file man://'.tolower(fnameescape(ref)) - endif - - call s:set_options(v:true) -endfunction - -function! man#goto_tag(pattern, flags, info) abort - let [l:sect, l:name] = s:extract_sect_and_name_ref(a:pattern) - - let l:paths = s:get_paths(l:sect, l:name, v:true) - let l:structured = [] - - for l:path in l:paths - let [l:sect, l:name] = s:extract_sect_and_name_path(l:path) - let l:structured += [{ - \ 'name': l:name, - \ 'title': l:name . '(' . l:sect . ')' - \ }] - endfor - - if &cscopetag - " return only a single entry so we work well with :cstag (#11675) - let l:structured = l:structured[:0] - endif - - return map(l:structured, { - \ _, entry -> { - \ 'name': entry.name, - \ 'filename': 'man://' . entry.title, - \ 'cmd': '1' - \ } - \ }) -endfunction - -call man#init() diff --git a/runtime/doc/filetype.txt b/runtime/doc/filetype.txt index 7fff74a963..9f8ef248f8 100644 --- a/runtime/doc/filetype.txt +++ b/runtime/doc/filetype.txt @@ -586,12 +586,12 @@ Local mappings: to the end of the file in Normal mode. This means "> " is inserted in each line. -MAN *ft-man-plugin* *:Man* *man.vim* +MAN *ft-man-plugin* *:Man* *man.lua* View manpages in Nvim. Supports highlighting, completion, locales, and navigation. Also see |find-manpage|. -man.vim will always attempt to reuse the closest man window (above/left) but +man.lua will always attempt to reuse the closest man window (above/left) but otherwise create a split. The case sensitivity of completion is controlled by 'fileignorecase'. diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index 0011cd9821..b013e00fe8 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -73,7 +73,7 @@ centralized reference of the differences. - 'wildmenu' is enabled - 'wildoptions' defaults to "pum,tagfile" -- |man.vim| plugin is enabled, so |:Man| is available by default. +- |man.lua| plugin is enabled, so |:Man| is available by default. - |matchit| plugin is enabled. To disable it in your config: > :let loaded_matchit = 1 diff --git a/runtime/ftplugin/man.vim b/runtime/ftplugin/man.vim index d7a08a9941..277ce3c0b3 100644 --- a/runtime/ftplugin/man.vim +++ b/runtime/ftplugin/man.vim @@ -17,12 +17,12 @@ setlocal iskeyword=@-@,:,a-z,A-Z,48-57,_,.,-,(,) setlocal nonumber norelativenumber setlocal foldcolumn=0 colorcolumn=0 nolist nofoldenable -setlocal tagfunc=man#goto_tag +setlocal tagfunc=v:lua.require'man'.goto_tag if !exists('g:no_plugin_maps') && !exists('g:no_man_maps') nnoremap <silent> <buffer> j gj nnoremap <silent> <buffer> k gk - nnoremap <silent> <buffer> gO :call man#show_toc()<CR> + nnoremap <silent> <buffer> gO :lua require'man'.show_toc()<CR> nnoremap <silent> <buffer> <2-LeftMouse> :Man<CR> if get(b:, 'pager') nnoremap <silent> <buffer> <nowait> q :lclose<CR><C-W>q diff --git a/runtime/lua/man.lua b/runtime/lua/man.lua index 5da3d2a92f..4b8239ce74 100644 --- a/runtime/lua/man.lua +++ b/runtime/lua/man.lua @@ -1,7 +1,75 @@ require('vim.compat') +local api, fn = vim.api, vim.fn + +local find_arg = '-w' +local localfile_arg = true -- Always use -l if possible. #6683 local buf_hls = {} +local M = {} + +local function man_error(msg) + M.errormsg = 'man.lua: ' .. vim.inspect(msg) + error(M.errormsg) +end + +-- Run a system command and timeout after 30 seconds. +local function man_system(cmd, silent) + local stdout_data = {} + local stderr_data = {} + local stdout = vim.loop.new_pipe(false) + local stderr = vim.loop.new_pipe(false) + + local done = false + local exit_code + + local handle = vim.loop.spawn(cmd[1], { + args = vim.list_slice(cmd, 2), + stdio = { nil, stdout, stderr }, + }, function(code) + exit_code = code + stdout:close() + stderr:close() + done = true + end) + + if handle then + stdout:read_start(function(_, data) + stdout_data[#stdout_data + 1] = data + end) + stderr:read_start(function(_, data) + stderr_data[#stderr_data + 1] = data + end) + else + stdout:close() + stderr:close() + if not silent then + man_error(string.format('command error: %s', table.concat(cmd))) + end + end + + vim.wait(30000, function() + return done + end) + + if not done then + if handle then + vim.loop.shutdown(handle) + stdout:close() + stderr:close() + end + man_error(string.format('command timed out: %s', table.concat(cmd, ' '))) + end + + if exit_code ~= 0 and not silent then + man_error( + string.format("command error '%s': %s", table.concat(cmd, ' '), table.concat(stderr_data)) + ) + end + + return table.concat(stdout_data) +end + local function highlight_line(line, linenr) local chars = {} local prev_char = '' @@ -152,21 +220,540 @@ local function highlight_line(line, linenr) end local function highlight_man_page() - local mod = vim.api.nvim_buf_get_option(0, 'modifiable') - vim.api.nvim_buf_set_option(0, 'modifiable', true) + local mod = vim.bo.modifiable + vim.bo.modifiable = true - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local lines = api.nvim_buf_get_lines(0, 0, -1, false) for i, line in ipairs(lines) do lines[i] = highlight_line(line, i) end - vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + api.nvim_buf_set_lines(0, 0, -1, false, lines) for _, args in ipairs(buf_hls) do - vim.api.nvim_buf_add_highlight(unpack(args)) + api.nvim_buf_add_highlight(unpack(args)) end buf_hls = {} - vim.api.nvim_buf_set_option(0, 'modifiable', mod) + vim.bo.modifiable = mod +end + +-- replace spaces in a man page name with underscores +-- intended for PostgreSQL, which has man pages like 'CREATE_TABLE(7)'; +-- while editing SQL source code, it's nice to visually select 'CREATE TABLE' +-- and hit 'K', which requires this transformation +local function spaces_to_underscores(str) + local res = str:gsub('%s', '_') + return res +end + +local function get_path(sect, name, silent) + name = name or '' + sect = sect or '' + -- Some man implementations (OpenBSD) return all available paths from the + -- search command. Previously, this function would simply select the first one. + -- + -- However, some searches will report matches that are incorrect: + -- man -w strlen may return string.3 followed by strlen.3, and therefore + -- selecting the first would get us the wrong page. Thus, we must find the + -- first matching one. + -- + -- There's yet another special case here. Consider the following: + -- If you run man -w strlen and string.3 comes up first, this is a problem. We + -- should search for a matching named one in the results list. + -- However, if you search for man -w clock_gettime, you will *only* get + -- clock_getres.2, which is the right page. Searching the resuls for + -- clock_gettime will no longer work. In this case, we should just use the + -- first one that was found in the correct section. + -- + -- Finally, we can avoid relying on -S or -s here since they are very + -- inconsistently supported. Instead, call -w with a section and a name. + local cmd + if sect == '' then + cmd = { 'man', find_arg, name } + else + cmd = { 'man', find_arg, sect, name } + end + + local lines = man_system(cmd, silent) + if lines == nil then + return nil + end + + local results = vim.split(lines, '\n', { trimempty = true }) + + if #results == 0 then + return + end + + -- find any that match the specified name + local namematches = vim.tbl_filter(function(v) + return fn.fnamemodify(v, ':t'):match(name) + end, results) or {} + local sectmatches = {} + + if #namematches > 0 and sect ~= '' then + sectmatches = vim.tbl_filter(function(v) + return fn.fnamemodify(v, ':e') == sect + end, namematches) + end + + return fn.substitute(sectmatches[1] or namematches[1] or results[1], [[\n\+$]], '', '') +end + +local function matchstr(text, pat_or_re) + local re = type(pat_or_re) == 'string' and vim.regex(pat_or_re) or pat_or_re + + local s, e = re:match_str(text) + + if s == nil then + return + end + + return text:sub(vim.str_utfindex(text, s) + 1, vim.str_utfindex(text, e)) +end + +-- attempt to extract the name and sect out of 'name(sect)' +-- otherwise just return the largest string of valid characters in ref +local function extract_sect_and_name_ref(ref) + ref = ref or '' + if ref:sub(1, 1) == '-' then -- try ':Man -pandoc' with this disabled. + man_error("manpage name cannot start with '-'") + end + local ref1 = ref:match('[^()]+%([^()]+%)') + if not ref1 then + local name = ref:match('[^()]+') + if not name then + man_error('manpage reference cannot contain only parentheses: ' .. ref) + end + return '', spaces_to_underscores(name) + end + local parts = vim.split(ref1, '(', { plain = true }) + -- see ':Man 3X curses' on why tolower. + -- TODO(nhooyr) Not sure if this is portable across OSs + -- but I have not seen a single uppercase section. + local sect = vim.split(parts[2] or '', ')', { plain = true })[1]:lower() + local name = spaces_to_underscores(parts[1]) + return sect, name +end + +-- verify_exists attempts to find the path to a manpage +-- based on the passed section and name. +-- +-- 1. If manpage could not be found with the given sect and name, +-- then try all the sections in b:man_default_sects. +-- 2. If it still could not be found, then we try again without a section. +-- 3. If still not found but $MANSECT is set, then we try again with $MANSECT +-- unset. +local function verify_exists(sect, name) + if sect and sect ~= '' then + local ret = get_path(sect, name, true) + if ret then + return ret + end + end + + if vim.b.man_default_sects ~= nil then + local sects = vim.split(vim.b.man_default_sects, ',', { plain = true, trimempty = true }) + for _, sec in ipairs(sects) do + local ret = get_path(sec, name, true) + if ret then + return ret + end + end + end + + -- if none of the above worked, we will try with no section + local res_empty_sect = get_path('', name, true) + if res_empty_sect then + return res_empty_sect + end + + -- if that still didn't work, we will check for $MANSECT and try again with it + -- unset + if vim.env.MANSECT then + local mansect = vim.env.MANSECT + vim.env.MANSECT = nil + local res = get_path('', name, true) + vim.env.MANSECT = mansect + if res then + return res + end + end + + -- finally, if that didn't work, there is no hope + man_error('no manual entry for ' .. name) +end + +local EXT_RE = vim.regex([[\.\%([glx]z\|bz2\|lzma\|Z\)$]]) + +-- Extracts the name/section from the 'path/name.sect', because sometimes the actual section is +-- more specific than what we provided to `man` (try `:Man 3 App::CLI`). +-- Also on linux, name seems to be case-insensitive. So for `:Man PRIntf`, we +-- still want the name of the buffer to be 'printf'. +local function extract_sect_and_name_path(path) + local tail = fn.fnamemodify(path, ':t') + if EXT_RE:match_str(path) then -- valid extensions + tail = fn.fnamemodify(tail, ':r') + end + local name, sect = tail:match('^(.+)%.([^.]+)$') + return sect, name +end + +local function find_man() + local win = 1 + while win <= fn.winnr('$') do + local buf = fn.winbufnr(win) + if vim.bo[buf].filetype == 'man' then + vim.cmd(win .. 'wincmd w') + return true + end + win = win + 1 + end + return false +end + +local function set_options(pager) + vim.bo.swapfile = false + vim.bo.buftype = 'nofile' + vim.bo.bufhidden = 'hide' + vim.bo.modified = false + vim.bo.readonly = true + vim.bo.modifiable = false + vim.b.pager = pager + vim.bo.filetype = 'man' +end + +local function get_page(path, silent) + -- Disable hard-wrap by using a big $MANWIDTH (max 1000 on some systems #9065). + -- Soft-wrap: ftplugin/man.lua sets wrap/breakindent/…. + -- Hard-wrap: driven by `man`. + local manwidth + if (vim.g.man_hardwrap or 1) ~= 1 then + manwidth = 999 + elseif vim.env.MANWIDTH then + manwidth = vim.env.MANWIDTH + else + manwidth = api.nvim_win_get_width(0) + end + -- Force MANPAGER=cat to ensure Vim is not recursively invoked (by man-db). + -- http://comments.gmane.org/gmane.editors.vim.devel/29085 + -- Set MAN_KEEP_FORMATTING so Debian man doesn't discard backspaces. + local cmd = { 'env', 'MANPAGER=cat', 'MANWIDTH=' .. manwidth, 'MAN_KEEP_FORMATTING=1', 'man' } + if localfile_arg then + cmd[#cmd + 1] = '-l' + end + cmd[#cmd + 1] = path + return man_system(cmd, silent) +end + +local function put_page(page) + vim.bo.modified = true + vim.bo.readonly = false + vim.bo.swapfile = false + + api.nvim_buf_set_lines(0, 0, -1, false, vim.split(page, '\n')) + + while fn.getline(1):match('^%s*$') do + api.nvim_buf_set_lines(0, 0, 1, false, {}) + end + -- XXX: nroff justifies text by filling it with whitespace. That interacts + -- badly with our use of $MANWIDTH=999. Hack around this by using a fixed + -- size for those whitespace regions. + vim.cmd([[silent! keeppatterns keepjumps %s/\s\{199,}/\=repeat(' ', 10)/g]]) + vim.cmd('1') -- Move cursor to first line + highlight_man_page() + set_options(false) +end + +local function format_candidate(path, psect) + if matchstr(path, [[\.\%(pdf\|in\)$]]) then -- invalid extensions + return '' + end + local sect, name = extract_sect_and_name_path(path) + if sect == psect then + return name + elseif sect and name and matchstr(sect, psect .. '.\\+$') then -- invalid extensions + -- We include the section if the user provided section is a prefix + -- of the actual section. + return ('%s(%s)'):format(name, sect) + end + return '' end -return { highlight_man_page = highlight_man_page } +local function get_paths(sect, name, do_fallback) + -- callers must try-catch this, as some `man` implementations don't support `s:find_arg` + local ok, ret = pcall(function() + local mandirs = + table.concat(vim.split(man_system({ 'man', find_arg }), '[:\n]', { trimempty = true }), ',') + local paths = fn.globpath(mandirs, 'man?/' .. name .. '*.' .. sect .. '*', false, true) + pcall(function() + -- Prioritize the result from verify_exists as it obeys b:man_default_sects. + local first = verify_exists(sect, name) + paths = vim.tbl_filter(function(v) + return v ~= first + end, paths) + paths = { first, unpack(paths) } + end) + return paths + end) + + if not ok then + if not do_fallback then + error(ret) + end + + -- Fallback to a single path, with the page we're trying to find. + ok, ret = pcall(verify_exists, sect, name) + + return { ok and ret or nil } + end + return ret or {} +end + +local function complete(sect, psect, name) + local pages = get_paths(sect, name, false) + -- We remove duplicates in case the same manpage in different languages was found. + return fn.uniq(fn.sort(vim.tbl_map(function(v) + return format_candidate(v, psect) + end, pages) or {}, 'i')) +end + +-- see extract_sect_and_name_ref on why tolower(sect) +function M.man_complete(arg_lead, cmd_line, _) + local args = vim.split(cmd_line, '%s+', { trimempty = true }) + local cmd_offset = fn.index(args, 'Man') + if cmd_offset > 0 then + -- Prune all arguments up to :Man itself. Otherwise modifier commands like + -- :tab, :vertical, etc. would lead to a wrong length. + args = vim.list_slice(args, cmd_offset + 1) + end + + if #args > 3 then + return {} + end + + if #args == 1 then + -- returning full completion is laggy. Require some arg_lead to complete + -- return complete('', '', '') + return {} + end + + if arg_lead:match('^[^()]+%([^()]*$') then + -- cursor (|) is at ':Man printf(|' or ':Man 1 printf(|' + -- The later is is allowed because of ':Man pri<TAB>'. + -- It will offer 'priclass.d(1m)' even though section is specified as 1. + local tmp = vim.split(arg_lead, '(', { plain = true }) + local name = tmp[1] + local sect = (tmp[2] or ''):lower() + return complete(sect, '', name) + end + + if not args[2]:match('^[^()]+$') then + -- cursor (|) is at ':Man 3() |' or ':Man (3|' or ':Man 3() pri|' + -- or ':Man 3() pri |' + return {} + end + + if #args == 2 then + local name, sect + if arg_lead == '' then + -- cursor (|) is at ':Man 1 |' + name = '' + sect = args[1]:lower() + else + -- cursor (|) is at ':Man pri|' + if arg_lead:match('/') then + -- if the name is a path, complete files + -- TODO(nhooyr) why does this complete the last one automatically + return fn.glob(arg_lead .. '*', false, true) + end + name = arg_lead + sect = '' + end + return complete(sect, sect, name) + end + + if not arg_lead:match('[^()]+$') then + -- cursor (|) is at ':Man 3 printf |' or ':Man 3 (pr)i|' + return {} + end + + -- cursor (|) is at ':Man 3 pri|' + local name = arg_lead + local sect = args[2]:lower() + return complete(sect, sect, name) +end + +function M.goto_tag(pattern, _, _) + local sect, name = extract_sect_and_name_ref(pattern) + + local paths = get_paths(sect, name, true) + local structured = {} + + for _, path in ipairs(paths) do + sect, name = extract_sect_and_name_path(path) + if sect and name then + structured[#structured + 1] = { + name = name, + title = name .. '(' .. sect .. ')', + } + end + end + + if vim.o.cscopetag then + -- return only a single entry so we work well with :cstag (#11675) + structured = { structured[1] } + end + + return vim.tbl_map(function(entry) + return { + name = entry.name, + filename = 'man://' .. entry.title, + cmd = '1', + } + end, structured) +end + +-- Called when Nvim is invoked as $MANPAGER. +function M.init_pager() + if fn.getline(1):match('^%s*$') then + api.nvim_buf_set_lines(0, 0, 1, false, {}) + else + vim.cmd('keepjumps 1') + end + highlight_man_page() + -- Guess the ref from the heading (which is usually uppercase, so we cannot + -- know the correct casing, cf. `man glDrawArraysInstanced`). + local ref = fn.substitute(matchstr(fn.getline(1), [[^[^)]\+)]]) or '', ' ', '_', 'g') + local ok, res = pcall(extract_sect_and_name_ref, ref) + vim.b.man_sect = ok and res or '' + + if not fn.bufname('%'):match('man://') then -- Avoid duplicate buffers, E95. + vim.cmd.file({ 'man://' .. fn.fnameescape(ref):lower(), mods = { silent = true } }) + end + + set_options(true) +end + +function M.open_page(count, smods, args) + if #args > 2 then + man_error('too many arguments') + end + + local ref + if #args == 0 then + ref = vim.bo.filetype == 'man' and fn.expand('<cWORD>') or fn.expand('<cword>') + if ref == '' then + man_error('no identifier under cursor') + end + elseif #args == 1 then + ref = args[1] + else + -- Combine the name and sect into a manpage reference so that all + -- verification/extraction can be kept in a single function. + -- If args[2] is a reference as well, that is fine because it is the only + -- reference that will match. + ref = ('%s(%s)'):format(args[2], args[1]) + end + + local sect, name = extract_sect_and_name_ref(ref) + if count >= 0 then + sect = tostring(count) + end + + local path = verify_exists(sect, name) + sect, name = extract_sect_and_name_path(path) + + local buf = fn.bufnr() + local save_tfu = vim.bo[buf].tagfunc + vim.bo[buf].tagfunc = "v:lua.require'man'.goto_tag" + + local target = ('%s(%s)'):format(name, sect) + + local ok, ret = pcall(function() + if not smods.tab and find_man() then + vim.cmd.tag({ target, mods = { silent = true, keepalt = true } }) + else + smods.silent = true + smods.keepalt = true + vim.cmd.stag({ target, mods = smods }) + end + end) + + vim.bo[buf].tagfunc = save_tfu + + if not ok then + error(ret) + else + set_options(false) + end + + vim.b.man_sect = sect +end + +-- Called when a man:// buffer is opened. +function M.read_page(ref) + local sect, name = extract_sect_and_name_ref(ref) + local path = verify_exists(sect, name) + sect = extract_sect_and_name_path(path) + local page = get_page(path) + vim.b.man_sect = sect + put_page(page) +end + +function M.show_toc() + local bufname = fn.bufname('%') + local info = fn.getloclist(0, { winid = 1 }) + if info ~= '' and vim.w[info.winid].qf_toc == bufname then + vim.cmd.lopen() + return + end + + local toc = {} + local lnum = 2 + local last_line = fn.line('$') - 1 + local section_title_re = vim.regex([[^\%( \{3\}\)\=\S.*$]]) + local flag_title_re = vim.regex([[^\s\+\%(+\|-\)\S\+]]) + while lnum and lnum < last_line do + local text = fn.getline(lnum) + if section_title_re:match_str(text) then + -- if text is a section title + toc[#toc + 1] = { + bufnr = fn.bufnr('%'), + lnum = lnum, + text = text, + } + elseif flag_title_re:match_str(text) then + -- if text is a flag title. we strip whitespaces and prepend two + -- spaces to have a consistent format in the loclist. + toc[#toc + 1] = { + bufnr = fn.bufnr('%'), + lnum = lnum, + text = ' ' .. fn.substitute(text, [[^\s*\(.\{-}\)\s*$]], [[\1]], ''), + } + end + lnum = fn.nextnonblank(lnum + 1) + end + + fn.setloclist(0, toc, ' ') + fn.setloclist(0, {}, 'a', { title = 'Man TOC' }) + vim.cmd.lopen() + vim.w.qf_toc = bufname +end + +local function init() + local path = get_path('', 'man', true) + local page + if path ~= nil then + -- Check for -l support. + page = get_page(path, true) + end + + if page == '' or page == nil then + localfile_arg = false + end +end + +init() + +return M diff --git a/runtime/plugin/man.lua b/runtime/plugin/man.lua new file mode 100644 index 0000000000..4b1528b0cb --- /dev/null +++ b/runtime/plugin/man.lua @@ -0,0 +1,34 @@ +if vim.g.loaded_man ~= nil then + return +end +vim.g.loaded_man = true + +vim.api.nvim_create_user_command('Man', function(params) + local man = require('man') + if params.bang then + man.init_pager() + else + local ok, err = pcall(man.open_page, params.count, params.smods, params.fargs) + if not ok then + vim.notify(man.errormsg or err, vim.log.levels.ERROR) + end + end +end, { + bang = true, + bar = true, + addr = 'other', + nargs = '*', + complete = function(...) + return require('man').man_complete(...) + end, +}) + +local augroup = vim.api.nvim_create_augroup('man', {}) + +vim.api.nvim_create_autocmd('BufReadCmd', { + group = augroup, + pattern = 'man://*', + callback = function(params) + require('man').read_page(vim.fn.matchstr(params.match, 'man://\\zs.*')) + end, +}) diff --git a/runtime/plugin/man.vim b/runtime/plugin/man.vim deleted file mode 100644 index b10677593f..0000000000 --- a/runtime/plugin/man.vim +++ /dev/null @@ -1,15 +0,0 @@ -" Maintainer: Anmol Sethi <hi@nhooyr.io> - -if exists('g:loaded_man') - finish -endif -let g:loaded_man = 1 - -command! -bang -bar -addr=other -complete=customlist,man#complete -nargs=* Man - \ if <bang>0 | call man#init_pager() | - \ else | call man#open_page(<count>, <q-mods>, <f-args>) | endif - -augroup man - autocmd! - autocmd BufReadCmd man://* call man#read_page(matchstr(expand('<amatch>'), 'man://\zs.*')) -augroup END diff --git a/src/nvim/testdir/test_profile.vim b/src/nvim/testdir/test_profile.vim index fdb6f13e2b..4225b91bc4 100644 --- a/src/nvim/testdir/test_profile.vim +++ b/src/nvim/testdir/test_profile.vim @@ -40,8 +40,8 @@ func Test_profile_func() call writefile(lines, 'Xprofile_func.vim') call system(GetVimCommand() \ . ' -es --clean' - \ . ' -c "so Xprofile_func.vim"' - \ . ' -c "qall!"') + \ . ' --cmd "so Xprofile_func.vim"' + \ . ' --cmd "qall!"') call assert_equal(0, v:shell_error) let lines = readfile('Xprofile_func.log') @@ -475,7 +475,7 @@ func Test_profdel_func() call Foo3() [CODE] call writefile(lines, 'Xprofile_file.vim') - call system(GetVimCommandClean() . ' -es -c "so Xprofile_file.vim" -c q') + call system(GetVimCommandClean() . ' -es --cmd "so Xprofile_file.vim" --cmd q') call assert_equal(0, v:shell_error) let lines = readfile('Xprofile_file.log') diff --git a/test/functional/plugin/man_spec.lua b/test/functional/plugin/man_spec.lua index 9304aa6da9..3e63c5df9a 100644 --- a/test/functional/plugin/man_spec.lua +++ b/test/functional/plugin/man_spec.lua @@ -1,7 +1,8 @@ local helpers = require('test.functional.helpers')(after_each) local Screen = require('test.functional.ui.screen') -local command, eval, rawfeed = helpers.command, helpers.eval, helpers.rawfeed +local command, rawfeed = helpers.command, helpers.rawfeed local clear = helpers.clear +local exec_lua = helpers.exec_lua local funcs = helpers.funcs local nvim_prog = helpers.nvim_prog local matches = helpers.matches @@ -50,7 +51,7 @@ describe(':Man', function() | ]]} - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ ^this {b:is} {b:a} test | @@ -74,7 +75,7 @@ describe(':Man', function() | ]=]} - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ ^this {b:is }{bi:a }{biu:test} | @@ -89,7 +90,7 @@ describe(':Man', function() rawfeed([[ ithis i<C-v><C-h>is<C-v><C-h>s あ<C-v><C-h>あ test with _<C-v><C-h>ö_<C-v><C-h>v_<C-v><C-h>e_<C-v><C-h>r_<C-v><C-h>s_<C-v><C-h>t_<C-v><C-h>r_<C-v><C-h>u_<C-v><C-h>̃_<C-v><C-h>c_<C-v><C-h>k te<C-v><ESC>[3mxt¶<C-v><ESC>[0m<ESC>]]) - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ ^this {b:is} {b:あ} test | @@ -105,7 +106,7 @@ describe(':Man', function() i_<C-v><C-h>_b<C-v><C-h>be<C-v><C-h>eg<C-v><C-h>gi<C-v><C-h>in<C-v><C-h>ns<C-v><C-h>s m<C-v><C-h>mi<C-v><C-h>id<C-v><C-h>d_<C-v><C-h>_d<C-v><C-h>dl<C-v><C-h>le<C-v><C-h>e _<C-v><C-h>m_<C-v><C-h>i_<C-v><C-h>d_<C-v><C-h>__<C-v><C-h>d_<C-v><C-h>l_<C-v><C-h>e<ESC>]]) - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ {b:^_begins} | @@ -121,7 +122,7 @@ describe(':Man', function() i· ·<C-v><C-h>· +<C-v><C-h>o +<C-v><C-h>+<C-v><C-h>o<C-v><C-h>o double<ESC>]]) - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ ^· {b:·} | @@ -138,7 +139,7 @@ describe(':Man', function() <C-v><C-[>[44m 4 <C-v><C-[>[45m 5 <C-v><C-[>[46m 6 <C-v><C-[>[47m 7 <C-v><C-[>[100m 8 <C-v><C-[>[101m 9 <C-v><C-[>[102m 10 <C-v><C-[>[103m 11 <C-v><C-[>[104m 12 <C-v><C-[>[105m 13 <C-v><C-[>[106m 14 <C-v><C-[>[107m 15 <C-v><C-[>[48:5:16m 16 <ESC>]]) - eval('man#init_pager()') + exec_lua[[require'man'.init_pager()]] screen:expect([[ ^ 0 1 2 3 | |