diff --git a/README.md b/README.md index dca3d3d..7144890 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,135 @@
# ivy.nvim An [ivy-mode](https://github.com/abo-abo/swiper#ivy) port to neovim. Ivy is a generic completion mechanism for ~~Emacs~~ Nvim
## Installation ### Manually ```sh git clone https://github.com/AdeAttwood/ivy.nvim ~/.config/nvim/pack/bundle/start/ivy.nvim ``` ### Plugin managers TODO: Add docs in the plugin managers I don't use any ### Compiling For the native searching, you will need to compile the shard library. You can do that by running the below command in the root of the plugin. ```sh cargo build --release ``` You will need to have the rust toolchain installed. You can find more about that [here](https://www.rust-lang.org/tools/install) If you get a linker error you may need to install `build-essential` to get `ld`. This is a common issue if you are running the [benchmarks](#benchmarks) in a VM ``` error: linker `cc` not found | = note: No such file or directory (os error 2) ``` ## Features ### Commands A command can be run that will launch the completion UI | Command | Key Map | Description | | ---------- | ----------- | ------------------------------------------------------ | | IvyFd | \p | Find files in your project with the fd cli file finder | | IvyAg | \/ | Find content in files using the silver searcher | | IvyBuffers | \b | Search though open buffers | | IvyLines | | Search the lines in the current buffer | ### Actions Action can be run on selected candidates provide functionality -| Action | Description | -| -------- | ------------------------------------------------------------------------------ | -| Complete | Run the completion function, usually this will be opening a file | -| Peek | Run the completion function on a selection, but don't close the results window | +| Action | Description | +| -------------- | ------------------------------------------------------------------------------ | +| Complete | Run the completion function, usually this will be opening a file | +| Peek | Run the completion function on a selection, but don't close the results window | +| Vertical Split | Run the completion function in a new vertical split | +| Split | Run the completion function in a new split | ## API ```lua vim.ivy.run( -- The name given to the results window and displayed to the user "Title", -- Call back function to get all the candidates that will be displayed in -- the results window, The `input` will be passed in, so you can filter -- your results with the value from the prompt function(input) return { "One", "Two", Three } end, -- Action callback that will be called on the completion or peek actions. -- The currently selected item is passed in as the result. function(result) vim.cmd("edit " .. result) end ) ``` ## Benchmarks Benchmarks are of various tasks that ivy will do. The purpose of the benchmarks are to give us a baseline on where to start when trying to optimize performance in the matching and sorting, not to put ivy against other tools. When starting to optimize, you will probably need to get a baseline on your hardware. There are fixtures provided that will create the directory structure of the [kubernetes](https://github.com/kubernetes/kubernetes) source code, from somewhere around commit sha 985c9202ccd250a5fe22c01faf0d8f83d804b9f3. This will create a directory tree of 23511 files a relative large source tree to get a good idea of performance. To create the source tree under `/tmp/ivy-trees/kubernetes` run the following command. This will need to be run for the benchmarks to run. ```bash # Create the source trees bash ./scripts/fixtures.bash # Run the benchmark script luajit ./scripts/benchmark.lua ``` Current benchmark status running on a `e2-standard-2` 2 vCPU + 8 GB memory VM running on GCP. IvyRs (Lua) | Name | Total | Average | Min | Max | | ---------------------------- | ------------- | ------------- | ------------- | ------------- | | ivy_match(file.lua) 1000000x | 04.153531 (s) | 00.000004 (s) | 00.000003 (s) | 00.002429 (s) | | ivy_files(kubernetes) 100x | 03.526795 (s) | 00.035268 (s) | 00.021557 (s) | 00.037127 (s) | IvyRs (Criterion) | Name | Min | Mean | Max | | --------------------- | --------- | --------- | --------- | | ivy_files(kubernetes) | 19.727 ms | 19.784 ms | 19.842 ms | | ivy_match(file.lua) | 2.6772 µs | 2.6822 µs | 2.6873 µs | CPP | Name | Total | Average | Min | Max | | ---------------------------- | ------------- | ------------- | ------------- | ------------- | | ivy_match(file.lua) 1000000x | 01.855197 (s) | 00.000002 (s) | 00.000001 (s) | 00.000177 (s) | | ivy_files(kubernetes) 100x | 14.696396 (s) | 00.146964 (s) | 00.056604 (s) | 00.168478 (s) | ## Other stuff you might like - [ivy-mode](https://github.com/abo-abo/swiper#ivy) - An emacs package that was the inspiration for this nvim plugin - [Command-T](https://github.com/wincent/command-t) - Vim plugin I used before I started this one - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) - Another competition plugin, lots of people are using diff --git a/lua/ivy/controller.lua b/lua/ivy/controller.lua index 306acb0..c0194eb 100644 --- a/lua/ivy/controller.lua +++ b/lua/ivy/controller.lua @@ -1,83 +1,85 @@ local window = require "ivy.window" local prompt = require "ivy.prompt" +local utils = require "ivy.utils" local controller = {} +controller.action = utils.actions controller.items = nil controller.callback = nil controller.run = function(name, items, callback) controller.callback = callback controller.items = items window.initialize() window.set_items { { content = "-- Loading ---" } } vim.api.nvim_buf_set_name(window.get_buffer(), name) controller.input "" end controller.input = function(char) prompt.input(char) controller.update(prompt.text()) end controller.search = function(value) prompt.set(value) controller.update(prompt.text()) end controller.update = function(text) vim.schedule(function() window.set_items(controller.items(text)) vim.cmd "syntax clear IvyMatch" vim.cmd("syntax match IvyMatch '[(" .. text .. ")]'") end) end -controller.complete = function() +controller.complete = function(action) vim.api.nvim_set_current_win(window.origin) - controller.callback(window.get_current_selection()) + controller.callback(window.get_current_selection(), action) controller.destroy() end controller.checkpoint = function() vim.api.nvim_set_current_win(window.origin) - controller.callback(window.get_current_selection()) + controller.callback(window.get_current_selection(), controller.action.CHECKPOINT) vim.api.nvim_set_current_win(window.window) end controller.next = function() local max = vim.api.nvim_buf_line_count(window.buffer) - 1 if window.index == max then return end window.index = window.index + 1 window.update() end controller.previous = function() if window.index == 0 then return end window.index = window.index - 1 window.update() end controller.origin = function() return vim.api.nvim_win_get_buf(window.origin) end controller.destroy = function() controller.items = nil controller.callback = nil window.destroy() prompt.destroy() end return controller diff --git a/lua/ivy/prompt_test.lua b/lua/ivy/prompt_test.lua index 57b0339..a99680d 100644 --- a/lua/ivy/prompt_test.lua +++ b/lua/ivy/prompt_test.lua @@ -1,67 +1,61 @@ local prompt = require "ivy.prompt" +local vim_mock = require "ivy.vim_mock" before_each(function() - -- Mock the global vim functions we are using in the prompt - _G.vim = { - notify = function() end, - api = { - nvim_echo = function() end, - }, - } - + vim_mock.reset() prompt.destroy() end) -- Input a list of strings into the prompt local input = function(input_table) for index = 1, #input_table do prompt.input(input_table[index]) end end -- Asserts the prompt contains the correct value local assert_prompt = function(t, expected) local text = prompt.text() if text ~= expected then - t.error("The promp text should be '" .. expected .. "' found '" .. text .. "'") + t.error("The prompt text should be '" .. expected .. "' found '" .. text .. "'") end end it("starts with empty text", function(t) if prompt.text() ~= "" then t.error "The prompt should start with empty text" end end) it("can input some text", function(t) input { "A", "d", "e" } assert_prompt(t, "Ade") end) it("can delete a char", function(t) input { "A", "d", "e", "BACKSPACE" } assert_prompt(t, "Ad") end) it("will reset the text", function(t) input { "A", "d", "e" } prompt.set "New" assert_prompt(t, "New") end) it("can move around the a word", function(t) input { "P", "r", "o", "p", "t", "LEFT", "LEFT", "LEFT", "RIGHT", "m" } assert_prompt(t, "Prompt") end) it("can delete a word", function(t) prompt.set "Ade Attwood" input { "DELETE_WORD" } assert_prompt(t, "Ade") end) it("can delete a word in the middle", function(t) prompt.set "Ade middle A" input { "LEFT", "LEFT", "DELETE_WORD" } assert_prompt(t, "Ade A") end) diff --git a/lua/ivy/utils.lua b/lua/ivy/utils.lua index 6fa8f67..75fc7c7 100644 --- a/lua/ivy/utils.lua +++ b/lua/ivy/utils.lua @@ -1,64 +1,88 @@ local utils = {} +-- A list of all of the actions defined by ivy. The callback function can +-- implement as many of them as necessary. As a minimum it should implement the +-- "EDIT" action that is called on the default complete. +utils.actions = { + EDIT = "EDIT", + CHECKPOINT = "CHECKPOINT", + VSPLIT = "VSPLIT", + SPLIT = "SPLIT", +} + +utils.command_map = { + [utils.actions.EDIT] = "edit", + [utils.actions.CHECKPOINT] = "edit", + [utils.actions.VSPLIT] = "vsplit", + [utils.actions.SPLIT] = "split", +} + utils.command_finder = function(command, min) if min == nil then min = 3 end return function(input) -- Dont run the commands unless we have somting to search that wont -- return a ton of results or on some commands the command files with -- no search term if #input < min then return "-- Please type more than " .. min .. " chars --" end -- TODO(ade): Think if we want to start escaping the command here. I -- dont know if its causing issues while trying to use regex especially -- with word boundaries `input:gsub("'", "\\'"):gsub('"', '\\"')` local handle = io.popen(command .. " " .. input .. " 2>&1") if handle == nil then return {} end local result = handle:read "*a" handle:close() return result end end utils.vimgrep_action = function() - return function(item) + return function(item, action) -- Match file and line form vimgrep style commands local file = item:match "([^:]+):" local line = item:match ":(%d+):" -- Cant do anything if we cant find a file to go to if file == nil then return end - vim.cmd("edit " .. file) + utils.file_action()(file, action) if line ~= nil then vim.cmd(line) end end end utils.file_action = function() - return function(file) + return function(file, action) if file == nil then return end - vim.cmd("edit " .. file) + + local command = utils.command_map[action] + if command == nil then + vim.api.nvim_err_writeln("[IVY] The file action is unable the handel the action " .. action) + return + end + + vim.cmd(command .. " " .. file) end end utils.line_action = function() return function(item) local line = item:match "^%s+(%d+):" vim.cmd(line) end end return utils diff --git a/lua/ivy/utils_line_action_test.lua b/lua/ivy/utils_line_action_test.lua new file mode 100644 index 0000000..f68810e --- /dev/null +++ b/lua/ivy/utils_line_action_test.lua @@ -0,0 +1,39 @@ +local utils = require "ivy.utils" +local line_action = utils.line_action() +local vim_mock = require "ivy.vim_mock" + +before_each(function() + vim_mock.reset() +end) + +it("will run the line command", function(t) + line_action " 4: Some text" + + if #vim_mock.commands ~= 1 then + t.error "`line_action` command length should be 1" + end + + if vim_mock.commands[1] ~= "4" then + t.error "`line_action` command should be 4" + end +end) + +it("will run with more numbers", function(t) + line_action " 44: Some text" + + if #vim_mock.commands ~= 1 then + t.error "`line_action` command length should be 1" + end + + if vim_mock.commands[1] ~= "44" then + t.error "`line_action` command should be 44" + end +end) + +it("dose not run any action if no line is found", function(t) + line_action "Some text" + + if #vim_mock.commands ~= 0 then + t.error "`line_action` command length should be 1" + end +end) diff --git a/lua/ivy/utils_vimgrep_action_test.lua b/lua/ivy/utils_vimgrep_action_test.lua new file mode 100644 index 0000000..0c08c09 --- /dev/null +++ b/lua/ivy/utils_vimgrep_action_test.lua @@ -0,0 +1,56 @@ +local utils = require "ivy.utils" +local vimgrep_action = utils.vimgrep_action() +local vim_mock = require "ivy.vim_mock" + +before_each(function() + vim_mock.reset() +end) + +local test_data = { + { + it = "will edit some file and goto the line", + completion = "some/file.lua:2: This is some text", + action = utils.actions.EDIT, + commands = { + "edit some/file.lua", + "2", + }, + }, + { + it = "will skip the line if its not matched", + completion = "some/file.lua: This is some text", + action = utils.actions.EDIT, + commands = { "edit some/file.lua" }, + }, + { + it = "will run the vsplit command", + completion = "some/file.lua: This is some text", + action = utils.actions.VSPLIT, + commands = { "vsplit some/file.lua" }, + }, + { + it = "will run the split command", + completion = "some/file.lua: This is some text", + action = utils.actions.SPLIT, + commands = { "split some/file.lua" }, + }, +} + +for i = 1, #test_data do + local data = test_data[i] + it(data.it, function(t) + vimgrep_action(data.completion, data.action) + + if #vim_mock.commands ~= #data.commands then + t.error("Incorrect number of commands run expected " .. #data.commands .. " but found " .. #vim_mock.commands) + end + + for j = 1, #data.commands do + if vim_mock.commands[j] ~= data.commands[j] then + t.error( + "Incorrect command run expected '" .. data.commands[j] .. "' but found '" .. vim_mock.commands[j] .. "'" + ) + end + end + end) +end diff --git a/lua/ivy/window_test.lua b/lua/ivy/vim_mock.lua similarity index 59% copy from lua/ivy/window_test.lua copy to lua/ivy/vim_mock.lua index fff5bc4..98f03fd 100644 --- a/lua/ivy/window_test.lua +++ b/lua/ivy/vim_mock.lua @@ -1,30 +1,30 @@ -local window = require "ivy.window" +local mock = { + commands = {}, +} + +mock.reset = function() + mock.commands = {} -before_each(function() - -- Mock the global vim functions we are using in the prompt _G.vim = { notify = function() end, + cmd = function(cmd) + table.insert(mock.commands, cmd) + end, api = { nvim_echo = function() end, nvim_get_current_win = function() return 10 end, nvim_command = function() end, nvim_win_get_buf = function() return 10 end, nvim_win_set_option = function() end, nvim_buf_set_option = function() end, nvim_buf_set_var = function() end, nvim_buf_set_keymap = function() end, }, } -end) - -it("can initialize", function(t) - window.initialize() +end - if window.get_buffer() ~= 10 then - t.error("The windows buffer should be 10 found " .. window.get_buffer()) - end -end) +return mock diff --git a/lua/ivy/window.lua b/lua/ivy/window.lua index ea94ec3..87c0856 100644 --- a/lua/ivy/window.lua +++ b/lua/ivy/window.lua @@ -1,142 +1,144 @@ -- Constent options that will be used for the keymaps local opts = { noremap = true, silent = true, nowait = true } -- All of the base chars that will be used for an "input" operation on the -- prompt -- stylua: ignore local chars = { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "<", ">", "`", "@", "#", "~", "!", "\"", "$", "%", "^", "&", "/", "(", ")", "=", "+", "*", "-", "_", ".", ",", ";", ":", "?", "\\", "|", "'", "{", "}", "[", "]", " ", } local function string_to_table(lines) local matches = {} for line in lines:gmatch "[^\r\n]+" do table.insert(matches, { content = line }) end return matches end local window = {} window.index = 0 window.origin = nil window.window = nil window.buffer = nil window.initialize = function() window.make_buffer() end window.make_buffer = function() window.origin = vim.api.nvim_get_current_win() vim.api.nvim_command "botright split new" window.buffer = vim.api.nvim_win_get_buf(0) window.window = vim.api.nvim_get_current_win() vim.api.nvim_win_set_option(window.window, "number", false) vim.api.nvim_win_set_option(window.window, "relativenumber", false) vim.api.nvim_win_set_option(window.window, "signcolumn", "no") vim.api.nvim_buf_set_option(window.buffer, "filetype", "ivy") vim.api.nvim_buf_set_var(window.buffer, "bufftype", "nofile") for index = 1, #chars do local char = chars[index] if char == "'" then char = "\\'" end if char == "\\" then char = "\\\\\\\\" end vim.api.nvim_buf_set_keymap(window.buffer, "n", chars[index], "lua vim.ivy.input('" .. char .. "')", opts) end vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.destroy()", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.search('')", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.next()", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.previous()", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.next(); vim.ivy.checkpoint()", opts) vim.api.nvim_buf_set_keymap( window.buffer, "n", "", "lua vim.ivy.previous(); vim.ivy.checkpoint()", opts ) - vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.complete()", opts) + vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.complete(vim.ivy.action.EDIT)", opts) + vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.complete(vim.ivy.action.VSPLIT)", opts) + vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.complete(vim.ivy.action.SPLIT)", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.input('BACKSPACE')", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.input('LEFT')", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.input('RIGHT')", opts) vim.api.nvim_buf_set_keymap(window.buffer, "n", "", "lua vim.ivy.input('DELETE_WORD')", opts) end window.get_current_selection = function() local line = vim.api.nvim_buf_get_lines(window.buffer, window.index, window.index + 1, true) if line == nil then line = { "" } end return line[1] end window.get_buffer = function() if window.buffer == nil then window.make_buffer() end return window.buffer end window.update = function() vim.api.nvim_win_set_cursor(window.window, { window.index + 1, 0 }) end window.set_items = function(items) if type(items) == "string" then items = string_to_table(items) end -- TODO(ade): Validate the items are in the correct format. This also need to -- come with some descriptive messages and possible help. -- Display no items text if there are no items to dispaly if #items == 0 then items = { { content = "-- No Items --" } } end local items_length = #items window.index = items_length - 1 for index = 1, items_length do vim.api.nvim_buf_set_lines(window.buffer, index - 1, -1, false, { items[index].content }) end -- Limit the results window size to 10 so when there are lots of results the -- window does not take up the hole terminal local line_count = items_length if line_count > 10 then line_count = 10 end vim.api.nvim_win_set_height(window.window, line_count) window.update() end window.destroy = function() if type(window.buffer) == "number" then vim.api.nvim_buf_delete(window.buffer, { force = true }) end window.buffer = nil window.window = nil window.origin = nil window.index = 0 end return window diff --git a/lua/ivy/window_test.lua b/lua/ivy/window_test.lua index fff5bc4..75b9698 100644 --- a/lua/ivy/window_test.lua +++ b/lua/ivy/window_test.lua @@ -1,30 +1,14 @@ +local vim_mock = require "ivy.vim_mock" local window = require "ivy.window" before_each(function() - -- Mock the global vim functions we are using in the prompt - _G.vim = { - notify = function() end, - api = { - nvim_echo = function() end, - nvim_get_current_win = function() - return 10 - end, - nvim_command = function() end, - nvim_win_get_buf = function() - return 10 - end, - nvim_win_set_option = function() end, - nvim_buf_set_option = function() end, - nvim_buf_set_var = function() end, - nvim_buf_set_keymap = function() end, - }, - } + vim_mock.reset() end) it("can initialize", function(t) window.initialize() if window.get_buffer() ~= 10 then t.error("The windows buffer should be 10 found " .. window.get_buffer()) end end)