Repository navigation
Expand file tree
/
Copy pathsearch.vimrc
More file actions
361 lines (331 loc) · 12.4 KB
/
Copy pathsearch.vimrc
File metadata and controls
361 lines (331 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
" Editable search fields occupy lines 1-5; results start after the help/status.
let s:search_header_lines = 8
function! SearchBuffer()
if bufexists('search') || bufexists('search-preview')
call CloseSearchBuffers()
return
endif
let l:target_winid = win_getid()
let l:root = getcwd()
if executable('git')
let l:git_root = systemlist('git -C ' . shellescape(l:root) . ' rev-parse --show-toplevel 2>/dev/null')
if v:shell_error == 0 && !empty(l:git_root)
let l:root = l:git_root[0]
endif
endif
vnew
file search
setlocal buftype=nofile bufhidden=hide noswapfile nobuflisted nowrap
let b:search_root = l:root
let b:search_target_winid = l:target_winid
let b:search_locations = []
let b:search_result_lines = []
let b:search_match_positions = {}
" Search is line-oriented. Seed it with the first line of the clipboard.
let l:clipboard = split(getreg('+'), "\n", 1)
call setline(1, ['Search: ' . get(l:clipboard, 0, ''),
\ 'Regex: off', 'Match case: off', 'Include: ', 'Exclude: ',
\ 'Enter: search/toggle | <Leader>r: regex | <Leader>c: case | o: open',
\ 'Root: ' . b:search_root, 'Status: edit fields above, then press Enter'])
call cursor(1, strlen('Search: ') + 1)
nnoremap <silent> <buffer> <CR> :call SearchEnter()<CR>
inoremap <silent> <buffer> <CR> <Esc>:call ExecuteSearch()<CR>
nnoremap <silent> <buffer> <Leader>r :call ToggleSearchOption(2)<CR>
nnoremap <silent> <buffer> <Leader>c :call ToggleSearchOption(3)<CR>
nnoremap <silent> <buffer> o :call OpenFileFromSearchResult()<CR>
augroup SearchPreview
autocmd! * <buffer>
" Preview filetype detection must be able to trigger FileType and Syntax.
autocmd CursorMoved <buffer> ++nested call PreviewSearchResult()
augroup END
endfunction
function! CloseSearchBuffers()
for l:buffer_name in ['search-preview', 'search']
let l:bufnr = bufnr(l:buffer_name)
if l:bufnr != -1
execute 'bwipeout! ' . l:bufnr
endif
endfor
endfunction
function! SearchEnter()
if line('.') == 2 || line('.') == 3
call ToggleSearchOption(line('.'))
else
call ExecuteSearch()
endif
endfunction
function! ToggleSearchOption(line_number)
let l:label = a:line_number == 2 ? 'Regex: ' : 'Match case: '
call setline(a:line_number, l:label . (getline(a:line_number) ==# l:label . 'on' ? 'off' : 'on'))
call ExecuteSearch()
endfunction
function! s:SearchField(line_number, label)
let l:line = getline(a:line_number)
if stridx(l:line, a:label . ':') != 0
throw 'Keep the ' . a:label . ': field on line ' . a:line_number
endif
" Remove the one separator space, preserving whitespace in the search term.
return substitute(strpart(l:line, strlen(a:label) + 1), '^ ', '', '')
endfunction
function! s:SearchGlobs(value)
let l:patterns = []
for l:part in split(a:value, ',')
let l:pattern = trim(l:part)
if empty(l:pattern)
continue
endif
if l:pattern[0] ==# '!'
throw 'Use the Exclude field instead of a leading ! in a glob'
endif
let l:pattern = substitute(l:pattern, '^\./', '', '')
" A directory name is shorthand for its entire subtree.
if l:pattern =~# '/$' || isdirectory(b:search_root . '/' . l:pattern)
let l:pattern = substitute(l:pattern, '/\+$', '', '') . '/**'
endif
call add(l:patterns, l:pattern)
endfor
return l:patterns
endfunction
function! s:SearchJsonText(value)
if has_key(a:value, 'text')
return a:value.text
endif
" ripgrep encodes non-UTF-8 paths and lines as base64. Decode to raw bytes.
let l:alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let l:bits = 0
let l:count = 0
let l:escaped = ''
for l:char in split(a:value.bytes, '\zs')
if l:char ==# '='
break
endif
let l:bits = l:bits * 64 + stridx(l:alphabet, l:char)
let l:count += 6
if l:count >= 8
let l:count -= 8
let l:divisor = float2nr(pow(2, l:count))
let l:escaped .= printf('\x%02x', l:bits / l:divisor)
let l:bits = l:bits % l:divisor
endif
endfor
return eval('"' . l:escaped . '"')
endfunction
function! ExecuteSearch()
let l:position = getpos('.')
let b:search_locations = []
let b:search_result_lines = []
let b:search_match_positions = {}
if line('$') > s:search_header_lines
execute (s:search_header_lines + 1) . ',$delete _'
endif
" A new search must not leave an unrelated preview visible.
if bufexists('search-preview')
execute 'bwipeout! ' . bufnr('search-preview')
endif
let l:stderr = tempname()
try
if !executable('rg')
throw 'ripgrep (rg) is required; install it and ensure it is on PATH'
endif
let l:term = s:SearchField(1, 'Search')
let l:regex = trim(s:SearchField(2, 'Regex'))
let l:case = trim(s:SearchField(3, 'Match case'))
let l:include = s:SearchGlobs(s:SearchField(4, 'Include'))
let l:exclude = s:SearchGlobs(s:SearchField(5, 'Exclude'))
if index(['on', 'off'], l:regex) < 0 || index(['on', 'off'], l:case) < 0
throw 'Regex and Match case must be on or off (Enter toggles them)'
endif
if empty(l:term)
call setline(8, 'Status: enter a search term')
return
endif
let l:args = ['rg', '--no-config', '--json', '--hidden', '--no-require-git', '--color=never']
call add(l:args, l:case ==# 'on' ? '--case-sensitive' : '--ignore-case')
if l:regex ==# 'off'
call add(l:args, '--fixed-strings')
endif
if !empty(l:include)
" Overrides alone cannot traverse ignored directories. Limit an
" unrestricted traversal to explicitly included globs instead.
call add(l:args, '--no-ignore')
endif
for l:pattern in l:include
call extend(l:args, ['--glob', l:pattern])
endfor
for l:pattern in l:exclude
call extend(l:args, ['--glob', '!' . l:pattern])
endfor
" Exclusions win, and Git metadata is never part of a workspace search.
call extend(l:args, ['--glob', '!.git', '--', l:term, '.'])
let l:command = 'cd ' . shellescape(b:search_root) . ' && '
\ . join(map(copy(l:args), 'shellescape(v:val)'), ' ')
\ . ' 2>' . shellescape(l:stderr)
let l:output = systemlist(l:command)
let l:exit_code = v:shell_error
let l:matches = []
let l:binary_paths = {}
for l:line in l:output
let l:event = json_decode(l:line)
if get(l:event, 'type', '') ==# 'end' && get(l:event.data, 'binary_offset', v:null) isnot v:null
let l:binary_paths[s:SearchJsonText(l:event.data.path)] = 1
endif
if get(l:event, 'type', '') !=# 'match'
continue
endif
call add(l:matches, l:event.data)
endfor
for l:data in l:matches
" A NUL found later in a file can invalidate earlier JSON matches.
if has_key(l:binary_paths, s:SearchJsonText(l:data.path))
continue
endif
let l:path = substitute(s:SearchJsonText(l:data.path), '^\./', '', '')
let l:text = substitute(s:SearchJsonText(l:data.lines), '\r\?\n$', '', '')
let l:file_path = b:search_root . '/' . l:path
" Keep locations separate from display text, including paths with colons.
call add(b:search_locations, [l:file_path, l:data.line_number])
call add(b:search_result_lines, strtrans(l:path) . ':' . l:data.line_number . ':' . strtrans(l:text))
if !has_key(b:search_match_positions, l:file_path)
let b:search_match_positions[l:file_path] = []
endif
" rg and matchaddpos() both use byte offsets, including for UTF-8 text.
" Keep the engine's exact spans instead of translating its regex to Vim.
for l:match in l:data.submatches
if l:match.end > l:match.start
call add(b:search_match_positions[l:file_path],
\ [l:data.line_number, l:match.start + 1, l:match.end - l:match.start])
endif
endfor
endfor
call append(s:search_header_lines, b:search_result_lines)
if l:exit_code > 1
let l:errors = filereadable(l:stderr) ? join(readfile(l:stderr), ' ') : ''
throw 'ripgrep: ' . (empty(l:errors) ? 'exit code ' . l:exit_code : l:errors)
endif
call setline(8, empty(b:search_locations) ? 'Status: no matches' : 'Status: ' . len(b:search_locations) . ' matching lines')
catch
call setline(8, 'Status: error: ' . v:exception)
finally
call delete(l:stderr)
call setpos('.', l:position)
setlocal nomodified
endtry
endfunction
function! SearchResultLocation()
let l:index = line('.') - s:search_header_lines - 1
if l:index < 0 || l:index >= len(get(b:, 'search_locations', []))
return []
endif
" Do not navigate from an edited result line.
if getline('.') !=# b:search_result_lines[l:index]
return []
endif
return b:search_locations[l:index]
endfunction
function! OpenFileFromSearchResult()
let l:location = SearchResultLocation()
if empty(l:location)
echo 'Please select a valid search result.'
return
endif
" Reuse the window that opened search, independent of split layout or cwd.
if !win_gotoid(b:search_target_winid)
leftabove vnew
endif
execute 'edit ' . fnameescape(l:location[0])
call cursor(l:location[1], 1)
normal! zz
endfunction
function! PreviewSearchResult()
let l:location = SearchResultLocation()
if empty(l:location)
return
endif
let l:search_winid = win_getid()
let l:positions = get(b:search_match_positions, l:location[0], [])
let l:preview_bufnr = bufnr('search-preview')
let l:preview_winid = l:preview_bufnr == -1 ? -1 : bufwinid(l:preview_bufnr)
try
if l:preview_winid == -1
belowright split
if l:preview_bufnr == -1
enew
file search-preview
setlocal buftype=nofile bufhidden=hide noswapfile nobuflisted
else
execute 'buffer ' . l:preview_bufnr
endif
else
call win_gotoid(l:preview_winid)
endif
call ShowSearchPreview(l:location[0], l:location[1])
call s:HighlightSearchPreview(l:positions)
finally
call win_gotoid(l:search_winid)
endtry
endfunction
function! s:ClearSearchPreviewHighlights(buffer)
" Matches belong to windows, so remove only our overlays before reuse.
for l:winid in win_findbuf(a:buffer)
for l:id in getwinvar(l:winid, 'search_preview_match_ids', [])
silent! call matchdelete(l:id, l:winid)
endfor
call setwinvar(l:winid, 'search_preview_match_ids', [])
call setwinvar(l:winid, 'search_preview_match_buffer', -1)
endfor
endfunction
function! s:HighlightSearchPreview(positions)
if get(w:, 'search_preview_match_buffer', -1) == bufnr('%')
return
endif
call s:ClearSearchPreviewHighlights(bufnr('%'))
" Batches of eight also work with older Vim versions.
let l:start = 0
while l:start < len(a:positions)
call add(w:search_preview_match_ids,
\ matchaddpos('Search', a:positions[l:start : l:start + 7], 10))
let l:start += 8
endwhile
let w:search_preview_match_buffer = bufnr('%')
augroup SearchPreviewHighlights
autocmd! * <buffer>
autocmd BufWinLeave <buffer> call <SID>ClearSearchPreviewHighlights(str2nr(expand('<abuf>')))
augroup END
endfunction
function! ShowSearchPreview(file_path, line_number)
if get(b:, 'search_preview_file', '') ==# a:file_path
call JumpToSearchPreviewLine(a:line_number)
return
endif
" Filetype detection remembers that it already ran for this buffer while
" inside an autocommand. A fresh scratch buffer resets that state and any
" language-specific buffer settings when moving to a different file.
if exists('b:search_preview_file')
let l:old_bufnr = bufnr('%')
noautocmd enew
execute 'bwipeout! ' . l:old_bufnr
file search-preview
setlocal buftype=nofile bufhidden=hide noswapfile nobuflisted
endif
try
let l:lines = readfile(a:file_path)
catch
let l:lines = ['Unable to read ' . a:file_path, v:exception]
endtry
setlocal modifiable noreadonly
silent! %delete _
call setline(1, empty(l:lines) ? [''] : l:lines)
let b:search_preview_file = a:file_path
" Detect from the source filename and contents. Do not clear 'filetype'
" first: even assigning an empty value marks detection as already done.
filetype on
execute 'doautocmd <nomodeline> filetypedetect BufRead ' . fnameescape(a:file_path)
let &l:syntax = &l:filetype
setlocal nomodified nomodifiable readonly
call JumpToSearchPreviewLine(a:line_number)
endfunction
function! JumpToSearchPreviewLine(line_number)
call cursor(min([max([a:line_number, 1]), line('$')]), 1)
normal! zz
endfunction
nnoremap <Leader>ag :call SearchBuffer()<CR>