├── README.md
├── lua
└── nvim-find
│ ├── async.lua
│ ├── config.lua
│ ├── defaults.lua
│ ├── filters
│ ├── cache.lua
│ ├── filename.lua
│ ├── init.lua
│ ├── join.lua
│ ├── simple.lua
│ ├── sort.lua
│ └── wrap.lua
│ ├── init.lua
│ ├── job.lua
│ ├── mappings.lua
│ ├── set.lua
│ ├── sources
│ ├── buffers.lua
│ ├── fd.lua
│ ├── init.lua
│ └── rg.lua
│ └── utils.lua
└── plugin
└── nvim-find.vim
/README.md:
--------------------------------------------------------------------------------
1 | # Notice
2 |
3 | I am no longer maintaining this plugin. To my knowledge, it still
4 | functions perfectly fine as a simple fuzzy finder interface for neovim.
5 |
6 | When I first wrote nvim-find, I wanted a better fuzzy-finder matching
7 | experience in neovim, because I'm not perfectly happy with fzf or fzy's
8 | algorithms. So I made this plugin.
9 |
10 | Later, after [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)
11 | fixed a few missing features, I realized it would be a better use of my time to
12 | use telescope for my fuzzy finder interface, and make my own algorithm to be
13 | used in telescope and in the terminal.
14 |
15 | So I have created [zf](https://github.com/natecraddock/zf) as a replacement to fzf
16 | and fzy, and [telescope-zf-native.nvim](https://github.com/natecraddock/telescope-zf-native.nvim)
17 | to integrate zf with telescope. This means I don't have to maintain a fuzzy finding
18 | interface _and_ a sorting algorithm.
19 |
20 | So you are welcome to use this, but I would recommend using telescope and zf
21 | if you want to have a filename matching algorithm similar to nvim-find.
22 |
23 | # nvim-find
24 |
25 | A fast and simple finder plugin for Neovim
26 |
27 | ## Goals
28 |
29 | * **Speed:** The finder should open fast and filter quickly
30 | * **Simplicity:** The finder should be unobtrusive and not distract from flow
31 | * **Extensible:** It should be easy to create custom finders
32 |
33 | ## Default Finders
34 |
35 | For usage instructions see the [Finders](#finders) section below.
36 |
37 | * **Files:** Find files in the current working directory respecting gitignore
38 | * **Buffers:** List open buffers
39 | * **Search:** Search using ripgrep in the current working directory
40 |
41 | ## Requirements
42 |
43 | **Requires Neovim >= v0.5.0**
44 |
45 | Optional dependencies:
46 | * [`fd`](https://github.com/sharkdp/fd) for listing files.
47 | * [`ripgrep`](https://github.com/BurntSushi/ripgrep) for listing files or for project search.
48 | `ripgrep` may be used in place of fd for listing files.
49 |
50 | ## Installation
51 |
52 | Install with a plugin manager such as:
53 |
54 | [packer](https://github.com/wbthomason/packer.nvim)
55 |
56 | ```
57 | use 'natecraddock/nvim-find'
58 | ```
59 |
60 | [vim-plug](https://github.com/junegunn/vim-plug)
61 |
62 | ```
63 | Plug 'natecraddock/nvim-find'
64 | ```
65 |
66 | # Configuration
67 |
68 | Access the config table by requiring `nvim-find.config`. Edit the values of the config table
69 | to change how nvim-find behaves. For example:
70 |
71 | ```lua
72 | local cfg = require("nvim-find.config")
73 |
74 | cfg.height = 14 -- set max height
75 | ```
76 |
77 | ## Configuration Options
78 |
79 | The available options are as follows, with their default values:
80 |
81 | ```lua
82 | local cfg = require("nvim-find.config")
83 |
84 | -- maximum height of the finder
85 | cfg.height = 20
86 |
87 | -- maximum width of the finder
88 | cfg.width = 100
89 |
90 | -- list of ignore globs for the filename filter
91 | -- e.g. "*.png" will ignore any file ending in .png and
92 | -- "*node_modules*" ignores any path containing node_modules
93 | cgf.files.ignore = {}
94 |
95 | -- start with all result groups collapsed
96 | cfg.search.start_closed = false
97 | ```
98 |
99 | # Finders
100 |
101 | Finders are not mapped by default. Each section below indicates which function to map to enable
102 | quick access to the finder. The default command is also listed if available.
103 |
104 | If a finder is **transient** then it can be closed immediately with esc. A **non-transient**
105 | finder will return to normal mode when esc is pressed.
106 |
107 | Finders open centered at the top of the terminal window. Any finder with a file preview draws centered
108 | and is expanded to fill more of the available space.
109 |
110 | ## General
111 |
112 | These mappings are always enabled when a finder is open
113 |
114 | Key(s) | Mapping
115 | -------|--------
116 | ctrl-j or ctrl-n | select next result
117 | ctrl-k or ctrl-p | select previous result
118 | ctrl-c | close finder
119 | esc or ctrl-[ | close finder if transient or enter normal mode
120 |
121 | A **non-transient** finder has the following additional mappings in normal mode
122 |
123 | Key(s) | Mapping
124 | -------|--------
125 | j or n | select next result
126 | k or p | select previous result
127 | ctrl-c or esc or ctrl-[ | close finder
128 |
129 | ## Files
130 | **Transient**. Find files in the current working directory.
131 |
132 | Because the [majority of file names are unique](https://nathancraddock.com/posts/in-search-of-a-better-finder/)
133 | within a project, the file finder does not do fuzzy-matching. The query is separated into space-delimited tokens.
134 | The first token is used to filter the file list by file name. The remaining tokens are used to further reduce the
135 | list of results by matching against the full file paths.
136 |
137 | Additionally, if no matches are found, then the first token will be matched against the full path rather than only
138 | the filename.
139 |
140 | Although this finder does not do fuzzy-matching, there is still some degree of sloppiness allowed. If the characters
141 | `-_.` are not included in the query they will be ignored in the file paths. For example, the query
142 | `outlinerdrawc` matches the file `outliner_draw.c`.
143 |
144 | This algorithm is the main reason I created `nvim-find`.
145 |
146 | Example mapping:
147 | ```
148 | nnoremap :lua require("nvim-find.defaults").files()
149 | ```
150 |
151 | **Command:** `:NvimFindFiles`
152 |
153 | Key | Mapping
154 | ----|--------
155 | enter | open selected file in last used buffer
156 | ctrl-v | split vertically and open selected file
157 | ctrl-s | split horizontally and open selected file
158 | ctrl-t | open selected file in a new tab
159 |
160 | ## Buffers
161 | **Transient**. List open buffers.
162 |
163 | Lists open buffers. The alternate buffer is labeled with `(alt)`, and any buffers with unsaved changes
164 | are prefixed with a circle icon.
165 |
166 | Example mapping:
167 | ```
168 | nnoremap b :lua require("nvim-find.defaults").buffers()
169 | ```
170 |
171 | **Command:** `:NvimFindBuffers`
172 |
173 | Key | Mapping
174 | ----|--------
175 | enter | open selected file in last used buffer
176 | ctrl-v | split vertically and open selected file
177 | ctrl-s | split horizontally and open selected file
178 | ctrl-t | open selected file in a new tab
179 |
180 | ## Search (`ripgrep`)
181 | **Non-transient**. Search files in the current working directory with ripgrep with a preview.
182 |
183 | This finder shows a preview of the match in context of the file. The results are grouped by file,
184 | and tab can be used to expand or collapse a file's group. After choosing a result the
185 | lines are also sent to the quickfix list for later reference.
186 |
187 | ### Search at cursor
188 | To search for the word under the cursor, an additional function is exposed
189 | `require("nvim-find.defaults").search_at_cursor()`.
190 |
191 | In cases where more than a single word should be searched for, the desired text can be selected
192 | in visual mode. Then calling `require("nvim-find.defaults").search()` will search for the selected
193 | text. This requires a visual mode mapping.
194 |
195 | Example mapping:
196 | ```
197 | nnoremap f :lua require("nvim-find.defaults").search()
198 | ```
199 |
200 | **Command:** `:NvimFindSearch`
201 |
202 |
203 | Key | Mapping
204 | ----|--------
205 | gg | scroll to the top of the list
206 | G | scroll to the bottom of the list
207 | tab | open or close current group fold
208 | o | open or close all group folds (toggles)
209 | ctrl-q (insert) or q (normal) | send results to the quickfix list and close
210 | enter | insert: switch to normal mode. normal: open selected match in last used buffer
211 | ctrl-v | split vertically and open selected match
212 | ctrl-s | split horizontally and open selected match
213 | ctrl-t | open selected match in a new tab
214 |
215 | # Contributing
216 | If you find a bug, have an idea for a new feature, or even write some code you want included, please
217 | create an issue or pull request! I would appreciate contributions. Note that plan to keep nvim-find
218 | simple, focused, and opinionated, so not all features will be accepted.
219 |
220 | ## Acknowledgements
221 |
222 | This is my first vim/neovim plugin, and first project in Lua. I have relied on
223 | [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim),
224 | [plenary.nvim](https://github.com/nvim-lua/plenary.nvim),
225 | and [Snap](https://github.com/camspiers/snap) for help on how to interact with the neovim api, and for
226 | inspiration on various parts of this plugin. Thanks to all the developers for helping me get started!
227 |
228 | The async design of nvim-find is most heavily inspired by Snap.
229 |
--------------------------------------------------------------------------------
/lua/nvim-find/async.lua:
--------------------------------------------------------------------------------
1 | -- Code to help the finders run asynchronously
2 |
3 | local utils = require("nvim-find.utils")
4 |
5 | local async = {}
6 |
7 | local uv = vim.loop
8 |
9 | -- A constant used to yield to other coroutines
10 | async.pass = {}
11 |
12 | -- A constant used to inform of a canceled coroutine
13 | async.stopped = {}
14 |
15 | -- A constant used to inform of a source run to completion
16 | async.completed = {}
17 |
18 | -- Await some functions result
19 | function async.wait(fn)
20 | -- Return the function to the main event loop
21 | -- The main loop will schedule the execution and
22 | -- resume the coroutine when ready.
23 | local state, result = coroutine.yield(fn)
24 | return result
25 | end
26 |
27 | local function resume(thread, state, notify, value)
28 | local _, result = coroutine.resume(thread, state, value)
29 |
30 | if state.closed() or state.changed() or result == async.stopped then
31 | return async.stopped
32 | end
33 |
34 | if type(result) == "function" then
35 | return resume(thread, state, notify, async.wait(result))
36 | end
37 |
38 | -- The source finished iterating
39 | if notify and result == nil then
40 | coroutine.yield(async.completed)
41 | elseif result == async.stopped then
42 | coroutine.yield(async.stopped)
43 | end
44 |
45 | return result
46 | end
47 |
48 | function async.iterate(source, state, notify)
49 | local thread = coroutine.create(source)
50 | return function()
51 | if coroutine.status(thread) ~= "dead" then
52 | return resume(thread, state, notify)
53 | end
54 | end
55 | end
56 |
57 | -- Loop to run when a finder is active
58 | -- This is the lowest point at which coroutines are handled. Some are
59 | -- caught at deeper layers, but if a source or filter doesn't handle a
60 | -- case it will end up here.
61 | function async.loop(config)
62 | local state = config.state
63 | local idle = uv.new_idle()
64 | local thread = coroutine.create(config.source)
65 |
66 | local deferred = {
67 | running = false,
68 | result = nil,
69 | }
70 |
71 | function deferred.run(fn)
72 | deferred.running = true
73 | vim.schedule(function()
74 | deferred.result = fn()
75 | deferred.running = false
76 | end)
77 | end
78 |
79 | local function stop()
80 | uv.idle_stop(idle)
81 | end
82 |
83 | if state.closed() or state.changed() then
84 | return
85 | end
86 |
87 | uv.idle_start(idle, function()
88 | if deferred.running then return end
89 |
90 | if coroutine.status(thread) ~= "dead" then
91 | -- Resume the main thread or a deeper coroutine
92 | local _, value = coroutine.resume(thread, state, deferred.result)
93 |
94 | if state.closed() or state.changed() then
95 | stop()
96 | else
97 |
98 | -- Must catch this case
99 | -- Could we maybe use iterate here too?
100 | if value == nil then
101 | stop()
102 | elseif type(value) == "function" then
103 | -- Schedule the function to be run
104 | deferred.run(value)
105 | elseif value == async.stopped then
106 | stop()
107 | elseif value == async.pass then
108 | else
109 | config.on_value(value)
110 | end
111 | end
112 |
113 | else
114 | -- The main thread is finished
115 | stop()
116 | end
117 | end)
118 | end
119 |
120 | return async
121 |
--------------------------------------------------------------------------------
/lua/nvim-find/config.lua:
--------------------------------------------------------------------------------
1 | return {
2 | -- Maximum height of non-preview finders
3 | height = 20,
4 | -- Maximum width of non-preview finders
5 | width = 100,
6 | files = {
7 | -- list of ignore globs for the filename filter
8 | ignore = {},
9 | },
10 | search = {
11 | -- start with all result groups collapsed
12 | start_closed = false,
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/lua/nvim-find/defaults.lua:
--------------------------------------------------------------------------------
1 | -- Default finders
2 |
3 | local config = require("nvim-find.config")
4 | local filters = require("nvim-find.filters")
5 | local find = require("nvim-find")
6 | local sources = require("nvim-find.sources")
7 | local utils = require("nvim-find.utils")
8 |
9 | local defaults = {}
10 |
11 | local function executable(exec)
12 | return vim.fn.executable(exec) ~= 0
13 | end
14 |
15 | local function get_source(name)
16 | if sources[name] ~= nil then return sources[name] end
17 | error(string.format("The executable \"%s\" is not found", name))
18 | end
19 |
20 | local function get_best_file_source()
21 | if executable("fd") then return get_source("fd") end
22 | if executable("rg") then return get_source("rg_files") end
23 | end
24 |
25 | local file_source = nil
26 |
27 | -- fd or rg file picker
28 |
29 | -- sort by rank then by line length
30 | local function ranked_sort(to_sort)
31 | table.sort(to_sort, function(a, b)
32 | if a.rank == b.rank then
33 | return #a.result < #b.result
34 | end
35 | return a.rank > b.rank
36 | end)
37 | end
38 |
39 | function defaults.files()
40 | if not file_source then
41 | file_source = get_best_file_source()
42 | file_source = filters.wrap(file_source)
43 | end
44 |
45 | find.create({
46 | source = filters.sort(filters.filename(filters.cache(file_source)), 100, ranked_sort),
47 | transient = true,
48 | })
49 | end
50 |
51 | -- vim buffers
52 | function defaults.buffers()
53 | find.create({
54 | source = filters.simple(sources.buffers),
55 | transient = true,
56 | })
57 | end
58 |
59 | -- ripgrep project search
60 | local function vimgrep(lines)
61 | local ret = {}
62 | local dir = ""
63 |
64 | for _, line in ipairs(lines) do
65 | local filepath, row, col, match = string.match(line, "(.-):(.-):(.-):(.*)")
66 | if dir ~= filepath then
67 | table.insert(ret, { open = not config.search.start_closed, result = filepath })
68 | dir = filepath
69 | end
70 |
71 | table.insert(ret, {
72 | path = filepath,
73 | line = tonumber(row),
74 | col = tonumber(col),
75 | result = utils.str.trim(match),
76 | })
77 | end
78 |
79 | return ret
80 | end
81 |
82 | local function fill_quickfix(lines)
83 | local qfitems = {}
84 | for _, line in ipairs(lines) do
85 | if line.open == nil then
86 | table.insert(qfitems, { filename = line.path, lnum = line.line, col = line.col, text = line.result })
87 | end
88 | end
89 | vim.fn.setqflist(qfitems)
90 |
91 | utils.notify(string.format("%s items added to quickfix list", #qfitems))
92 | end
93 |
94 | function defaults.search(at_cursor)
95 | local query = nil
96 |
97 | -- Get initial query if needed
98 | local mode = vim.fn.mode()
99 | if mode == "v" or mode == "V" then
100 | query = utils.vim.visual_selection()
101 | -- HACK: is there an easier way to exit normal mode?
102 | vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true)
103 | elseif at_cursor then
104 | local word_at_cursor = vim.fn.expand("")
105 | if word_at_cursor ~= "" then query = word_at_cursor end
106 | end
107 |
108 | find.create({
109 | source = filters.wrap(sources.rg_grep, vimgrep),
110 | events = {{ mode = "n", key = "q", close = true, fn = fill_quickfix },
111 | { mode = "i", key = "", close = true, fn = fill_quickfix }},
112 | layout = "full",
113 | preview = true,
114 | toggles = true,
115 | query = query,
116 | fn = fill_quickfix,
117 | })
118 | end
119 |
120 | function defaults.search_at_cursor()
121 | defaults.search(true)
122 | end
123 |
124 | function defaults.test()
125 | find.create({
126 | source = filters.simple(filters.join(sources.buffers, sources.buffers)),
127 | events = {},
128 | })
129 | end
130 |
131 | return defaults
132 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/cache.lua:
--------------------------------------------------------------------------------
1 | -- A filter that caches the results of its source so later
2 | -- iterations can run quicker and potentially make fewer
3 | -- subprocess calls for efficiency.
4 |
5 | local async = require("nvim-find.async")
6 |
7 | local cache = {}
8 |
9 | local buffer_size = 1000
10 |
11 | -- TODO: Allow passing in a table for an external cache
12 | -- then we could store the results of a large file find somewhere,
13 | -- then invalidate them on a file change? idk it sounds like a decent idea
14 | function cache.run(source)
15 | local c = {}
16 |
17 | -- In addition to caching the results, we also need to track if
18 | -- all of the lines were indeed received from the source, otherwise
19 | -- the cache is only storing a partial set of the results!
20 | local full = false
21 |
22 | return function(state)
23 | -- When full the cache can be large. Returning the entire cache can be
24 | -- really slow for later filters, so it's best to buffer it when large.
25 | if full then
26 | -- In the case the buffer is small don't add extra overhead
27 | if #c <= buffer_size then
28 | return c
29 | end
30 |
31 | local index = 1
32 |
33 | -- TODO: Extract into general purpose buffer filter?
34 | while index < #c do
35 | local e = math.min(#c, index + buffer_size)
36 | coroutine.yield({unpack(c, index, e)})
37 | index = index + buffer_size
38 | end
39 | else
40 | for results in async.iterate(source, state, true) do
41 | if results == async.completed then coroutine.yield({}) end
42 |
43 | for _, val in ipairs(results) do
44 | table.insert(c, val)
45 | end
46 | coroutine.yield(results)
47 | end
48 |
49 | full = true
50 | end
51 | end
52 | end
53 |
54 | return cache
55 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/filename.lua:
--------------------------------------------------------------------------------
1 | -- A filter designed to be particularly good at filename matching
2 |
3 | local async = require("nvim-find.async")
4 | local config = require("nvim-find.config")
5 | local utils = require("nvim-find.utils")
6 |
7 | local file = {}
8 |
9 | local function has_upper(value)
10 | return string.match(value, "%u") ~= nil
11 | end
12 |
13 | local DELIMITERS = "[-_.]"
14 | local function has_delimiters(value)
15 | return string.match(value, DELIMITERS) ~= nil
16 | end
17 |
18 | -- Creates a filter that uses the given query
19 | local function filename_filter(query, ignore_case, ignore_delimiters)
20 | -- Should we ignore case?
21 | if ignore_case == nil then
22 | ignore_case = not has_upper(query)
23 | end
24 |
25 | -- Should we ignore delimiters?
26 | if ignore_delimiters == nil then
27 | ignore_delimiters = not has_delimiters(query)
28 | end
29 |
30 | local tokens = vim.split(query, " ", true)
31 |
32 | local ignore_patterns = {}
33 | for _, pat in ipairs(config.files.ignore) do
34 | pat = pat:gsub("%.", "%%.")
35 | pat = pat:gsub("%*", "%.%*")
36 | table.insert(ignore_patterns, "^" .. pat .. "$")
37 | end
38 | local function should_ignore(value)
39 | for _, pat in ipairs(ignore_patterns) do
40 | if value:match(pat) then return true end
41 | end
42 | return false
43 | end
44 |
45 | -- When there are more tokens after the first query do additional
46 | -- matching on the entire path
47 | return function(line)
48 | local value = line.result
49 |
50 | -- first check if the path should be ignored
51 | if should_ignore(value) then return end
52 |
53 | if ignore_case then value = value:lower() end
54 | if ignore_delimiters then value = value:gsub(DELIMITERS, "") end
55 |
56 | local filename = utils.path.basename(value)
57 | line.rank = 1
58 | if not string.find(filename, tokens[1], 0, true) then
59 | -- retry on full path
60 | if not string.find(value, tokens[1], 0, true) then
61 | return false
62 | end
63 |
64 | -- Did match on the full path
65 | line.rank = 0
66 | end
67 |
68 | -- The hope is that the previous check will eliminate most of the matches
69 | -- so any work afterwords can be slightly more complex because it is only
70 | -- running on a subset of the input
71 |
72 | -- A match was found, try remaining tokens on full path
73 | for i=2,#tokens do
74 | if not string.find(value, tokens[i], 0, true) then
75 | return false
76 | end
77 | end
78 |
79 | return true
80 | end
81 | end
82 |
83 | function file.run(source, ignore_case, ignore_delimiters)
84 | return function(state)
85 | local query = utils.str.trim(state.query)
86 |
87 | local had_results = false
88 | for results in async.iterate(source, state) do
89 | if type(results) == "table" then
90 | local filtered = vim.tbl_filter(filename_filter(query, ignore_case, ignore_delimiters), results)
91 | if #filtered > 0 then had_results = true end
92 |
93 | coroutine.yield(filtered)
94 | else
95 | coroutine.yield(results)
96 | end
97 | end
98 | end
99 | end
100 |
101 | return file
102 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/init.lua:
--------------------------------------------------------------------------------
1 | -- Easier access to filters by name
2 |
3 | local filters = {}
4 |
5 | filters.cache = require("nvim-find.filters.cache").run
6 | filters.filename = require("nvim-find.filters.filename").run
7 | filters.join = require("nvim-find.filters.join").run
8 | filters.simple = require("nvim-find.filters.simple").run
9 | filters.sort = require("nvim-find.filters.sort").run
10 | filters.wrap = require("nvim-find.filters.wrap").run
11 |
12 | return filters
13 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/join.lua:
--------------------------------------------------------------------------------
1 | -- A filter to join two or more sources
2 |
3 | local async = require("nvim-find.async")
4 |
5 | local join = {}
6 |
7 | function join.run(...)
8 | local sources = {...}
9 | assert(#sources > 1, "the join filter expects more than one source")
10 |
11 | return function(state)
12 | for _, source in ipairs(sources) do
13 | for results in async.iterate(source, state) do
14 | coroutine.yield(results)
15 | end
16 | end
17 | end
18 | end
19 |
20 | return join
21 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/simple.lua:
--------------------------------------------------------------------------------
1 | -- A simple string matching filter
2 |
3 | local async = require("nvim-find.async")
4 |
5 | local simple = {}
6 |
7 | local function has_upper(value)
8 | return string.match(value, "%u") ~= nil
9 | end
10 |
11 | local function simple_filter(query)
12 | -- Should we ignore case?
13 | local ignore_case = not has_upper(query)
14 |
15 | return function(value)
16 | value = value.result
17 | if ignore_case then value = value:lower() end
18 | return string.find(value, query, 0, true)
19 | end
20 | end
21 |
22 | function simple.run(source)
23 | return function(state)
24 | for results in async.iterate(source, state) do
25 | if type(results) == "table" then
26 | coroutine.yield(vim.tbl_filter(simple_filter(state.query), results))
27 | else
28 | -- TODO: Is this case needed? Is it handled by async.iterate already?
29 | coroutine.yield(results)
30 | end
31 | end
32 | end
33 | end
34 |
35 | return simple
36 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/sort.lua:
--------------------------------------------------------------------------------
1 | -- A filter that sorts the results by length
2 |
3 | local async = require("nvim-find.async")
4 |
5 | local sort = {}
6 |
7 | local function sort_by_length(to_sort)
8 | table.sort(to_sort, function(a, b)
9 | return #a.result < #b.result
10 | end)
11 | end
12 |
13 | function sort.run(source, n, fn)
14 | -- Too much sorting can slow down filters
15 | -- Only sort at most the first 100 results by default.
16 | n = n or 100
17 | fn = fn or sort_by_length
18 |
19 | return function(state)
20 | -- Sorting requires a complete list
21 | local to_sort = {}
22 | local sorted = false
23 |
24 | for results in async.iterate(source, state) do
25 | if type(results) == "table" then
26 | if sorted then
27 | coroutine.yield(results)
28 | else
29 | for _, val in ipairs(results) do
30 | table.insert(to_sort, val)
31 | end
32 |
33 | -- If enough results have come, run the sort
34 | if #to_sort >= n then
35 | local first = { unpack(to_sort, 1, n) }
36 | local second = { unpack(to_sort, n + 1) }
37 |
38 | -- Yield the first n results sorted
39 | fn(first)
40 | coroutine.yield(first)
41 |
42 | -- If there were others iterated already, yield these too
43 | if #second ~= 0 then
44 | coroutine.yield(second)
45 | end
46 |
47 | sorted = true
48 | else
49 | -- Allow other tasks to run
50 | coroutine.yield(async.pass)
51 | end
52 | end
53 | else
54 | coroutine.yield(results)
55 | end
56 | end
57 |
58 | -- If we are here and have not sorted then there were less
59 | -- than n total results. Sort now and return.
60 | if not sorted then
61 | fn(to_sort)
62 | return to_sort
63 | end
64 |
65 | end
66 | end
67 |
68 | return sort
69 |
--------------------------------------------------------------------------------
/lua/nvim-find/filters/wrap.lua:
--------------------------------------------------------------------------------
1 | -- Wrap filter. All sources should be wrapped by this filter
2 |
3 | local async = require("nvim-find.async")
4 | local utils = require("nvim-find.utils")
5 |
6 | local wrap = {}
7 |
8 | function wrap.run(source, fn)
9 | fn = fn or function(lines)
10 | utils.fn.mutmap(lines, function(result)
11 | return { result = result, path = result }
12 | end)
13 | return lines
14 | end
15 |
16 | return function(state)
17 | -- Store the partial contents of the last line
18 | local last_line_partial = ""
19 |
20 | for results in async.iterate(source, state) do
21 | if type(results) == "table" then
22 | if results.as_string ~= nil then
23 | results = results.as_string
24 | -- The results are one large string to be split
25 | local lines = vim.split(results, "\n", true)
26 |
27 | -- If there is partial portion from last time concat to the first line
28 | if last_line_partial ~= "" then
29 | lines[1] = last_line_partial .. lines[1]
30 | last_line_partial = ""
31 | end
32 |
33 | -- If the last line was incomplete then the last item in the array
34 | -- won't be an empty string.
35 | if lines[#lines] ~= "" then
36 | last_line_partial = lines[#lines]
37 | end
38 |
39 | -- Never include the last line because it is either partial or ""
40 | local partial = utils.fn.slice(lines, 1, #lines - 1)
41 | partial = fn(partial)
42 | coroutine.yield(partial)
43 | else
44 | results = fn(results)
45 | coroutine.yield(results)
46 | end
47 | else
48 | -- TODO: is this case needed?
49 | coroutine.yield(results)
50 | end
51 | end
52 | end
53 | end
54 |
55 | return wrap
56 |
--------------------------------------------------------------------------------
/lua/nvim-find/init.lua:
--------------------------------------------------------------------------------
1 | -- nvim-find: A fast, simple, async finder plugin
2 |
3 | local async = require("nvim-find.async")
4 | local config = require("nvim-find.config")
5 | local mappings = require("nvim-find.mappings")
6 | local utils = require("nvim-find.utils")
7 |
8 | local find = {}
9 |
10 | local api = vim.api
11 |
12 | -- The finder should be kept small and unintrusive unless a preview is shown.
13 | -- In that case it makes sense to take more of the available space.
14 | local function get_finder_dimensions(layout, use_preview)
15 | local vim_width = api.nvim_get_option("columns")
16 | local vim_height = api.nvim_get_option("lines")
17 |
18 | local row, finder_width, finder_height = (function()
19 | if layout == "full" then
20 | local pad = 8
21 | local width = vim_width - (pad * 2)
22 | local height = vim_height - pad
23 | return 1, width, height
24 | elseif layout == "top" then
25 | local width = math.min(config.width, math.ceil(vim_width * 0.8))
26 | local height = math.min(config.height, math.ceil(vim_height / 2))
27 | return 0, width, height
28 | end
29 |
30 | error("Unsupported layout: " .. layout)
31 | end)()
32 |
33 | local width_prompt = finder_width
34 | if use_preview then
35 | width_prompt = math.ceil(finder_width * 0.4)
36 | end
37 | local width_preview = finder_width - width_prompt
38 | local height_preview = finder_height + 1
39 |
40 | local column = math.ceil((vim_width - finder_width) / 2)
41 | local column_preview = column + width_prompt + 1
42 |
43 | return {
44 | row = row,
45 | column = column,
46 | width = width_prompt,
47 | height = finder_height,
48 | column_preview = column_preview,
49 | width_preview = width_preview,
50 | height_preview = height_preview,
51 | }
52 | end
53 |
54 | -- Create a new popup given a row, column, width, and height
55 | -- Returns the created buffer and window in a table
56 | local function create_popup(row, col, width, height, border, z)
57 | local buffer = api.nvim_create_buf(false, true)
58 | api.nvim_buf_set_option(buffer, "bufhidden", "wipe")
59 | api.nvim_buf_set_option(buffer, "buflisted", false)
60 |
61 | local opts = {
62 | style = "minimal",
63 | relative = "editor",
64 | row = row,
65 | col = col,
66 | width = width,
67 | height = height,
68 | border = border,
69 | zindex = z,
70 | }
71 |
72 | local window = api.nvim_open_win(buffer, true, opts)
73 | api.nvim_win_set_option(window, "winhl", "Normal:Normal")
74 |
75 | -- Used to close the window when finished or canceled
76 | local close = function()
77 | if buffer or window then
78 | if window then api.nvim_win_close(window, true) end
79 | end
80 | end
81 |
82 | return { buffer = buffer, window = window, close = close }
83 | end
84 |
85 | local function centered_slice(data, n, w)
86 | -- Line that is centered
87 | local centered = math.ceil(w / 2)
88 | local before = centered - 1
89 | local after = centered
90 |
91 | if n - before < 1 then
92 | local diff = 1 - (n - before)
93 | before = before - diff
94 | after = after + diff
95 | elseif n + after > #data then
96 | local diff = (n + after) - #data
97 | before = before + diff
98 | after = after - diff
99 | end
100 |
101 | return utils.fn.slice(data, n - before, n + after + 1), before + 1
102 | end
103 |
104 | local is_finder_open = false
105 |
106 | -- Create and open a new finder
107 | -- TODO: Cleanup this function
108 | function find.create(opts)
109 | -- Prevent opening more than one finder at a time
110 | if is_finder_open then return end
111 |
112 | -- Finder config
113 | if not opts then
114 | error("opts must not be nil")
115 | end
116 |
117 | if not opts.source then
118 | error("opts must contain a source")
119 | end
120 |
121 | -- The initial query passed to the finder
122 | local initial_query = opts.query or nil
123 |
124 | -- the layout of the finder
125 | local layout = opts.layout or "top"
126 |
127 | -- Transient finders close on escape and are meant to be used for quick
128 | -- searches that narrow down quickly.
129 | local transient = opts.transient or false
130 |
131 | -- Tracks if the finder is running
132 | local open = true
133 |
134 | local last_query = ""
135 |
136 | -- A source wrapped by zero or more filters
137 | local source = opts.source
138 |
139 | -- Show a preview window
140 | local use_preview = opts.preview or false
141 |
142 | local last_window = api.nvim_get_current_win()
143 |
144 | -- Create all popups needed for this finder
145 | local dimensions = get_finder_dimensions(layout, use_preview)
146 |
147 | local borders_prompt = {"┌", "─", "┐", "│", "┘", "─", "└", "│"}
148 | local borders_results = {"├", "─", "┤", "│", "┘", "─", "└", "│"}
149 | local borders_preview = {"┬", "─", "┐", "│", "┘", "─", "┴", "│"}
150 |
151 | local prompt = create_popup(dimensions.row, dimensions.column,
152 | dimensions.width, 1, borders_prompt, 1)
153 | -- Strangely making the buffer a prompt type will trigger the event loop
154 | -- but a normal buffer won't be triggered until a character is typed
155 | api.nvim_buf_set_option(prompt.buffer, "buftype", "prompt")
156 | vim.fn.prompt_setprompt(prompt.buffer, "> ")
157 | api.nvim_command("startinsert")
158 |
159 | local results = create_popup(dimensions.row + 2, dimensions.column,
160 | dimensions.width, dimensions.height, borders_results, 10)
161 | api.nvim_win_set_option(results.window, "cursorline", true)
162 | api.nvim_win_set_option(results.window, "scrolloff", 0)
163 |
164 | local preview
165 | if use_preview then
166 | preview = create_popup(dimensions.row, dimensions.column_preview, dimensions.width_preview,
167 | dimensions.height_preview + 1, borders_preview, 20)
168 | end
169 |
170 | results.scroll = 1
171 | results.all_lines = {}
172 | results.display_lines = {}
173 | results.open_count = 0
174 |
175 | local function close()
176 | if not open then return end
177 | open = false
178 |
179 | -- Close all open popups
180 | prompt.close()
181 | results.close()
182 | if preview then
183 | preview.close()
184 | end
185 |
186 | api.nvim_set_current_win(last_window)
187 | api.nvim_command("stopinsert")
188 |
189 | is_finder_open = false
190 | end
191 |
192 | local fill_preview = utils.scheduled(function(data, line, col, path)
193 | local lines = vim.split(data:sub(1, -2), "\n", true)
194 |
195 | local highlight_line = nil
196 |
197 | if not open then return end
198 | if #lines > dimensions.height then
199 | lines, highlight_line = centered_slice(lines, line, dimensions.height)
200 | else
201 | highlight_line = line
202 | end
203 |
204 | highlight_line = math.max(highlight_line, 1)
205 |
206 | api.nvim_buf_set_lines(preview.buffer, 0, -1, false, lines)
207 |
208 | api.nvim_buf_add_highlight(preview.buffer, -1, "Search", highlight_line - 1, col - 1, -1)
209 |
210 | local has_treesitter = utils.try_require("nvim-treesitter")
211 | local _, highlight = utils.try_require("nvim-treesitter.highlight")
212 | local _, parsers = utils.try_require("nvim-treesitter.parsers")
213 |
214 | -- Syntax highlight!
215 | local name = vim.fn.tempname() .. utils.path.sep .. path
216 |
217 | -- Prevent changing the window title when "saving" the buffer
218 | local title = api.nvim_get_option("title")
219 | api.nvim_set_option("title", false)
220 | api.nvim_buf_set_name(preview.buffer, name)
221 | api.nvim_set_option("title", title)
222 |
223 | api.nvim_buf_call(preview.buffer, function()
224 | local ignore = api.nvim_get_option("eventignore")
225 | api.nvim_set_option("eventignore", "FileType")
226 | api.nvim_command("filetype detect")
227 | api.nvim_set_option("eventignore", ignore)
228 | end)
229 | local filetype = api.nvim_buf_get_option(preview.buffer, "filetype")
230 | if filetype ~= "" then
231 | if has_treesitter then
232 | local language = parsers.ft_to_lang(filetype)
233 | if parsers.has_parser(language) then
234 | highlight.attach(preview.buffer, language)
235 | else
236 | api.nvim_buf_set_option(preview.buffer, "syntax", filetype)
237 | end
238 | else
239 | api.nvim_buf_set_option(preview.buffer, "syntax", filetype)
240 | end
241 | end
242 | end)
243 |
244 | local buffer_cache = {}
245 |
246 | local function strip_closed()
247 | local is_open = false
248 | return function(line)
249 | if line.open ~= nil then
250 | is_open = line.open
251 | return true
252 | end
253 | return is_open
254 | end
255 | end
256 |
257 | local function format_line(line)
258 | if opts.toggles then
259 | -- parent row
260 | if line.open ~= nil then
261 | if line.open then
262 | return " " .. line.result
263 | else
264 | return " " .. line.result
265 | end
266 | end
267 | -- child row
268 | return "│ " .. line.result
269 | end
270 | -- normal rows
271 | return line.result
272 | end
273 |
274 | -- Fill the results buffer with the lines visible at the current cursor and scroll offsets
275 | local fill_results = utils.scheduled(function(lines)
276 | if not open then return end
277 |
278 | -- Start from all the lines
279 | lines = lines or results.all_lines
280 | if opts.toggles then
281 | lines = vim.tbl_filter(strip_closed(), lines)
282 | end
283 | results.open_count = #lines
284 |
285 | results.display_lines = { unpack(lines, results.scroll, results.scroll + dimensions.height) }
286 | api.nvim_buf_set_lines(results.buffer, 0, -1, false, utils.fn.map(results.display_lines, format_line))
287 |
288 | if use_preview and #results.display_lines > 0 then
289 | local row = api.nvim_win_get_cursor(results.window)[1]
290 | local selected = results.display_lines[row]
291 | if selected.open ~= nil then return end
292 |
293 | if buffer_cache[selected.path] then
294 | fill_preview(buffer_cache[selected.path], selected.line or 1, selected.col or 1, selected.path)
295 | else
296 | utils.fs.read(selected.path, function(d)
297 | buffer_cache[selected.path] = d
298 | fill_preview(d, selected.line or 1, selected.col or 1, selected.path)
299 | end)
300 | end
301 | elseif use_preview then
302 | api.nvim_buf_set_lines(preview.buffer, 0, -1, false, {})
303 | end
304 | end)
305 |
306 | -- Expand/contract the current list item and redraw
307 | local function toggle()
308 | if not opts.toggles then return end
309 |
310 | local row = api.nvim_win_get_cursor(results.window)[1]
311 | local selected = results.display_lines[row]
312 |
313 | -- Find selected in all lines
314 | local selected_index = 0
315 | for i, line in ipairs(results.all_lines) do
316 | if selected.id == line.id then
317 | selected = line
318 | selected_index = i
319 | break
320 | end
321 | end
322 |
323 | if selected then
324 | while selected_index > 0 and selected.open == nil do
325 | selected_index = selected_index - 1
326 | row = row - 1
327 | selected = results.all_lines[selected_index]
328 | end
329 | selected.open = not selected.open
330 | -- Move cursor to parent
331 | if row < 1 then
332 | results.scroll = results.scroll + row - 1
333 | row = 1
334 | end
335 | api.nvim_win_set_cursor(results.window, { row, 0 })
336 | fill_results()
337 | end
338 | end
339 |
340 | local function toggle_all()
341 | local open = true
342 | for _, result in ipairs(results.all_lines) do
343 | if result.open ~= nil and result.open then
344 | open = false
345 | break
346 | end
347 | end
348 |
349 | for _, result in ipairs(results.all_lines) do
350 | if result.open ~= nil then
351 | result.open = open
352 | end
353 | end
354 |
355 | api.nvim_win_set_cursor(results.window, { 1, 0 })
356 | results.scroll = 1
357 | fill_results()
358 | end
359 |
360 | local function choose(command)
361 | command = command or "edit"
362 |
363 | local row = api.nvim_win_get_cursor(results.window)[1]
364 | local selected = results.display_lines[row]
365 |
366 | close()
367 |
368 | -- Nothing was selected so just close
369 | if #results.display_lines == 0 then
370 | return
371 | end
372 |
373 | api.nvim_command(string.format("%s %s", command, selected.path))
374 | if selected.line then
375 | local win = api.nvim_get_current_win()
376 | api.nvim_win_set_cursor(win, { selected.line, selected.col })
377 | end
378 |
379 | -- Custom callback
380 | if opts.fn then
381 | opts.fn(results.all_lines)
382 | end
383 | end
384 |
385 | local function move_cursor(direction)
386 | if #results.display_lines == 0 then return end
387 |
388 | local cursor = api.nvim_win_get_cursor(results.window)
389 | local length = results.open_count
390 |
391 | if direction == "up" then
392 | if cursor[1] > 1 then
393 | cursor[1] = cursor[1] - 1
394 | elseif results.scroll > 1 then
395 | results.scroll = results.scroll - 1
396 | end
397 | elseif direction == "down" then
398 | if cursor[1] < math.min(length, dimensions.height) then
399 | cursor[1] = cursor[1] + 1
400 | elseif results.scroll <= length - dimensions.height then
401 | results.scroll = results.scroll + 1
402 | end
403 | elseif direction == "top" then
404 | cursor[1] = 1
405 | results.scroll = 1
406 | elseif direction == "bottom" then
407 | cursor[1] = dimensions.height
408 | if length > dimensions.height then
409 | results.scroll = length - dimensions.height + 1
410 | end
411 | end
412 |
413 | -- Clamp to display lines for safety
414 | cursor[1] = utils.fn.clamp(cursor[1], 1, #results.display_lines)
415 |
416 | api.nvim_win_set_cursor(results.window, cursor)
417 |
418 | -- Always redraw the lines to force a window redraw
419 | fill_results()
420 | end
421 |
422 | -- TODO: These default events are hard coded for file opening
423 | local events = {
424 | -- Always allow closing in normal mode and with ctrl-c
425 | { type = "keymap", key = "", fn = close },
426 | { type = "keymap", key = "", fn = close },
427 | { type = "keymap", mode = "i", key = "", fn = close },
428 |
429 | -- Never allow leaving the prompt buffer
430 | { type = "autocmd", event = "BufLeave", fn = close },
431 |
432 | -- For toggling nested lists
433 | { type = "keymap", key = "", fn = toggle },
434 | { type = "keymap", mode = "i", key = "", fn = toggle },
435 |
436 | -- Convenience
437 | { type = "keymap", key = "gg", fn = function() move_cursor("top") end },
438 | { type = "keymap", key = "G", fn = function() move_cursor("bottom") end },
439 | { type = "keymap", key = "o", fn = function() toggle_all() end },
440 |
441 | { type = "keymap", key = "", fn = choose },
442 | { type = "keymap", key = "", fn = function() choose("split") end },
443 | { type = "keymap", key = "", fn = function() choose("vsplit") end },
444 | { type = "keymap", key = "", fn = function() choose("tabedit") end },
445 | { type = "keymap", mode = "i", key = "", str = "" },
446 | { type = "keymap", mode = "i", key = "", fn = function() choose("split") end },
447 | { type = "keymap", mode = "i", key = "", fn = function() choose("vsplit") end },
448 | { type = "keymap", mode = "i", key = "", fn = function() choose("tabedit") end },
449 |
450 | { type = "keymap", key = "j", fn = function() move_cursor("down") end },
451 | { type = "keymap", key = "k", fn = function() move_cursor("up") end },
452 | { type = "keymap", key = "n", fn = function() move_cursor("down") end },
453 | { type = "keymap", key = "p", fn = function() move_cursor("up") end },
454 | { type = "keymap", mode = "i", key = "", fn = function() move_cursor("down") end },
455 | { type = "keymap", mode = "i", key = "", fn = function() move_cursor("up") end },
456 | { type = "keymap", mode = "i", key = "", fn = function() move_cursor("down") end },
457 | { type = "keymap", mode = "i", key = "", fn = function() move_cursor("up") end },
458 | }
459 |
460 | local transient_events = {
461 | { type = "keymap", mode = "i", key = "", fn = choose },
462 | { type = "keymap", mode = "i", key = "", fn = close },
463 | { type = "autocmd", event = "InsertLeave", fn = close },
464 | }
465 |
466 | if transient then
467 | utils.fn.mutextend(events, transient_events)
468 | end
469 |
470 | -- User events are handled specially, and only keymaps are supported
471 | if opts.events then
472 | for _, event in ipairs(opts.events) do
473 | -- to inform the type checker that nothing bad is happening here
474 | local fn = vim.deepcopy(event.fn)
475 |
476 | local handler = function()
477 | -- close the finder if needed
478 | if event.close then
479 | close()
480 | end
481 |
482 | -- run the callback
483 | fn(results.all_lines)
484 | end
485 |
486 | event.fn = handler
487 | event.type = "keymap"
488 | end
489 |
490 | utils.fn.mutextend(events, opts.events)
491 | end
492 |
493 | for _, event in ipairs(events) do
494 | mappings.add(prompt.buffer, event)
495 | end
496 |
497 | local function get_prompt(buffer)
498 | local line = api.nvim_buf_get_lines(buffer, 0, 1, false)[1]
499 | return utils.str.trim(line:sub(3))
500 | end
501 |
502 | local function prompt_changed()
503 | -- Reset the cursor and scroll offset
504 | api.nvim_win_set_cursor(results.window, { 1, 0 })
505 | results.scroll = 1
506 | results.all_lines = {}
507 | results.display_lines = {}
508 | results.open_count = 0
509 |
510 | -- clear the lines
511 | fill_results({})
512 |
513 | last_query = get_prompt(prompt.buffer)
514 |
515 | -- Stores the state of the current finder
516 | local state = {
517 | query = last_query,
518 | last_window = last_window,
519 | }
520 |
521 | function state.closed()
522 | return not open
523 | end
524 |
525 | function state.changed()
526 | return state.query ~= last_query
527 | end
528 |
529 | local id = 1
530 | local function on_value(value)
531 | for _, val in ipairs(value) do
532 | if val and val ~= "" then
533 | -- store a unique id for each line for lookup between tables
534 | val.id = id
535 | id = id + 1
536 | table.insert(results.all_lines, val)
537 | end
538 | end
539 | results.display_lines = utils.fn.copy(results.all_lines)
540 | fill_results()
541 | end
542 |
543 | -- Run the event loop
544 | async.loop({
545 | state = state,
546 | source = source,
547 | on_value = on_value,
548 | })
549 | end
550 |
551 | -- Ensure the prompt is the focused window
552 | api.nvim_set_current_win(prompt.window)
553 |
554 | if initial_query then
555 | -- This seems to be the only way to fill a prompt-type buffer
556 | -- Save work by sending the keys before the prompt_changed callback is attached.
557 | utils.scheduled(function()
558 | api.nvim_feedkeys(initial_query, "i", false)
559 | api.nvim_buf_attach(prompt.buffer, false, { on_lines = prompt_changed, on_detach = function() end })
560 |
561 | -- wait a small bit before running this
562 | vim.defer_fn(function()
563 | api.nvim_command("stopinsert")
564 | end, 50)
565 | end)()
566 | else
567 | api.nvim_buf_attach(prompt.buffer, false, { on_lines = prompt_changed, on_detach = function() end })
568 | end
569 |
570 | is_finder_open = true
571 | end
572 |
573 | return find
574 |
--------------------------------------------------------------------------------
/lua/nvim-find/job.lua:
--------------------------------------------------------------------------------
1 | -- local str = require("nvim-find.string-utils")
2 | local uv = vim.loop
3 |
4 | local job = {}
5 |
6 | -- uv.read_start(self.stdout, function(err, data)
7 | -- assert(not err, err)
8 | -- if data then
9 | -- local lines = str.split(data, "\n")
10 | --
11 | -- local start = 1
12 | -- if not self.last_was_complete then
13 | -- -- Concat last and first
14 | -- self.lines[#self.lines] = self.lines[#self.lines] .. lines[1]
15 | -- start = 2
16 | -- end
17 | -- for index=start,#lines do
18 | -- table.insert(self.lines, lines[index])
19 | -- end
20 | --
21 | -- self.last_was_complete = data == "\n"
22 | -- end
23 | -- end)
24 |
25 | function job.spawn(cmd, args)
26 | local buffers = {
27 | stdout = "",
28 | stderr = "",
29 | }
30 |
31 | local stdout = uv.new_pipe(false)
32 | local stderr = uv.new_pipe(false)
33 |
34 | local handle
35 |
36 | -- For cleanup when finished
37 | local function exit(_, _)
38 | uv.read_stop(stdout)
39 | uv.read_stop(stderr)
40 | uv.close(stdout)
41 | uv.close(stderr)
42 | uv.close(handle)
43 | end
44 |
45 | local function close()
46 | uv.process_kill(handle, uv.constants.SIGTERM)
47 | end
48 |
49 | local options = {
50 | args = args,
51 | stdio = { nil, stdout, stderr },
52 | }
53 | handle = uv.spawn(cmd, options, exit)
54 |
55 | uv.read_start(stdout, function(err, data)
56 | assert(not err, err)
57 | if data then
58 | buffers.stdout = buffers.stdout .. data
59 | end
60 | end)
61 |
62 | uv.read_start(stderr, function(err, data)
63 | assert(not err, err)
64 | if data then
65 | buffers.stderr = buffers.stderr .. data
66 | end
67 | end)
68 |
69 | -- Lua iterators are inner functions
70 | return function()
71 | if (handle and uv.is_active(handle)) or buffers.stdout ~= "" then
72 | local out = buffers.stdout
73 | local err = buffers.stderr
74 | buffers.stdout = ""
75 | buffers.stderr = ""
76 | return out, err, close
77 | else
78 | return nil
79 | end
80 | end
81 | end
82 |
83 | return job
84 |
--------------------------------------------------------------------------------
/lua/nvim-find/mappings.lua:
--------------------------------------------------------------------------------
1 | local mappings = {}
2 |
3 | local api = vim.api
4 |
5 | local mappings_table = {}
6 |
7 | function mappings.clear()
8 | mappings_table = {}
9 | end
10 |
11 | function mappings.run(num)
12 | mappings_table[num]()
13 | end
14 |
15 | local function register(fn)
16 | table.insert(mappings_table, fn)
17 | return #mappings_table
18 | end
19 |
20 | local function register_keymap(buffer, mode, key, action)
21 | local options = { nowait = true, silent = true, noremap = true }
22 |
23 | local rhs = ""
24 | if type(action) == "function" then
25 | local num = register(action)
26 | rhs = string.format(":lua require('nvim-find.mappings').run(%s)", num)
27 | else
28 | rhs = action
29 | end
30 | api.nvim_buf_set_keymap(buffer, mode, key, rhs, options)
31 | end
32 |
33 | local function register_autocmd(buffer, event, fn)
34 | local num = register(fn)
35 |
36 | local cmd = string.format("autocmd %s :lua require('nvim-find.mappings').run(%s)",
37 | event,
38 | buffer,
39 | num)
40 | api.nvim_command(cmd)
41 | end
42 |
43 | -- Set the autocommands and keybindings for the finder
44 | function mappings.add(buffer, mapping)
45 | if mapping.type == "keymap" then
46 | local mode = mapping.mode or "n"
47 | register_keymap(buffer, mode, mapping.key, mapping.fn or mapping.str)
48 | elseif mapping.type == "autocmd" then
49 | register_autocmd(buffer, mapping.event, mapping.fn)
50 | end
51 | end
52 |
53 | return mappings
54 |
--------------------------------------------------------------------------------
/lua/nvim-find/set.lua:
--------------------------------------------------------------------------------
1 | -- A set-like wrapper around tables
2 |
3 | local Set = {
4 | items = {}
5 | }
6 |
7 | function Set:new(list)
8 | local set = { items = {} }
9 | if list then
10 | for _, value in ipairs(list) do
11 | set.items[value] = 1
12 | end
13 | end
14 | setmetatable(set, self)
15 | self.__index = self
16 | return set
17 | end
18 |
19 | function Set:add(item)
20 | self.items[item] = 1
21 | end
22 |
23 | function Set:contains(item)
24 | return self.items[item] ~= nil
25 | end
26 |
27 | function Set:union(other)
28 | local set = vim.deepcopy(self)
29 | setmetatable(set, getmetatable(self))
30 |
31 | for key, _ in pairs(other) do
32 | set[key] = 1
33 | end
34 | return set
35 | end
36 |
37 | return Set
38 |
--------------------------------------------------------------------------------
/lua/nvim-find/sources/buffers.lua:
--------------------------------------------------------------------------------
1 | -- Creates a list of open buffers
2 |
3 | local async = require("nvim-find.async")
4 |
5 | local api = vim.api
6 |
7 | local buffers = {}
8 |
9 | local function buffer_filter(buf)
10 | if 1 ~= vim.fn.buflisted(buf) then
11 | return false
12 | end
13 | if not api.nvim_buf_is_loaded(buf) then
14 | return false
15 | end
16 | return true
17 | end
18 |
19 | local function get_buffer_list()
20 | local results = {}
21 | local bufs = api.nvim_list_bufs()
22 | for _, b in ipairs(bufs) do
23 | if buffer_filter(b) then
24 | table.insert(results, b)
25 | end
26 | end
27 | return results
28 | end
29 |
30 | local function get_alternate_name(window)
31 | return async.wait(function()
32 | return api.nvim_win_call(window, function()
33 | return vim.fn.bufname("#")
34 | end)
35 | end)
36 | end
37 |
38 | -- Assuming that the list of buffers is never more than 1000
39 | -- there is no need to buffer the results here. Simply returning
40 | -- the list should be responsive enough.
41 | function buffers.run(state)
42 | local bufs = async.wait(get_buffer_list)
43 |
44 | -- build a pretty representation
45 | local bufs_res = {}
46 | for _, buffer in ipairs(bufs) do
47 | local name = async.wait(function() return vim.fn.bufname(buffer) end)
48 | local alternate_name = get_alternate_name(state.last_window)
49 | local info = async.wait(function() return vim.fn.getbufinfo(buffer)[1] end)
50 |
51 | local modified = " "
52 | if info.changed == 1 then
53 | modified = ""
54 | end
55 |
56 | local alternate = ""
57 | print(alternate_name)
58 | if name == alternate_name then
59 | -- alternate = ""
60 | alternate = " (alt)"
61 | end
62 |
63 | local result = string.format("%s %s%s", modified, name, alternate)
64 | table.insert(bufs_res, { result = result, path = name })
65 | end
66 |
67 | return bufs_res
68 | end
69 |
70 | return buffers
71 |
--------------------------------------------------------------------------------
/lua/nvim-find/sources/fd.lua:
--------------------------------------------------------------------------------
1 | -- Find files in the current directory using the wonderful `fd` tool
2 |
3 | local async = require("nvim-find.async")
4 | local job = require("nvim-find.job")
5 |
6 | local fd = {}
7 |
8 | function fd.run(state)
9 | for stdout, stderr, close in job.spawn("fd", {"-t", "f"}) do
10 |
11 | if state.closed() or state.changed() or stderr ~= "" then
12 | close()
13 | coroutine.yield(async.stopped)
14 | end
15 |
16 | if stdout ~= "" then
17 | coroutine.yield({ as_string = stdout })
18 | else
19 | coroutine.yield(async.pass)
20 | end
21 | end
22 | end
23 |
24 | return fd
25 |
--------------------------------------------------------------------------------
/lua/nvim-find/sources/init.lua:
--------------------------------------------------------------------------------
1 | -- Easier access to sources by name
2 |
3 | local sources = {}
4 |
5 | sources.buffers = require("nvim-find.sources.buffers").run
6 | sources.fd = require("nvim-find.sources.fd").run
7 | sources.rg_grep = require("nvim-find.sources.rg").grep
8 | sources.rg_files = require("nvim-find.sources.rg").files
9 |
10 | return sources
11 |
--------------------------------------------------------------------------------
/lua/nvim-find/sources/rg.lua:
--------------------------------------------------------------------------------
1 | -- Search through the project with `rg`
2 |
3 | local async = require("nvim-find.async")
4 | local job = require("nvim-find.job")
5 |
6 | local rg = {}
7 |
8 | function rg.grep(state)
9 | if state.query == "" then
10 | return {}
11 | end
12 |
13 | for stdout, stderr, close in job.spawn("rg", {"--vimgrep", "--smart-case", state.query}) do
14 | if state.closed() or state.changed() or stderr ~= "" then
15 | close()
16 | coroutine.yield(async.stopped)
17 | end
18 |
19 | if stdout ~= "" then
20 | coroutine.yield({ as_string = stdout })
21 | else
22 | coroutine.yield(async.pass)
23 | end
24 | end
25 | end
26 |
27 | function rg.files(state)
28 | for stdout, stderr, close in job.spawn("rg", {"--files"}) do
29 | if state.closed() or state.changed() or stderr ~= "" then
30 | close()
31 | coroutine.yield(async.stopped)
32 | end
33 |
34 | if stdout ~= "" then
35 | coroutine.yield({ as_string = stdout })
36 | else
37 | coroutine.yield(async.pass)
38 | end
39 | end
40 | end
41 |
42 | return rg
43 |
--------------------------------------------------------------------------------
/lua/nvim-find/utils.lua:
--------------------------------------------------------------------------------
1 | -- General utility functions useful throughout nvim-find
2 |
3 | local uv = vim.loop
4 |
5 | local utils = {
6 | fn = {},
7 | path = {},
8 | str = {},
9 | fs = {},
10 | vim = {},
11 | }
12 |
13 | function utils.notify(msg)
14 | vim.api.nvim_echo({{ "nvim-find: " .. msg, "MsgArea" }}, true, {})
15 | end
16 |
17 | -- attempt to require
18 | function utils.try_require(name)
19 | return pcall(function() return require(name) end)
20 | end
21 |
22 | -- wraps a function that is scheduled to run
23 | function utils.scheduled(fn)
24 | local args_cache = nil
25 | return function(...)
26 | local args = { ... }
27 |
28 | -- update args and exit if already scheduled
29 | if args_cache then
30 | args_cache = args
31 | return
32 | end
33 |
34 | args_cache = args
35 |
36 | vim.schedule(function()
37 | fn(unpack(args_cache))
38 |
39 | -- allow running again with updated args
40 | args_cache = nil
41 | end)
42 | end
43 | end
44 |
45 | -- Thanks plenary devs!
46 | utils.path.sep = (function()
47 | if jit then
48 | local os = string.lower(jit.os)
49 | if os == "linux" or os == "osx" or os == "bsd" then
50 | return "/"
51 | else
52 | return "\\"
53 | end
54 | else
55 | return package.config:sub(1, 1)
56 | end
57 | end)()
58 |
59 | -- Functional-programming style utilities and other useful functions
60 |
61 | -- A map that returns a new list
62 | function utils.fn.map(list, fn)
63 | local new_list = {}
64 | for _, item in ipairs(list) do
65 | table.insert(new_list, fn(item))
66 | end
67 | return new_list
68 | end
69 |
70 | -- A map that mutates the given list
71 | function utils.fn.mutmap(list, fn)
72 | for i, item in ipairs(list) do
73 | list[i] = fn(item)
74 | end
75 | end
76 |
77 | function utils.fn.slice(list, i, j)
78 | local new_list = {}
79 | for index=i,j do
80 | table.insert(new_list, list[index])
81 | end
82 | return new_list
83 | end
84 |
85 | -- join table b at the end of table a
86 | function utils.fn.mutextend(a, b)
87 | for _, value in ipairs(b) do
88 | table.insert(a, value)
89 | end
90 | end
91 |
92 | function utils.fn.copy(list)
93 | local new_list = {}
94 | for key, value in pairs(list) do
95 | new_list[key] = value
96 | end
97 | return new_list
98 | end
99 |
100 | -- clamp within a range (inclusive)
101 | function utils.fn.clamp(val, a, b)
102 | if val < a then
103 | return a
104 | elseif val > b then
105 | return b
106 | end
107 | return val
108 | end
109 |
110 | -- Path related utilities
111 |
112 | -- Return the basename of a path
113 | -- If the path ends in a file, the name is returned
114 | -- If the path ends in a directory followed by a separator then
115 | -- the final directory is returned.
116 | function utils.path.basename(path_str)
117 | local parts = utils.str.split(path_str, utils.path.sep)
118 | return parts[#parts]
119 | end
120 |
121 | -- Given a path, split into name and extension pair
122 | -- If there is no name or extension (like .bashrc) then the
123 | -- full name is returned as the name with an empty extension.
124 | function utils.path.splitext(name)
125 | if name:sub(1, 1) == "." then
126 | return name, ""
127 | end
128 |
129 | local parts = utils.str.split(name, "%.")
130 | return parts[1], parts[2]
131 | end
132 |
133 | -- String related utilities
134 |
135 | -- Split a string into parts given a split delimiter
136 | -- If no delimiter is given, space is used by default
137 | -- Returns the parts of the string, the original is left unchanged.
138 | function utils.str.split(str, split_char)
139 | split_char = split_char or " "
140 | local parts = {}
141 | repeat
142 | local start, _ = str:find(split_char)
143 | -- Last match
144 | if not start then
145 | table.insert(parts, str)
146 | str = ""
147 | else
148 | table.insert(parts, str:sub(1, start - 1))
149 | str = str:sub(start + 1)
150 | end
151 | until #str == 0
152 | return parts
153 | end
154 |
155 | -- Join the elements of the table delimited by str.
156 | function utils.str.join(str, list)
157 | local result = ""
158 | if #list == 1 then
159 | return list[1]
160 | end
161 |
162 | result = list[1]
163 | for i = 2,#list do
164 | result = result .. str .. list[i]
165 | end
166 |
167 | return result
168 | end
169 |
170 | -- Returns a string with whitespace trimmed from the front and end of
171 | -- the string.
172 | function utils.str.trim(str)
173 | -- Remove whitespace from the beginning of the string
174 | local start, ending = str:find("^%s*")
175 | if start then
176 | str = str:sub(ending + 1)
177 | end
178 | -- Remove whitespace from the end of the string
179 | start, ending = str:find("%s*$")
180 | if start then
181 | str = str:sub(1, start - 1)
182 | end
183 | return str
184 | end
185 |
186 | function utils.fs.read(path, fn)
187 | uv.fs_open(path, "r", 438, function(_, fd)
188 | uv.fs_fstat(fd, function(_, stat)
189 | -- TODO: Read this in chunks?
190 | uv.fs_read(fd, stat.size, 0, function(_, data)
191 | uv.fs_close(fd, function(_)
192 | -- If we get here then return the data
193 | return fn(data)
194 | end)
195 | end)
196 | end)
197 | end)
198 | end
199 |
200 | -- Get the currently visual-selected text
201 | function utils.vim.visual_selection()
202 | local _, line_start, col_start = unpack(vim.fn.getpos("v"))
203 | local _, line_end, col_end = unpack(vim.fn.getpos("."))
204 | local lines = vim.api.nvim_buf_get_lines(0, line_start - 1, line_end, false)
205 | if #lines == 0 then return "" end
206 |
207 | -- Strip chars from the beginning and end lines
208 | lines[#lines] = lines[#lines]:sub(1, col_end)
209 | lines[1] = lines[1]:sub(col_start)
210 |
211 | str = ""
212 | for _, line in ipairs(lines) do
213 | str = str .. line
214 | end
215 |
216 | return str
217 | end
218 |
219 | return utils
220 |
--------------------------------------------------------------------------------
/plugin/nvim-find.vim:
--------------------------------------------------------------------------------
1 | if exists('g:loaded_nvim_find')
2 | finish
3 | endif
4 |
5 | let s:save_cpo = &cpo
6 | set cpo&vim
7 |
8 | command! NvimFindFiles lua require("nvim-find.defaults").files()
9 | command! NvimFindBuffers lua require("nvim-find.defaults").buffers()
10 | command! NvimFindSearch lua require("nvim-find.defaults").search()
11 |
12 | let &cpo = s:save_cpo
13 | unlet s:save_cpo
14 |
15 | let g:loaded_nvim_find = 1
16 |
--------------------------------------------------------------------------------