├── .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 |
--------------------------------------------------------------------------------