├── .github └── FUNDING.yml ├── README.markdown ├── autoload └── haystack.vim └── plugin └── haystack.vim /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tpope 2 | custom: ["https://www.paypal.me/vimpope"] 3 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # haystack.vim 2 | 3 | Haystack provides a fuzzy matching algorithm for use by other Vim plugins. 4 | It's based on [flx][] for Emacs but adapted to my own real world experience. 5 | The algorithm gives priority to runs of consecutive letters and letters after 6 | punctuation marks. 7 | 8 | [flx]: https://github.com/lewang/flx 9 | 10 | ## Usage 11 | 12 | Haystack defines `g:completion_filter` with a reference to a function for 13 | filtering a list of items based on a user provided query. Plugins can check 14 | for this variable and use it if it exists. This level of indirection allows 15 | for alternative matching algorithms without the need for plugins to be aware 16 | of each one. 17 | 18 | So far, out of the box support is included with [projectionist.vim][], 19 | including dependent plugins like [rails.vim][]. 20 | 21 | [projectionist.vim]: https://github.com/tpope/vim-projectionist 22 | [rails.vim]: https://github.com/tpope/vim-rails 23 | 24 | ### Use with fuzzy finders 25 | 26 | Here's a proof of concept for [ctrlp.vim][]. 27 | 28 | function! CtrlPMatch(items, str, limit, mmode, ispath, crfile, regex) abort 29 | let items = copy(a:items) 30 | if a:ispath 31 | call filter(items, 'v:val !=# a:crfile') 32 | endif 33 | return haystack#filter(items, a:str) 34 | endfunction 35 | let g:ctrlp_match_func = {'match': function('CtrlPMatch')} 36 | 37 | Note that the algorithm is slow enough to feel sluggish with larger data sets. 38 | You might consider a hybrid approach that uses a different algorithm above a 39 | certain number of items. 40 | 41 | [ctrlp.vim]: https://github.com/kien/ctrlp.vim 42 | 43 | ## License 44 | 45 | Copyright © Tim Pope. Distributed under the same terms as Vim itself. 46 | See `:help license`. 47 | -------------------------------------------------------------------------------- /autoload/haystack.vim: -------------------------------------------------------------------------------- 1 | " Location: autoload/haystack.vim 2 | " Author: Tim Pope 3 | 4 | if exists('g:autoloaded_haystack') 5 | finish 6 | endif 7 | let g:autoloaded_haystack = 1 8 | 9 | " Arguments: 10 | " 1st: List of items to filter. Will be mutated. 11 | " 2nd: Query to filter by. 12 | " 3rd: Path separator (slash), if different from platform default. 13 | " 4th: Reserved for a future options dictionary. 14 | function! haystack#filter(list, query, ...) abort 15 | if empty(a:query) 16 | return a:list 17 | endif 18 | call map(a:list, '[99999999-haystack#score(v:val, a:query, a:0 ? a:1 : haystack#slash()), type(v:val) == type({}) ? v:val.word : v:val, v:val]') 19 | call filter(a:list, 'v:val[0] < 99999999') 20 | call sort(a:list) 21 | call map(a:list, 'v:val[2]') 22 | return a:list 23 | endfunction 24 | 25 | function! haystack#slash() abort 26 | return exists('+shellslash') && !&shellslash ? '\' : '/' 27 | endfunction 28 | 29 | if !exists('s:hashes') 30 | let s:hashes = {} 31 | let s:heatmaps = {} 32 | endif 33 | 34 | function! s:get_hash_for_str(str) abort 35 | if has_key(s:hashes, a:str) 36 | return s:hashes[a:str] 37 | endif 38 | let res = {} 39 | let i = 0 40 | for char in split(tolower(a:str), '\zs') 41 | let res[char] = get(res, char, []) 42 | call add(res[char], i) 43 | let i += 1 44 | endfor 45 | let s:hashes[a:str] = res 46 | return res 47 | endfunction 48 | 49 | function! haystack#heatmap(str, ...) abort 50 | let key = (a:0 ? (empty(a:1) ? "\002" : a:1) : "\001") . a:str 51 | if has_key(s:heatmaps, key) 52 | return s:heatmaps[key] 53 | endif 54 | let chars = split(a:str, '\zs') 55 | let scores = repeat([-35], len(chars)) 56 | let groups_alist = [[-1, 0]] 57 | let scores[-1] += 1 58 | let last_char = '' 59 | let group_word_count = 0 60 | let i = 0 61 | for char in chars 62 | let effective_last_char = empty(group_word_count) ? '' : last_char 63 | if effective_last_char.char =~# '\U\u\|[[:punct:][:space:]][^[:punct:][:space:]]\|^.$' 64 | call insert(groups_alist[0], i, 2) 65 | endif 66 | if last_char.char =~# '^[[:punct:][:space:]]\=[^[:punct:][:space:]]$' 67 | let group_word_count += 1 68 | endif 69 | if last_char ==# '.' 70 | let scores[i] -= 45 71 | endif 72 | if a:0 && a:1 ==# char 73 | let groups_alist[0][1] = group_word_count 74 | let group_word_count = 0 75 | call insert(groups_alist, [i, group_word_count]) 76 | endif 77 | if i == len(chars) - 1 78 | let groups_alist[0][1] = group_word_count 79 | endif 80 | let i += 1 81 | let last_char = char 82 | endfor 83 | let group_count = len(groups_alist) 84 | let separator_count = group_count - 1 85 | if separator_count 86 | call map(scores, 'v:val - 2*group_count') 87 | endif 88 | let i = separator_count 89 | let last_group_limit = 0 90 | let basepath_found = 0 91 | for group in groups_alist 92 | let group_start = group[0] 93 | let word_count = group[1] 94 | let words_length = len(group) - 2 95 | let basepath_p = 0 96 | if words_length && !basepath_found 97 | let basepath_found = 1 98 | let basepath_p = 1 99 | endif 100 | if basepath_p 101 | let num = 35 + (separator_count > 1 ? separator_count - 1 : 0) - word_count 102 | else 103 | let num = i ? (i - 6) : -3 104 | endif 105 | for j in range(group_start+1, last_group_limit ? last_group_limit-1 : len(scores)-1) 106 | let scores[j] += num 107 | endfor 108 | let wi = words_length - 1 109 | let last_word = last_group_limit ? last_group_limit : len(chars) 110 | for word in group[2:-1] 111 | let scores[word] += 85 112 | let ci = 0 113 | for j in range(word, last_word-1) 114 | let scores[j] += -3 * wi - ci 115 | let ci += 1 116 | endfor 117 | let last_word = word 118 | let wi -= 1 119 | endfor 120 | 121 | let last_group_limit = group_start + 1 122 | let i -= 1 123 | endfor 124 | let s:heatmaps[key] = scores 125 | return scores 126 | endfunction 127 | 128 | function! s:get_matches(str, query) abort 129 | return s:compute_matches(s:get_hash_for_str(a:str), a:query, -1, 0) 130 | endfunction 131 | 132 | function! s:compute_matches(hash, query, gt, qi) abort 133 | let indexes = filter(copy(get(a:hash, a:query[a:qi], [])), 'v:val > a:gt') 134 | if a:qi >= len(a:query)-1 135 | return map(indexes, '[v:val]') 136 | endif 137 | let results = [] 138 | for index in indexes 139 | let next = s:compute_matches(a:hash, a:query, index, a:qi+1) 140 | call extend(results, map(next, '[index] + v:val')) 141 | endfor 142 | return results 143 | endfunction 144 | 145 | function! haystack#flx_score(str, query, sep) abort 146 | if empty(a:query) || empty(a:str) 147 | return -1 148 | endif 149 | if a:str !~? '\M'.substitute(escape(a:query, '\'), '.', '&\\.\\*', 'g') 150 | return 0 151 | endif 152 | let query = split(tolower(a:query), '\zs') 153 | let best_score = [] 154 | let heatmap = haystack#heatmap(a:str, a:sep) 155 | let matches = s:get_matches(a:str, query) 156 | let full_match_boost = len(query) > 1 && len(query) < 5 157 | 158 | for match_positions in matches 159 | let score = full_match_boost && len(match_positions) == len(a:str) ? 10000 : 0 160 | let contiguous_count = 0 161 | let last_match = -2 162 | for index in match_positions 163 | if last_match + 1 == index 164 | let contiguous_count += 1 165 | else 166 | let contiguous_count = 0 167 | endif 168 | let score += heatmap[index] 169 | if contiguous_count 170 | let score += 45 + 15 * min([contiguous_count, 4]) 171 | endif 172 | let last_match = index 173 | endfor 174 | if score > get(best_score, 0, -1) 175 | let best_score = [score] + match_positions 176 | endif 177 | endfor 178 | return get(best_score, 0, 0) 179 | endfunction 180 | 181 | function! haystack#score(word, query, ...) abort 182 | let word = type(a:word) == type({}) ? a:word.word : a:word 183 | let breaks = len(substitute(substitute(substitute(word, 184 | \ '[[:punct:]]*$', '', ''), 185 | \ '.\u\l', '@', 'g'), 186 | \ '[^[:punct:]]', '', 'g')) 187 | return haystack#flx_score(word, a:query, a:0 ? a:1 : haystack#slash()) * 10 / 188 | \ (2+breaks) 189 | endfunction 190 | 191 | if !has('pythonx') 192 | finish 193 | endif 194 | 195 | pythonx << EOF 196 | import vim 197 | from collections import defaultdict 198 | 199 | def flx_vim_encode(data): 200 | if isinstance(data, list): 201 | return "[" + ",".join([flx_vim_encode(x) for x in data]) + "]" 202 | elif isinstance(data, int): 203 | return str(data) 204 | else: 205 | raise TypeError("can't encode " + data) 206 | 207 | flx_hashes = {} 208 | def flx_get_hash_for_str(str): 209 | if str in flx_hashes: 210 | return flx_hashes[str] 211 | res = defaultdict(list) 212 | i = 0 213 | for char in str.lower(): 214 | res[char].append(i) 215 | i += 1 216 | flx_hashes[str] = res 217 | return res 218 | 219 | def flx_get_matches(hash, query, gt=-1, qi=0): 220 | qc = query[qi] 221 | indexes = [int(item) for item in hash[qc] if item > gt] 222 | if qi >= len(query)-1: 223 | return [[item] for item in indexes] 224 | results = [] 225 | for index in indexes: 226 | next = flx_get_matches(hash, query, index, qi+1) 227 | results += [[index] + item for item in next] 228 | return results 229 | EOF 230 | 231 | function! s:get_matches(str, query) abort 232 | pythonx vim.command('return ' + flx_vim_encode(flx_get_matches(flx_get_hash_for_str(vim.eval('a:str')), vim.eval('a:query')))) 233 | endfunction 234 | -------------------------------------------------------------------------------- /plugin/haystack.vim: -------------------------------------------------------------------------------- 1 | " haystack.vim 2 | " Author: Tim Pope 3 | " Version: 1.0 4 | 5 | if exists('g:loaded_haystack') 6 | finish 7 | endif 8 | let g:loaded_haystack = 1 9 | 10 | if !exists('g:completion_filter') 11 | let g:completion_filter = {'Apply': function('haystack#filter')} 12 | endif 13 | 14 | if !exists('g:projectionist_completion_filter') 15 | let g:projectionist_completion_filter = {'Apply': function('haystack#filter')} 16 | endif 17 | --------------------------------------------------------------------------------