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