Vim Statusline: Word Search
I searched this for this and could not find what I needed.
In my status bar, I want to count the number of matches that occur in the current file. The vim command below returns what I want. I need the returned number to appear in my status bar.
:%s/^I^I//n
vim returns: 16 matches over 16 lines
FYI Explanation: I am working in a CSV file. I'm looking for two tabs (^ i ^ I) because that points to the lines I still need to work on. This way my desired status line will indicate how much work is left in the current file.
I don't know how to type vim command in the status bar, I know% {} can be used to run a function, but how do I run the vim search command? I've tried variations of the following, but they are clearly wrong and just ended up with an error.
:set statusline+= %{s/^I^I//n}
Help me wash one kenobi, you are my only hope!
source to share
The first thing to mention here is that for large files this feature will be completely impractical. The reason is that the status bar is redrawn after every cursor movement, after every command finishes, and probably after other events that I am not even aware of. Performing a regex search on the entire buffer, and in addition, not only the current buffer, but every visible window (since each window has its own status bar) will slow things down significantly. Do not misunderstand me; the idea behind this function is a good one, as it will give you an immediate and fully automatic indication of your remaining work, but computers are simply not infinitely efficient (unfortunately) and so this can easily become a problem. I have edited files with millions of lines of text,and one re-lookup can take many seconds on such buffers.
But assuming you keep your files fairly small, I figured out three possible solutions with which you can achieve this.
Solution # 1: exe: s and output redirection
You can use :exe
for a function :s
with a parameterized template and :redir
to redirect the output to a local variable.
Unfortunately, this has two unwanted side effects, which in the context of this function would be full unlocks, since they will occur every time the status bar is redrawn:
- The cursor moves to the beginning of the current line. (Personal notes: I never really understood why vim does this, whether you do it
:s
from a status bar invocation or manually enter it on the vim command line.) - Visual choice, if any, is lost.
(And there might actually be more adverse effects that I am not aware of.)
The cursor problem can be fixed by saving and restoring the cursor position with getcurpos()
and setpos()
. Note that this should be getcurpos()
, not getpos()
because the latter does not return the field curswant
that is needed to store the column the cursor "wants" to be on, which may be different from the column the cursor "actually" is on (for example, if the cursor is moved to a shorter line). Unfortunately, this getcurpos()
is a fairly recent addition to vim, namely 7.4.313, and doesn't even work correctly based on my testing. Fortunately, there are older winsaveview()
and winrestview()
function that can perform the task perfectly and compatible. Therefore, we will use them for now.
Solution # 1a: Restore visual selection with gv
The visual selection problem I thought could be solved by running gv
in normal mode, but for some reason the visual selection is completely corrupted in doing so. I've tested this on Cygwin CLI and Windows gvim and I don't have a solution for this (in regards to visual selection restoration).
Anyway, here's the result of the above construct:
fun! MatchCount(pat,...)
"" return the number of matches for pat in the active buffer, by executing an :s call and redirecting the output to a local variable
"" saves and restores both the cursor position and the visual selection, which are clobbered by the :s call, although the latter restoration doesn't work very well for some reason as of vim-7.4.729
"" supports global matching (/g flag) by taking an optional second argument appended to :s flags
if (a:0 > 1)| throw 'too many arguments'| endif
let flags = a:0 == 1 ? a:000[0] : ''
let mode = mode()
let pos = winsaveview()
redir => output| sil exe '%s/'.a:pat.'//ne'.flags| redir END
call winrestview(pos)
if (mode == 'v' || mode == 'V' || mode == nr2char(22))
exe 'norm!gv'
endif
if (match(output,'Pattern not found') != -1)
return 0
else
return str2nr(substitute(output,'^[\s\n]*\(\d\+\).*','\1',''))
endif
return
endfun
set statusline+=\ [%{MatchCount('\\t\\t')}]
A few random notes:
- The use
^[\s\n]*
in the pattern of extracting matches was necessary to barrel through a leading line break that is captured during a redirect (not sure why this is happening). An alternative would be to skip any character up to the first digit with a non-living multiplier on the dot atom, i.e.^.\{-}
... - Doubling backslashes in the parameter value is
statusline
necessary because interpolation / removal of backslashes occurs during parsing of the parameter value itself. In general, single quoted strings do not cause backslash interpolation / stripping , and our stringpat
, after parsing, ends up being concatenated directly to the string:s
passed to:exe
, so there is no backslash interpolation / stripping (at least not before evaluating commands:s
when the backslash occurs with the backslash, which is what we want). I find this a little confusing, since inside the construct%{}
you would expect it to be a normal flawless VimScript expression, but that's how it works. - I added a flag
/e
for the command:s
. This is necessary to handle the zero match case. It usually:s
really throws an error if there are zero matches. To invoke the status bar is a big problem, because any error that occurs while trying to redraw the status bar causes vim to invalidate the parameterstatusline
as a protective measure to prevent repeated errors. I initially looked for solutions that included an error like:try
and:catch
, but nothing worked; after an error occurs in the vim source, the flag (called_emsg
) is set, which we cannot undo and is thereforestatusline
doomed to that point. Luckily, I found a flag/e
that prevents the error from being thrown altogether.
Solution # 1b: Dodge visual mode with local buffer cache
I was not satisfied with the visual selection problem, so I wrote an alternative solution. This solution effectively avoids starting the search altogether if visual mode is in effect, and instead pulls the last known seek counter from the buffer-local cache. I'm sure this will never cause the search counter to become obsolete because it is impossible to edit the buffer without giving up visual mode (I'm sure ...).
So now the function MatchCount()
doesn't work with visual mode:
fun! MatchCount(pat,...)
if (a:0 > 1)| throw 'too many arguments'| endif
let flags = a:0 == 1 ? a:000[0] : ''
let pos = winsaveview()
redir => output| sil exe '%s/'.a:pat.'//ne'.flags| redir END
call winrestview(pos)
if (match(output,'Pattern not found') != -1)
return 0
else
return str2nr(substitute(output,'^[\s\n]*\(\d\+\).*','\1',''))
endif
return
endfun
And now we need this "predicate" helper function that tells us when it is (not) safe to run a command :s
:
fun! IsVisualMode(mode)
return a:mode == 'v' || a:mode == 'V' || a:mode == nr2char(22)
endfun
And now we need a caching layer that leads to the result of the predicate and runs the main function only if it is safe, otherwise it pulls from the buffer-local cache the last known return value that was captured from the last call to the primary function taking these exact arguments:
fun! BufferCallCache(buf,callName,callArgs,callElseCache)
let callCache = getbufvar(a:buf,'callCache')
if (type(callCache) != type({}))
unlet callCache
let callCache = {}
call UnletBufVar(a:buf,'callCache')
call setbufvar(a:buf,'callCache',callCache)
endif
if (a:callElseCache)
let newValue = call(a:callName,a:callArgs)
if (!has_key(callCache,a:callName.':Args') || !has_key(callCache,a:callName.':Value'))
let callCache[a:callName.':Args'] = []
let callCache[a:callName.':Value'] = []
endif
let i = len(callCache[a:callName.':Args'])-1
while (i >= 0)
let args = callCache[a:callName.':Args'][i]
if (args == a:callArgs)
let callCache[a:callName.':Value'][i] = newValue
return newValue
endif
let i -= 1
endwhile
let callCache[a:callName.':Args'] += [a:callArgs]
let callCache[a:callName.':Value'] += [newValue]
return newValue
else
if (has_key(callCache,a:callName.':Args') && has_key(callCache,a:callName.':Value'))
let i = len(callCache[a:callName.':Args'])-1
while (i >= 0)
let args = callCache[a:callName.':Args'][i]
if (args == a:callArgs)
return callCache[a:callName.':Value'][i]
endif
let i -= 1
endwhile
endif
return ''
endif
endfun
What do we need this helper function for, which I found somewhere on the Internet years ago:
fun! UnletBufVar(bufExpr, varName )
"" source: <http://vim.1045645.n5.nabble.com/unlet-ing-variables-in-buffers-td5714912.html>
call filter(getbufvar(a:bufExpr,''), 'v:key != '''.a:varName.'''' )
endfun
Finally, we can install statusline
:
set statusline+=\ [%{BufferCallCache('','MatchCount',['\\t\\t'],!IsVisualMode(mode()))}]
Solution # 2: Call match()
on each line
I thought of another possible solution that is actually much simpler and seems to work great for non-huge files, although it involves more looping and processing at the VimScript level. To do this, you need to iterate over all the lines in the file and call match()
:
fun! MatchCount(pat)
"" return the number of matches for pat in the active buffer, by iterating over all lines and calling match() on them
"" does not support global matching (normally achieved with the /g flag on :s)
let i = line('$')
let c = 0
while (i >= 1)
let c += match(getline(i),a:pat) != -1
let i -= 1
endwhile
return c
endfun
set statusline+=\ [%{MatchCount('\\t\\t')}]
Solution # 3: challenge search()
/ searchpos()
multiple times
I wrote some slightly complex functions to do global and linear matching, built on searchpos()
and search()
, respectively. I've also included support for optional start and end boundaries.
fun! GlobalMatchCount(pat,...)
"" searches for pattern matches in the active buffer, with optional start and end [line,col] specifications
"" useful command-line for testing against last-used pattern within last-used visual selection: echo GlobalMatchCount(@/,getpos("'<")[1:2],getpos("'>")[1:2])
if (a:0 > 2)| echoerr 'too many arguments for function: GlobalMatchCount()'| return| endif
let start = a:0 >= 1 ? a:000[0] : [1,1]
let end = a:0 >= 2 ? a:000[1] : [line('$'),2147483647]
"" validate args
if (type(start) != type([]) || len(start) != 2 || type(start[0]) != type(0) || type(start[1]) != type(0))| echoerr 'invalid type of argument: start'| return| endif
if (type(end) != type([]) || len(end) != 2 || type(end[0]) != type(0) || type(end[1]) != type(0))| echoerr 'invalid type of argument: end'| return| endif
if (end[0] < start[0] || end[0] == start[0] && end[1] < start[1])| echoerr 'invalid arguments: end < start'| return| endif
"" allow degenerate case of end == start; just return zero immediately
if (end == start)| return [0,0]| endif
"" save current cursor position
let wsv = winsaveview()
"" set cursor position to start (defaults to start-of-buffer)
call setpos('.',[0,start[0],start[1],0])
"" accumulate match count and line count in local vars
let matchCount = 0
let lineCount = 0
"" also must keep track of the last line number in which we found a match for lineCount
let lastMatchLine = 0
"" add one if a match exists right at start; must treat this case specially because the main loop must avoid matching at the cursor position
if (searchpos(a:pat,'cn',start[0])[1] == start[1])
let matchCount += 1
let lineCount += 1
let lastMatchLine = 1
endif
"" keep searching until we hit end-of-buffer
let ret = searchpos(a:pat,'W')
while (ret[0] != 0)
"" break if the cursor is now at or past end; must do this prior to incrementing for most recent match, because if the match start is at or past end, it not a valid match for the caller
if (ret[0] > end[0] || ret[0] == end[0] && ret[1] >= end[1])
break
endif
let matchCount += 1
if (ret[0] != lastMatchLine)
let lineCount += 1
let lastMatchLine = ret[0]
endif
let ret = searchpos(a:pat,'W')
endwhile
"" restore original cursor position
call winrestview(wsv)
"" return result
return [matchCount,lineCount]
endfun
fun! LineMatchCount(pat,...)
"" searches for pattern matches in the active buffer, with optional start and end line number specifications
"" useful command-line for testing against last-used pattern within last-used visual selection: echo LineMatchCount(@/,getpos("'<")[1],getpos("'>")[1])
if (a:0 > 2)| echoerr 'too many arguments for function: LineMatchCount()'| return| endif
let start = a:0 >= 1 ? a:000[0] : 1
let end = a:0 >= 2 ? a:000[1] : line('$')
"" validate args
if (type(start) != type(0))| echoerr 'invalid type of argument: start'| return| endif
if (type(end) != type(0))| echoerr 'invalid type of argument: end'| return| endif
if (end < start)| echoerr 'invalid arguments: end < start'| return| endif
"" save current cursor position
let wsv = winsaveview()
"" set cursor position to start (defaults to start-of-buffer)
call setpos('.',[0,start,1,0])
"" accumulate line count in local var
let lineCount = 0
"" keep searching until we hit end-of-buffer
let ret = search(a:pat,'cW')
while (ret != 0)
"" break if the latest match was past end; must do this prior to incrementing lineCount for it, because if the match start is past end, it not a valid match for the caller
if (ret > end)
break
endif
let lineCount += 1
"" always move the cursor to the start of the line following the latest match; also, break if we're already at end; otherwise next search would be unnecessary, and could get stuck in an infinite loop if end == line('$')
if (ret == end)
break
endif
call setpos('.',[0,ret+1,1,0])
let ret = search(a:pat,'cW')
endwhile
"" restore original cursor position
call winrestview(wsv)
"" return result
return lineCount
endfun
source to share
Maybe not exactly what you are looking for, but if you put a function like this in your $ HOME / .vimrc file:
:set statusline+=%!SearchResults('^I^I')
$ HOME / .vimrc
function SearchResults(q)
redir => matches
silent! execute "%s/".a:q."//n"
redir END
return substitute(matches, "^.", "", "")
endfunction
If nothing else, maybe it will bring you closer.
source to share