Skip to content

Instantly share code, notes, and snippets.

@adscriven
Last active September 13, 2020 01:08
Show Gist options
  • Save adscriven/71b932ea1d87689379bc3c164c0ac1b9 to your computer and use it in GitHub Desktop.
Save adscriven/71b932ea1d87689379bc3c164c0ac1b9 to your computer and use it in GitHub Desktop.
Interactive file search (Vim)
" Way out of date now ...
" findfile.vim -- interactive file search
" Experimental; caveat emptor.
" Documentation is at the end.
" Public Domain.
let s:cpo = &cpo
set cpo&vim
" The script-local variabless are considered to be constants.
let s:ei = &ei
" Options
let s:canhighlightregexp = 1
let s:defmode = 'basic'
let s:canpreview = 1
let s:canhighlightsingleitem = 1
let s:delay = {
\ 'showpreview': 100,
\ 'cursormoved': 350,
\ 'changeinput': 900
\ }
let s:thisfile = expand('<sfile>:t')
let s:cacheprefix = expand('~/findcache')
let s:unavailprv = expand('~/[not\ available]')
let s:modes = ['basic', 'advanced', 'regexp']
let s:modesym = ' +/'
let s:infolnum = 1
let s:inputlnum = 2
let s:item1lnum = 3
fun! s:getmodel()
return get(b:, 'find', {})
endfun
fun! s:err(msg)
echohl errormsg
echo '(' . s:thisfile . ') ' . a:msg
echohl None
endfun
" msg -> update -> task
fun! s:do(id, f)
let m = s:getmodel()
set ei=all
call a:f()
let &ei = s:ei
endfun
fun! s:later(t, id, f)
let m = s:getmodel()
call timer_stop(get(m.timer, a:id, 0))
let id = timer_start(a:t, {t_id -> s:do(a:id, a:f)}, {'repeat': 1})
let m.timer[a:id] = id
endfun
" 'changeinput' msgs are a special case, because
" we want to implement a variable delay for those.
fun! s:msg(name, in)
let m = s:getmodel()
let out = s:u_{a:name}(m, a:in)
let t = get(s:delay, a:name, 0)
if a:name == 'changeinput' && t
let t = m.nfilt / t
endif
call s:later(t, a:name, {-> s:t_{a:name}(out)})
endfun
fun! s:q_isoneitem(m)
return a:m.nfilt == 1
endfun
fun! s:q_oninfoline()
let m = s:getmodel()
return m.line == s:infolnum
endfun
fun! s:q_oninputline()
let m = s:getmodel()
return m.line == s:inputlnum
endfun
fun! s:q_onitems()
let m = s:getmodel()
return m.line > s:inputlnum
endfun
fun! s:restorecursor(m)
call setpos('.', a:m.pos)
endfun
fun! s:u_cleanup(m, in)
return a:m
endfun
fun! s:t_cleanup(model)
let &cpo = s:cpo
hide pclose
call clearmatches()
call timer_stopall()
stopinsert
" set stl<
endfun
fun! s:u_renderinfo(m, in)
let a:m.uiinfo = '[' . a:m.modesym . '] ' .
\ a:m.info . ': ' . a:m.nfilt
return a:m
endfun
fun! s:t_renderinfo(m)
call setline(s:infolnum, a:m.uiinfo)
endfun
fun! s:u_renderitems(m, in)
let matches = a:m.filt[0:99]
if a:m.nfilt > 100
call add(matches, '...')
endif
let a:m.matches = matches
return a:m
endfun
fun! s:t_renderitems(m)
silent! exe s:item1lnum . ',$d'
" Everything matched; don't bother displaying results.
if a:m.nfilt == a:m.nitems || a:m.nfilt == 0
return
endif
if line('$') == 1
put=''
endif
" append causes redraw problems
put=a:m.matches
call clearmatches()
call s:msg('highlightregexp', 0)
if s:canhighlightsingleitem
call matchadd('search', '\%' . s:item1lnum . 'l.*\%$', -1)
endif
call s:restorecursor(a:m)
endfun
fun! s:u_renderinput(m, in)
return a:m
endfun
fun! s:t_renderinput(m)
if line('$') == 1
norm!o
endif
sign unplace 2
exe 'sign place 2 line=' . s:inputlnum .
\ ' name=find buffer=' . a:m.bufnr
call setline(s:inputlnum, a:m.input)
call s:restorecursor(a:m)
endfun
fun! s:u_renderui(m, in)
return a:m
endfun
fun! s:t_renderui(m)
call s:msg('renderinfo', 0)
call s:msg('renderinput', 0)
call s:msg('renderitems', 0)
call s:msg('showpreview', 0)
echo ''
endfun
fun! s:u_reset(m, in)
let a:m.showpreview = 0
let a:m.input = ''
let a:m.filt = a:m.items
let a:m.nfilt = a:m.nitems
let a:m.pos = [0, 2, 1, 0, 1]
return a:m
endfun
fun! s:t_reset(m)
call s:msg('renderui', 0)
endfun
fun! s:u_initwin(m, in)
let a:m.bufnr = bufnr('%')
let a:m.inittick = a:in.inittick
let a:m.dir = a:in.dir
let a:m.info = '#' . a:in.count . ' ' . a:in.dir
let a:m.items = a:in.items
let a:m.nitems = len(a:in.items)
let a:m.filt = a:m.items
let a:m.nfilt = a:m.nitems
let a:m.count = a:in.count
let a:m.modeidx = index(s:modes, s:defmode)
let a:m.mode = s:modes[a:m.modeidx]
let a:m.prevmode = ''
let a:m.modesym = s:modesym[a:m.modeidx]
let a:m.pos = [0, 2, 1, 0, 1]
let a:m.timer = {}
let a:m.line = 2
let a:m.uiinput = ''
return a:m
endfun
fun! s:t_initwin(data)
sign define find linehl=specialkey texthl=specialkey text=>
let &l:stl = '> ' . a:data.info . '%= %l,%v %P%<'
setl noswf bt=nofile nobl cul noudf scl=yes bh=wipe
set cpo-=v cpo-=$
au! bufenter <buffer> set cpo-=$ cpo-=v
au! bufwinleave <buffer> call s:t_cleanup({})
au! textchanged <buffer> call s:msg('textchanged', 0)
au! textchangedi <buffer> call s:msg('textchanged', 0)
au! cursormoved <buffer> silent call s:msg('cursormoved', 0)
au! cursormovedi <buffer> silent call s:msg('cursormoved', 0)
ino <buffer> <silent> <CR> <C-R>=<SID>cr()<CR>
nno <buffer> <silent> <CR> :<C-U>call <SID>cr()<CR>
ino <buffer> <silent> <BS> <C-R>=<SID>bs()<CR>
nno <buffer> <silent> <BS> :<C-U>call <SID>bs()<CR>
call s:msg('reset', 0)
endfun
fun! s:u_showpreview(m, in)
if s:q_oninputline() && !s:q_isoneitem(a:m) || s:q_oninfoline()
let a:m.showpreview = 0
return a:m
endif
let a:m.hasroomforpreview = a:m.showpreview ||
\ winheight(0) > (&previewheight + 1 + 3)
let a:m.previewfile = s:q_isoneitem(a:m)
\ ? a:m.filt[0]
\ : getline('.')
if a:m.previewfile[0:1] == '//' ||
\ !filereadable(a:m.previewfile)
let a:m.previewfile = s:unavailprv
endif
let a:m.showpreview = 1
return a:m
endfun
fun! s:t_showpreview(m)
if !has('quickfix')
return
endif
if !a:m.showpreview
hide pclose
return
endif
let view = winsaveview()
silent! exe 'pedit ' . fnameescape(a:m.previewfile)
call winrestview(view)
endfun
fun! s:u_highlightregexp(m, data)
let a:m.highlightregexp = a:m.mode == 'regexp'
return a:m
endfun
fun! s:t_highlightregexp(data)
if s:canhighlightregexp
if a:data.highlightregexp
call matchadd('search', a:data.input, -1)
endif
endif
endfun
fun! s:u_filtered(m, filt)
let a:m.filt = a:filt
let a:m.nfilt = len(a:filt)
return a:m
endfun
fun! s:t_filtered(m)
call s:msg('renderui', 0)
endfun
fun! s:bs()
if s:q_oninputline() && mode() == 'n' ||
\ s:q_oninputline() && mode() == 'i' && b:find.uiinput == ''
call s:msg('cyclemode', 0)
return ''
else
if mode() == 'i'
return "\<BS>"
else
exe "norm!\<BS>"
endif
endif
endfun
fun! s:u_cyclemode(m, in)
let a:m.modeidx = (a:m.modeidx + 1) % len(s:modes)
let a:m.prevmode = a:m.mode
let a:m.mode = s:modes[a:m.modeidx]
let a:m.modesym = s:modesym[a:m.modeidx]
let a:m.filt = a:m.items
call s:msg('changeinput', a:m.uiinput)
return a:m
endfun
fun! s:t_cyclemode(data)
endfun
fun! s:filterord(input, list)
let input = substitute(a:input, '^\s\+\|\s\+$', '', 'g')
if input == ''
return a:list
endif
let terms = '\%#=2\V' .
\ substitute(escape(input, '\'), '\s\+', '\\.\\*', 'g')
return input =~# '\u'
\ ? filter(copy(a:list), {i, item -> item =~# terms})
\ : filter(copy(a:list), {i, item -> item =~? terms})
endfun
fun! s:filterunord(input, list)
let input = substitute(a:input, '^\s\+\|\s\+$', '', 'g')
if input == ''
return a:list
endif
let terms = split(input, ' ')
call filter(terms, {i, term -> term != '!'})
if empty(terms)
return a:list
endif
call map(terms, {i, term -> escape(term, '\')})
let exact = input =~# '\u'
let list = terms[0][0] == '!'
\ ? exact
\ ? filter(copy(a:list), {i, item -> item !~# '\V' .
\ terms[0][1:]})
\ : filter(copy(a:list), {i, item -> item !~? '\V' .
\ terms[0][1:]})
\ : exact
\ ? filter(copy(a:list), {i, item -> item =~# '\V' . terms[0]})
\ : filter(copy(a:list), {i, item -> item =~? '\V' . terms[0]})
if len(terms) > 1
let filt = []
for item in list
for term in terms[1:]
let match = term[0] == '!'
\ ? exact
\ ? item !~# '\V' . term[1:]
\ : item !~? '\V' . term[1:]
\ : exact
\ ? item =~# '\V' . term
\ : item =~? '\V' . term
if !match
break
endif
endfor
if match
call add(filt, item)
endif
endfor
else
let filt = list
endif
return filt
endfun
fun! s:u_changeinput(m, input)
let a:m.shouldchangeinput = 0
if a:m.input == a:input && a:m.mode == a:m.prevmode
return a:m
endif
let a:m.shouldchangeinput = 1
let a:m.previnput = a:m.input
let a:m.input = getline(s:inputlnum)
return a:m
endfun
fun! s:srchregexp(data)
let input = a:data.input
let filt = copy(a:data.items)
if input =~# '\u'
call filter(filt, {i, item -> item =~# input})
else
call filter(filt, {i, item -> item =~? input})
endif
return filt
endfun
fun! s:srchbasic(data)
let previnput = a:data.previnput
let curinput = a:data.input
let items = previnput != '' && len(s:filterord(previnput, [curinput]))
\ ? a:data.filt
\ : a:data.items
return s:filterord(curinput, items)
endfun
fun! s:srchadv(data)
" If the search type hasn't changed, and input is a subset of
" b:find.input, filter the previous filtered list, rather than
" starting from scratch.
" XXX: not good enough now that ! terms can result in widening
" the search. We need to keep track of the b:find.filt list for
" the previous set of terms and use that as the basis of the
" filtering operation when appending to a ! term.
let previnput = a:data.previnput
let curinput = a:data.input
let items = previnput != '' && len(s:filterord(previnput, [curinput]))
\ ? a:data.filt
\ : a:data.items
return s:filterunord(curinput, items)
endfun
fun! s:t_changeinput(m)
if !a:m.shouldchangeinput
return
endif
let input = a:m.input
let mode = a:m.mode
if input == ''
let filt = a:m.items
else
echo 'Searching ...'
if mode == 'regexp'
let filt = s:srchregexp(a:m)
elseif mode == 'advanced'
let filt = s:srchadv(a:m)
elseif mode == 'basic'
let filt = s:srchbasic(a:m)
endif
endif
call s:msg('filtered', filt)
endfun
fun! s:cr()
if mode() == 'i' && s:q_onitems()
return "\<CR>"
endif
call s:msg('pressedcr', 0)
return ''
endfun
fun! s:u_pressedcr(m, in)
let a:m.shouldopenfile = 0
if s:q_oninputline() &&
\ a:m.mode == 'regexp' &&
\ a:m.input != a:m.uiinput
call s:msg('changeinput', a:m.uiinput)
elseif s:q_oninputline() && s:q_isoneitem(a:m) || s:q_onitems()
let a:m.shouldopenfile = 1
stopinsert
let a:m.filetoopen = s:q_oninputline()
\ ? getline('$')
\ : getline('.')
endif
return a:m
endfun
fun! s:openfile(file)
if !isdirectory(a:file) && !filereadable(a:file)
call s:err('Can''t read file: ' . a:file)
return
endif
call s:t_cleanup({})
let &ei = s:ei
if bufexists(a:file)
" Don't mess about if the buffer is already open. You could be
" on a slow network.
exe 'b ' . fnameescape(a:file)
else
exe 'e ' . fnameescape(a:file)
endif
doau bufenter,bufread
stopinsert
endfun
fun! s:t_pressedcr(data)
if a:data.shouldopenfile
call s:openfile(a:data.filetoopen)
return
endif
endfun
fun! s:u_back(m, in)
let a:m.pos = a:m.prevpos
return a:m
endfun
fun! s:t_back(m)
call s:msg('renderui', 0)
endfun
fun! s:u_textchanged(m, in)
let a:m.changedtick = b:changedtick
let a:m.uiinput = getline(s:inputlnum)
let a:m.uiitem1 = getline(s:item1lnum)
let a:m.oninputline = s:q_oninputline()
let a:m.oninfoline = s:q_oninfoline()
return a:m
endfun
fun! s:t_textchanged(m)
" No change since we started, but the autocmd can fire anyway during
" initialisation.
if a:m.changedtick == a:m.inittick
return
endif
let trashedbuf = line('$') == 1
let trashedinfo = a:m.oninfoline && a:m.uiinfo != getline(s:infolnum)
let trashedinput = a:m.oninputline &&
\ a:m.nfilt < a:m.nitems &&
\ get(a:m.filt, 0, '') != a:m.uiitem1
if trashedbuf || trashedinfo
call s:msg('back', 0)
return
endif
if !a:m.oninputline
return
endif
" Regexp: wait for CR
if a:m.mode == 'regexp' && a:m.uiinput != ''
return
endif
call s:msg('changeinput', a:m.uiinput)
endfun
fun! s:u_cursormoved(m, in)
let a:m.prevline = a:m.line
let a:m.line = line('.')
let a:m.prevpos = a:m.pos
let a:m.pos = getcurpos()
return a:m
endfun
fun! s:t_cursormoved(m)
if a:m.line != a:m.prevline
call s:msg('showpreview', 0)
endif
endfun
fun! s:find(count, bang, mods, dir)
let s:logitems = {}
let mod = &mod && len(getbufinfo('%')[0].windows) == 1
if mod && !&hidden && a:mods !~# '\<hide\>'
call s:err('Buffer modified. :w the buffer, :set hidden, ' .
\ 'or use :hide Find')
return
endif
exe a:mods . ' enew'
let b:find = {}
let cache = s:cacheprefix . a:count
if a:dir != ''
let append = a:bang == ''
let dir = fnamemodify(a:dir, ':p')
let out = systemlist('find ' . dir)
silent! let cached = append ? readfile(cache) : []
if !empty(cached)
let dir .= ', ' . cached[0]
let cached = cached[1:]
endif
let lines = [dir] + cached + out
call writefile(lines, cache)
endif
let cached = a:dir == '' ? readfile(cache) : lines
let dir = cached[0]
let items = cached[1:]
" before any msgs are sent
startinsert
call s:msg('initwin', {
\ 'count': a:count,
\ 'dir': dir,
\ 'items': items,
\ 'inittick': b:changedtick
\ })
endfun
fun! s:init()
com! -range=0 -bang -nargs=? Find
\ call s:find(<count>, <q-bang>, <q-mods>, <q-args>)
endfun
call s:init()
let &cpo = s:cpo
finish
REQUIREMENTS
Vim 8.
find(1) for generating file and directory lists.
INVOCATION
:[N] Find [!] [DIR]
N Optional cache number. Default: 0.
:[N] Find Reads the initial list of files from ~/findcacheN,
and makes the current window a Find File window.
Insert mode is started in the Search Input Line.
:[N] Find! Same as :Find.
:[N] Find DIR Uses find(1) to find files and directories
starting at DIR. The list is appended to
~/findcacheN. Otherwise the same as for :Find.
:[N] Find! DIR Uses find(1) to find files and directories
starting at DIR. ~/findcacheN is overwritten
with this list. Otherwise the same as :Find.
:Find may be used with :hide
:Find may not be followed with '|'.
FIND FILE WINDOW
The local status line is '> Find file (N)', where N is the cached
list in use.
The first line is the Search Input Line. It has a '>' marker in the
sign column.
The remaining lines display a maximum of a hundred found files.
If more files are found, '...' is displayed in the last line, along
with a total count.
<CR> in a Find File window act as follows:
Search Input Line, Insert mode:
If there is more than one file found, leave Insert mode.
If there is one file found, open it.
Search Input Line, Normal mode:
If there is more than one file found, do nothing.
If there is one file found, open it.
Other lines, Insert mode:
<CR>
Other lines, Normal mode:
Open the file.
Preview
If there is a single found file, the preview window displays its
contents. If there is more than one file found, move the cursor
over a filename to display it in the preview window. If a file
is unreadable the preview window is empty.
The preview window will be closed when leaving the Find File
window.
Typical usage
To open a file, with a mapping for :Find:
\o
start typing stuff
hit <CR> when there is one match left
SEARCHING
Three search modes are available: regular expressions, ordered
search terms, and unordered search terms. They all work as if
'ignorecase' and 'smartcase' are set.
1. Regular expressions
Requires '/' as the first character of the search.
Press <CR> to perform the search.
2. Ordered search terms
This is the default. They are separated by spaces. They are
searched for literally, and must appear in matching file paths
in the same order as typed. E.g.
> foo bar baz
will match '/foofoo/aaa/barbar/bbb/bazbaz', but not
'/foofoo/aaa/bazbaz/bbb/barbar'
Search results update as you type.
3. Unordered search terms
Requires '?' as the first character of the search. Like (2) they
are separated by spaces, but may appear matching file paths in
any order. E.g.
> ?baz foo bar
will match '/foofoo/aaa/barbar/bbb/bazbaz'.
Search terms can be negated by prefixing with '!'. E.g.
> ?bar !foo
will match '/bar/baz',
but not '/foo/bar/baz'.
Search results update as you type.
TASKS
[*] The filtering optimisation for unordered terms is no longer
correct since adding negation. See the code. It will still work
for single character negations.
[.] Docmentation.
[.] A ~73k line file list, albeit larger than usual, will cause
~32MB memory usage (Windows)! Not a problem for me, but this
seems excessive. The lists don't seem to get garbage-collected.
This becomes a problem if you have multiple Find File windows
open. This isn't normal, but is still a potential issue.
[ ] Add required features to REQUIREMENTS.
[ ] Exclude certain patterns from the search results? E.g. undo
files and other junk. (Change the find(1) command.)
[ ] Unordered search allows overlapping terms. Maybe this is
a feature.
[ ] Restore the preview window if it was already open. It's ot an
issue for me personally, but might be nice. It could take some
effort though.
[ ] Is there a better way to display an empty preview window than
this?
exe 'pedit ' . s:unavailprv
[ ] is there a way to detect an active VPN connection?
[ ] Undo on input line should invoke s:textchanged
[ ] Autoload. Don't personally care, but probably ought to.
@adscriven
Copy link
Author

An out-of-date experiment. Unused for ages (I implemented a much faster fuzzy finder that didn't need asynchronicity, used input(), used :echo to display the UI, and provided a much better UX -- and then I stopped using that).

This is interesting only because it implements a simple aysynchronous 'reactive' model. Also because it uses a buffer as the UI.

q_ functions query the model. u_ functions update the model. t_ functions are tasks that run asynchronously after the model has been updated.

Handling input asynchronously improves perceived response time and reduces the number of filter operations.

Incidentally you can improve on this immensely -- :read [!] a list of files into a buffer, then :g/RE/d or :v/RE/d to filter them.
gf to open a file. This is extremely simple, convenient with a few well-chosen mappings, and is a general technique which can be applied to various situations. Other benefits are saving/editing/reopening filtered buffers, searching them with /, and being able to undo filter operations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment