├── README.rst ├── autoload └── shout.vim ├── ftplugin └── shout.vim ├── plugin └── shout.vim └── syntax └── shout.vim /README.rst: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | VIM-SHOUT 3 | ################################################################################ 4 | 5 | Run and Capture Shell Command Output 6 | #################################### 7 | 8 | .. note:: 9 | 10 | Should work with Vim9 (compiled with ``HUGE`` features). 11 | 12 | 13 | I always wanted a simpler way to run an arbitrary shell command with the output 14 | being captured into some throwaway buffer. Mostly for the simple scripting 15 | (press a button, script is executed and the output is immediately visible). 16 | 17 | I have used (and still use) relevant builtin commands (``:make``, ``:!cmd``, 18 | ``:r !cmd`` and all the jazz with quickfix/location-list windows) but ... I 19 | didn't feel it worked my way. 20 | 21 | This works my way though. 22 | 23 | .. image:: https://asciinema.org/a/DaVumBuy1qtyXoIsNveok70dF.svg 24 | :target: https://asciinema.org/a/DaVumBuy1qtyXoIsNveok70dF 25 | 26 | 27 | Mappings 28 | ======== 29 | 30 | In a ``[shout]`` buffer: 31 | 32 | :kbd:`Enter` 33 | - While on line 1, re-execute the command. 34 | - Switch (open) to the file under cursor. 35 | 36 | :kbd:`Space` + :kbd:`Enter` 37 | Open file under cursor in a new tabpage. 38 | 39 | :kbd:`CTRL-C` 40 | Kill the shell command. 41 | 42 | :kbd:`]]` 43 | Goto next error. 44 | 45 | :kbd:`[[` 46 | Goto previous error. 47 | 48 | :kbd:`[{` 49 | Goto first error. 50 | 51 | :kbd:`]}` 52 | Goto last error. 53 | 54 | 55 | Commands 56 | ======== 57 | 58 | ``:Sh {command}`` 59 | Start ``{command}`` in background, open existing ``[shout]`` buffer or create 60 | a new one and print output of ``stdout`` and ``stderr`` there. 61 | Put cursor to the end of buffer. 62 | 63 | .. code:: 64 | 65 | :Sh ls -lah 66 | :Sh make 67 | :Sh python 68 | 69 | ``:Sh! {command}`` 70 | Same as ``Sh`` but keep cursor on line 1. 71 | 72 | .. code:: 73 | 74 | :Sh! rg -nS --column "\b(TODO|FIXME|XXX):" . 75 | 76 | ``:Shut`` 77 | Close shout window. 78 | 79 | 80 | Examples of User Commands 81 | ========================= 82 | 83 | ``:Rg searchpattern``, search using ripgrep:: 84 | 85 | command! -nargs=1 Rg Sh! rg -nS --column "" . 86 | 87 | ``:Todo``, search for all TODOs, FIXMEs and XXXs using ripgrep:: 88 | 89 | command! Todo Sh! rg -nS --column "\b(TODO|FIXME|XXX):" . 90 | 91 | 92 | Examples of User Mappings 93 | ========================= 94 | 95 | Search word under cursor:: 96 | 97 | nnoremap 8 exe "Rg" expand("") 98 | 99 | Run python script (put into ``~/.vim/after/ftplugin/python.vim``):: 100 | 101 | nnoremap exe "Sh python" expand("%:p") 102 | 103 | Build and run rust project (put into ``~/.vim/after/ftplugin/rust.vim``):: 104 | 105 | nnoremap Sh cargo run 106 | nnoremap Sh cargo build 107 | nnoremap Sh cargo build --release 108 | 109 | 110 | Build and run a single c-file without ``Makefile`` or project with ``Makefile`` 111 | (put into ``~/.vim/after/ftplugin/c.vim``):: 112 | 113 | vim9script 114 | 115 | def Make() 116 | if filereadable("Makefile") 117 | Sh make 118 | else 119 | var fname = expand("%:p:r") 120 | exe $"Sh make {fname} && chmod +x {fname} && {fname}" 121 | endif 122 | enddef 123 | 124 | nnoremap Make() 125 | 126 | 127 | .. image:: https://asciinema.org/a/566982.svg 128 | :target: https://asciinema.org/a/566982 129 | 130 | 131 | Options, Variables 132 | ================== 133 | 134 | ``g:shout_print_exit_code`` 135 | Add empty line followed by "Exit code: X" line to the end of ``[shout]`` buffer if set to ``true``: 136 | Default is ``true``. 137 | 138 | ``b:shout_exit_code`` 139 | Buffer local varibale. Contains exit code of the latest executed command. 140 | Could be useful in custom statuslines. 141 | 142 | ``b:shout_cmd`` 143 | Buffer local variable. Contains latest executed command. 144 | Could be useful in custom statuslines. 145 | -------------------------------------------------------------------------------- /autoload/shout.vim: -------------------------------------------------------------------------------- 1 | vim9script 2 | 3 | const W_THRESHOLD = 160 4 | 5 | var shout_job: job 6 | 7 | def Vertical(): string 8 | var result = "" 9 | # if the overall vim width is too narrow or 10 | # there are >=2 vertical windows, split below 11 | if &columns >= W_THRESHOLD && winlayout()[0] != 'row' 12 | result ..= "vertical" 13 | endif 14 | return result 15 | enddef 16 | 17 | def FindOtherWin(): number 18 | var result = -1 19 | var winid = win_getid() 20 | for wnd in range(1, winnr('$')) 21 | if win_getid(wnd) != winid 22 | result = win_getid(wnd) 23 | break 24 | endif 25 | endfor 26 | return result 27 | enddef 28 | 29 | def ShoutWinId(): number 30 | var buffers = getbufinfo()->filter((_, v) => fnamemodify(v.name, ":t") =~ '^\[shout\]$') 31 | for shbuf in buffers 32 | if len(shbuf.windows) > 0 33 | return shbuf.windows[0] 34 | endif 35 | endfor 36 | return -1 37 | enddef 38 | 39 | def PrepareBuffer(shell_cwd: string): number 40 | var bufname = '[shout]' 41 | var buffers = getbufinfo()->filter((_, v) => fnamemodify(v.name, ":t") == bufname) 42 | 43 | var bufnr = -1 44 | 45 | if len(buffers) > 0 46 | bufnr = buffers[0].bufnr 47 | else 48 | bufnr = bufadd(bufname) 49 | endif 50 | 51 | var windows = win_findbuf(bufnr) 52 | var initial_winid = win_getid() 53 | 54 | if windows->len() == 0 55 | exe "botright" Vertical() "sbuffer" bufnr 56 | b:shout_initial_winid = initial_winid 57 | setl filetype=shout 58 | else 59 | win_gotoid(windows[0]) 60 | endif 61 | 62 | silent :%d _ 63 | 64 | b:shout_cwd = shell_cwd 65 | exe "silent lcd" shell_cwd 66 | 67 | setl undolevels=-1 68 | 69 | return bufnr 70 | enddef 71 | 72 | export def CaptureOutput(command: string, follow: bool = false) 73 | var cwd = getcwd() 74 | var bufnr = PrepareBuffer(cwd->substitute('#', '\\&', 'g')) 75 | 76 | setbufvar(bufnr, "shout_exit_code", "") 77 | 78 | setbufline(bufnr, 1, $"$ {command}") 79 | setbufline(bufnr, 2, "") 80 | 81 | if shout_job->job_status() == "run" 82 | shout_job->job_stop() 83 | endif 84 | 85 | var job_command: any 86 | if has("win32") 87 | job_command = command 88 | else 89 | job_command = [&shell, &shellcmdflag, escape(command, '\')] 90 | endif 91 | 92 | shout_job = job_start(job_command, { 93 | cwd: cwd, 94 | out_io: 'buffer', 95 | out_buf: bufnr, 96 | out_msg: 0, 97 | err_io: 'out', 98 | err_buf: bufnr, 99 | err_msg: 0, 100 | close_cb: (channel) => { 101 | if !bufexists(bufnr) 102 | return 103 | endif 104 | var winid = bufwinid(bufnr) 105 | var exit_code = job_info(shout_job).exitval 106 | if get(g:, "shout_print_exit_code", true) 107 | var msg = [""] 108 | msg += ["Exit code: " .. exit_code] 109 | appendbufline(bufnr, line('$', winid), msg) 110 | endif 111 | if follow 112 | win_execute(winid, "normal! G") 113 | endif 114 | setbufvar(bufnr, "shout_exit_code", $"{exit_code}") 115 | win_execute(winid, "setl undolevels&") 116 | } 117 | }) 118 | 119 | b:shout_cmd = command 120 | 121 | if follow 122 | normal! G 123 | endif 124 | enddef 125 | 126 | export def OpenFile() 127 | var shout_cwd = get(b:, "shout_cwd", "") 128 | if !empty(shout_cwd) 129 | exe "silent lcd" b:shout_cwd 130 | endif 131 | 132 | # re-run the command if on line 1 133 | if line('.') == 1 134 | var cmd = getline(".")->matchstr('^\$ \zs.*$') 135 | if cmd !~ '^\s*$' 136 | var pos = getcurpos() 137 | CaptureOutput(cmd, false) 138 | setpos('.', pos) 139 | endif 140 | return 141 | endif 142 | 143 | # Windows has : in `isfname` thus for ./filename:20:10: gf can't find filename cause 144 | # it sees filename:20:10: instead of just filename 145 | # So the "hack" would be: 146 | # - take or a line under cursor 147 | # - extract file name, line, column 148 | # - edit file name 149 | 150 | # python 151 | var fname = getline('.')->matchlist('^\s\+File "\(.\{-}\)", line \(\d\+\)') 152 | 153 | # erlang escript 154 | if empty(fname) 155 | fname = getline('.')->matchlist('^\s\+in function\s\+.\{-}(\(.\{-}\), line \(\d\+\))') 156 | endif 157 | 158 | # rust 159 | if empty(fname) 160 | fname = getline('.')->matchlist('^\s\+--> \(.\{-}\):\(\d\+\):\(\d\+\)') 161 | endif 162 | 163 | # regular filename:linenr:colnr: 164 | if empty(fname) 165 | fname = getline('.')->matchlist('^\(.\{-}\):\(\d\+\):\(\d\+\).*') 166 | endif 167 | 168 | # regular filename:linenr: 169 | if empty(fname) 170 | fname = getline('.')->matchlist('^\(.\{-}\):\(\d\+\):\?.*') 171 | endif 172 | 173 | # regular filename: 174 | if empty(fname) 175 | fname = getline('.')->matchlist('^\(.\{-}\):.*') 176 | endif 177 | 178 | if fname->len() > 0 && filereadable(fname[1]) 179 | try 180 | var should_split = false 181 | var buffers = getbufinfo()->filter((_, v) => v.name == fnamemodify(fname[1], ":p")) 182 | fname[1] = fname[1]->substitute('#', '\\&', 'g') 183 | # goto opened file if it is visible 184 | if len(buffers) > 0 && len(buffers[0].windows) > 0 185 | win_gotoid(buffers[0].windows[0]) 186 | # goto first non shout window otherwise 187 | elseif win_gotoid(FindOtherWin()) 188 | if !&hidden && &modified 189 | should_split = true 190 | endif 191 | else 192 | should_split = true 193 | endif 194 | 195 | exe $"lcd {shout_cwd}" 196 | 197 | if should_split 198 | exe Vertical() "split" fname[1] 199 | else 200 | exe "edit" fname[1] 201 | endif 202 | 203 | if !empty(fname[2]) 204 | exe $":{fname[2]}" 205 | exe "normal! 0" 206 | endif 207 | 208 | if !empty(fname[3]) && fname[3]->str2nr() > 1 209 | exe $"normal! {fname[3]->str2nr() - 1}l" 210 | endif 211 | normal! zz 212 | catch 213 | endtry 214 | endif 215 | enddef 216 | 217 | export def Kill() 218 | if shout_job != null 219 | job_stop(shout_job) 220 | endif 221 | enddef 222 | 223 | export def CloseWindow() 224 | var winid = ShoutWinId() 225 | if winid == -1 226 | return 227 | endif 228 | var winnr = getwininfo(winid)[0].winnr 229 | exe $":{winnr}close" 230 | enddef 231 | 232 | export def NextError() 233 | # Search for python error 234 | var rxError = '^.\{-}:\d\+\(:\d\+:\?\)\?' 235 | var rxPyError = '^\s*File ".\{-}", line \d\+,' 236 | var rxErlEscriptError = '^\s\+in function\s\+.\{-}(.\{-}, line \d\+)' 237 | search($'\({rxError}\)\|\({rxPyError}\)\|\({rxErlEscriptError}\)', 'W') 238 | enddef 239 | 240 | export def FirstError() 241 | :2 242 | NextError() 243 | enddef 244 | 245 | export def PrevError(accept_at_curpos: bool = false) 246 | var rxError = '^.\{-}:\d\+\(:\d\+:\?\)\?' 247 | var rxPyError = '^\s*File ".\{-}", line \d\+,' 248 | var rxErlEscriptError = '^\s\+in function\s\+.\{-}(.\{-}, line \d\+)' 249 | search($'\({rxError}\)\|\({rxPyError}\)\|\({rxErlEscriptError}\)', 'bW') 250 | enddef 251 | 252 | export def LastError() 253 | :$ 254 | if getline('$') =~ "^Exit code: .*$" 255 | PrevError() 256 | else 257 | PrevError(true) 258 | endif 259 | enddef 260 | 261 | export def NextErrorJump() 262 | if win_gotoid(ShoutWinId()) 263 | :exe "normal ]]\" 264 | endif 265 | enddef 266 | 267 | export def FirstErrorJump() 268 | if win_gotoid(ShoutWinId()) 269 | :exe "normal [{\" 270 | endif 271 | enddef 272 | 273 | export def PrevErrorJump() 274 | if win_gotoid(ShoutWinId()) 275 | :exe "normal [[\" 276 | endif 277 | enddef 278 | 279 | export def LastErrorJump() 280 | if win_gotoid(ShoutWinId()) 281 | :exe "normal ]}\" 282 | endif 283 | enddef 284 | -------------------------------------------------------------------------------- /ftplugin/shout.vim: -------------------------------------------------------------------------------- 1 | vim9script 2 | 3 | if exists("b:did_ftplugin") 4 | finish 5 | endif 6 | 7 | b:did_ftplugin = 1 8 | 9 | setl cursorline 10 | setl cursorlineopt=both 11 | setl bufhidden=hide 12 | setl buftype=nofile 13 | setl buflisted 14 | setl noswapfile 15 | setl noundofile 16 | 17 | b:undo_ftplugin = 'setl cursorline< cursorlineopt< bufhidden< buftype< buflisted< swapfile< undofile<' 18 | b:undo_ftplugin ..= '| exe "nunmap "' 19 | b:undo_ftplugin ..= '| exe "nunmap "' 20 | b:undo_ftplugin ..= '| exe "nunmap ]]"' 21 | b:undo_ftplugin ..= '| exe "nunmap [["' 22 | b:undo_ftplugin ..= '| exe "nunmap ]}"' 23 | b:undo_ftplugin ..= '| exe "nunmap [{"' 24 | b:undo_ftplugin ..= '| exe "nunmap gq"' 25 | 26 | import autoload 'shout.vim' 27 | 28 | nnoremap shout.OpenFile() 29 | nnoremap shout.Kill() 30 | nnoremap ]] shout.NextError() 31 | nnoremap [[ shout.PrevError() 32 | nnoremap [{ shout.FirstError() 33 | nnoremap ]} shout.LastError() 34 | nnoremap gq c 35 | -------------------------------------------------------------------------------- /plugin/shout.vim: -------------------------------------------------------------------------------- 1 | vim9script 2 | 3 | if exists('g:loaded_shout') 4 | finish 5 | endif 6 | g:loaded_shout = 1 7 | 8 | import autoload 'shout.vim' 9 | 10 | command! -nargs=1 -bang -complete=file Sh shout.CaptureOutput(, empty() ? true : false) 11 | command! Shut shout.CloseWindow() 12 | -------------------------------------------------------------------------------- /syntax/shout.vim: -------------------------------------------------------------------------------- 1 | vim9script 2 | 3 | if exists("b:current_syntax") 4 | finish 5 | endif 6 | 7 | syn match shoutCmdPrompt "\%^\$" 8 | syn match shoutExitCodeErr "^Exit code: .*\%$" 9 | syn match shoutExitCodeNoErr "^Exit code: 0\%$" 10 | 11 | syn match shoutCargoPath "-->\s\+.\{-}:\d\+:\d\+" contains=shoutCargoPathNr 12 | syn match shoutCargoPathNr ":\d\+:\d\+" contained 13 | 14 | syn match shoutGrepPath "^\S.\{-}\S:\(\d\+:\)\{1,2}" contains=shoutGrepPathNr 15 | syn match shoutGrepPathNr ":\(\d\+:\)\{1,2}" contained 16 | 17 | syn match shoutPythonLocation '^\s\+File ".\{-}", line \d\+' contains=shoutPythonPath,shoutPythonNr 18 | syn match shoutPythonPath 'File "\zs.\{-}\ze"' contained 19 | syn match shoutPythonNr "line \zs\d\+" contained 20 | 21 | syn match shoutError "\c^\s*error:\ze " nextgroup=shoutMsg 22 | syn match shoutWarning "\c^\s*warning:\ze " nextgroup=shoutMsg 23 | syn match shoutSpecialInfo '^\s\+Compiling\|Finished\|Running\s\+' nextgroup=shoutMsg 24 | syn match shoutMsg ".*$" contained 25 | 26 | # erlang escript 27 | syn region shoutError matchgroup=shoutError start="^escript:" matchgroup=shoutMsg end="errors.$" contains=shoutMsg oneline 28 | syn region shoutError matchgroup=shoutError start="^escript: exception error:" end="$" contains=shoutMsg oneline keepend 29 | syn match shoutLocation '^\s\+in function\s\+.\{-}(.\{-}, line \d\+)' contains=shoutPath,shoutNr 30 | syn match shoutPath '(\zs.\{-}\ze, ' contained 31 | syn match shoutNr "line \zs\d\+" contained 32 | 33 | syn match shoutTexWarning '^Underfull \\[hv]box (badness \d\+).*$' 34 | syn match shoutTexError '^\s*==> .* <==$' 35 | 36 | syn match shoutTodo "\<\(TODO\|FIXME\|XXX\):" 37 | 38 | hi def link shoutCmdPrompt Statement 39 | hi def link shoutPath String 40 | hi def link shoutNr Constant 41 | 42 | hi def link shoutCargoPath String 43 | hi def link shoutCargoPathNr Constant 44 | hi def link shoutGrepPath String 45 | hi def link shoutGrepPathNr Constant 46 | hi def link shoutPythonPath String 47 | hi def link shoutPythonNr Constant 48 | 49 | hi def link shoutError ErrorMsg 50 | hi def link shoutWarning WarningMsg 51 | hi def link shoutMsg Title 52 | hi def link shoutSpecialInfo PreProc 53 | 54 | hi def link shoutExitCodeNoErr Comment 55 | hi def link shoutExitCodeErr WarningMsg 56 | 57 | hi def link shoutTexWarning WarningMsg 58 | hi def link shoutTexError ErrorMsg 59 | 60 | hi def link shoutTodo Todo 61 | 62 | b:current_syntax = "shout" 63 | --------------------------------------------------------------------------------