diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2163ad4..7422053 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v4 - name: Run stylua - uses: JohnnyMorganz/stylua-action@v3.0.0 + uses: JohnnyMorganz/stylua-action@v4.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} version: latest @@ -52,6 +52,9 @@ jobs: test: name: Build and test + strategy: + matrix: + nvim-version: ["v0.9.5", "stable", "nightly"] runs-on: ubuntu-latest steps: - name: Checkout @@ -61,10 +64,25 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install dependencies - run: sudo apt update && sudo apt install -y luajit build-essential + run: sudo apt update && sudo apt install -y build-essential luarocks + + - name: Install busted + run: sudo luarocks install busted + + - name: Install neovim + run: | + test -d _neovim || { + mkdir -p _neovim + curl -sL "https://github.com/neovim/neovim/releases/download/${{ matrix.nvim-version }}/nvim-linux64.tar.gz" | tar xzf - --strip-components=1 -C "${PWD}/_neovim" + } - name: Build run: cargo build --release - - name: Test - run: find lua -name "*_test.lua" | xargs luajit scripts/test.lua + - name: Run tests + run: | + export PATH="${PWD}/_neovim/bin:${PATH}" + export VIM="${PWD}/_neovim/share/nvim/runtime" + + nvim --version + nvim -l ./scripts/busted.lua -o TAP ./lua/ivy/ 2> /dev/null diff --git a/.luacheckrc b/.luacheckrc index 337ac8e..2d46e75 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -10,9 +10,12 @@ self = false read_globals = { "vim", - "it", "after", "after_each", + "assert", "before", "before_each", + "describe", + "it", + "spy", } diff --git a/Cargo.lock b/Cargo.lock index 60d34ec..17ec5d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.8.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", diff --git a/Cargo.toml b/Cargo.toml index cf70aca..d36ee36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ path = "rust/lib.rs" [dependencies] ignore = "0.4.22" fuzzy-matcher = "0.3.7" -rayon = "1.8.1" +rayon = "1.10.0" [dev-dependencies] criterion = "0.5.1" diff --git a/README.md b/README.md index e9e8d1e..f048609 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-ivy.vim +ivy.vim

@@ -20,7 +20,47 @@ git clone https://github.com/AdeAttwood/ivy.nvim ~/.config/nvim/pack/bundle/star ### Plugin managers -TODO: Add docs in the plugin managers I don't use any +Using [lazy.nvim](https://github.com/folke/lazy.nvim) +```lua +{ + "AdeAttwood/ivy.nvim", + build = "cargo build --release", +}, +``` + +TODO: Add more plugin managers + +### Setup / Configuration + +Ivy can be configured with minimal config that will give you all the defaults +provided by Ivy. + +```lua +require('ivy').setup() +``` + +With Ivy you can configure your own backends. + +```lua +require('ivy').setup { + backends = { + -- A backend module that will be registered + "ivy.backends.buffers", + -- Using a table so you can configure a custom keymap overriding the + -- default one. + { "ivy.backends.files", { keymap = "" } } + }, +} +``` + +The `setup` function can only be called once, if its called a second time any +backends or config will not be used. Ivy does expose the `register_backend` +function, this can be used to load backends before or after the setup function +is called. + +```lua +require('ivy').register_backend("ivy.backends.files") +``` ### Compiling @@ -56,28 +96,46 @@ cp ./post-merge.sample ./.git/hooks/post-merge ## Features -### Commands +### Backends -A command can be run that will launch the completion UI +A backend is a module that will provide completion candidates for the UI to +show. It will also provide functionality when actions are taken. The Command +and Key Map are the default options provided by the backend, they can be +customized when you register it. -| Command | Key Map | Description | -| ------------------ | ----------- | ----------------------------------------------------------- | -| IvyFd | \p | Find files in your project with a custom rust 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 | -| IvyWorkspaceSymbol | | Search for workspace symbols using the lsp workspace/symbol | +| Module | Command | Key Map | Description | +| ------------------------------------ | ------------------ | ----------- | ----------------------------------------------------------- | +| `ivy.backends.files` | IvyFd | \p | Find files in your project with a custom rust file finder | +| `ivy.backends.ag` | IvyAg | \/ | Find content in files using the silver searcher | +| `ivy.backends.rg` | IvyRg | \/ | Find content in files using ripgrep cli tool | +| `ivy.backends.buffers` | IvyBuffers | \b | Search though open buffers | +| `ivy.backends.lines` | IvyLines | | Search the lines in the current buffer | +| `ivy.backends.lsp-workspace-symbols` | IvyWorkspaceSymbol | | Search for workspace symbols using the lsp workspace/symbol | ### 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 | -| Vertical Split | Run the completion function in a new vertical split | -| Split | Run the completion function in a new split | +| Action | Key Map | Description | +| -------------- | ----------- | ------------------------------------------------------------------------------ | +| Complete | \ |Run the completion function, usually this will be opening a file | +| Vertical Split | \ |Run the completion function in a new vertical split | +| Split | \ |Run the completion function in a new split | +| Destroy | \ |Close the results window | +| Clear | \ |Clear the results window | +| Delete word | \ |Delete the word under the cursor | +| Next | \ |Move to the next candidate | +| Previous | \ |Move to the previous candidate | +| Next Checkpoint| \ |Move to the next candidate and keep Ivy open and focussed | +| Previous Checkpoint| \|Move to the previous candidate and keep Ivy open and focussed | + +Add your own keymaps for an action by adding a `ftplugin/ivy.lua` file in your config. +Just add a simple keymap like this: + +```lua +vim.api.nvim_set_keymap( "n", "", "lua vim.ivy.destroy()", { noremap = true, silent = true, nowait = true }) +``` + ## API @@ -130,7 +188,7 @@ vertical split action it will open the buffer in a new `vsplit` { content = "Three" }, } end, - -- Action callback that will be called on the completion or peek actions. + -- Action callback that will be called on the completion or checkpoint actions. -- The currently selected item is passed in as the result. function(result) vim.cmd("edit " .. result) end ) diff --git a/lua/ivy/config.lua b/lua/ivy/config.lua new file mode 100644 index 0000000..8ff1e6a --- /dev/null +++ b/lua/ivy/config.lua @@ -0,0 +1,33 @@ +local config_mt = {} +config_mt.__index = config_mt + +function config_mt:get_in(config, key_table) + local current_value = config + for _, key in ipairs(key_table) do + if current_value == nil then + return nil + end + + current_value = current_value[key] + end + + return current_value +end + +function config_mt:get(key_table) + return self:get_in(self.user_config, key_table) or self:get_in(self.default_config, key_table) +end + +local config = { user_config = {} } + +config.default_config = { + backends = { + "ivy.backends.buffers", + "ivy.backends.files", + "ivy.backends.lines", + "ivy.backends.rg", + "ivy.backends.lsp-workspace-symbols", + }, +} + +return setmetatable(config, config_mt) diff --git a/lua/ivy/config_spec.lua b/lua/ivy/config_spec.lua new file mode 100644 index 0000000..7ef8cd8 --- /dev/null +++ b/lua/ivy/config_spec.lua @@ -0,0 +1,27 @@ +local config = require "ivy.config" + +describe("config", function() + before_each(function() + config.user_config = {} + end) + + it("gets the first item when there is only default values", function() + local first_backend = config:get { "backends", 1 } + assert.is_equal("ivy.backends.buffers", first_backend) + end) + + it("returns nil if we access a key that is not a valid config item", function() + assert.is_nil(config:get { "not", "a", "thing" }) + end) + + it("returns the users overridden config value", function() + config.user_config = { backends = { "ivy.my.backend" } } + local first_backend = config:get { "backends", 1 } + assert.is_equal("ivy.my.backend", first_backend) + end) + + it("returns a nested value", function() + config.user_config = { some = { nested = "value" } } + assert.is_equal(config:get { "some", "nested" }, "value") + end) +end) diff --git a/lua/ivy/controller_spec.lua b/lua/ivy/controller_spec.lua new file mode 100644 index 0000000..6556cee --- /dev/null +++ b/lua/ivy/controller_spec.lua @@ -0,0 +1,57 @@ +local window = require "ivy.window" +local controller = require "ivy.controller" + +describe("controller", function() + before_each(function() + vim.cmd "highlight IvyMatch cterm=bold gui=bold" + window.initialize() + end) + + after_each(function() + controller.destroy() + end) + + it("will run the completion", function() + controller.run("Testing", function() + return { { content = "Some content" } } + end, function() + return {} + end) + + -- Run all the scheduled tasks + vim.wait(0) + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true) + assert.is_equal(#lines, 1) + assert.is_equal(lines[1], "Some content") + end) + + it("will not try and highlight the buffer if there is nothing to highlight", function() + spy.on(vim, "cmd") + + controller.items = function() + return { { content = "Hello" } } + end + + controller.update "" + + vim.wait(0) + + assert.spy(vim.cmd).was_called_with "syntax clear IvyMatch" + assert.spy(vim.cmd).was_not_called_with "syntax match IvyMatch '[H]'" + end) + + it("will escape a - when passing it to be highlighted", function() + spy.on(vim, "cmd") + + controller.items = function() + return { { content = "Hello" } } + end + + controller.update "some-file" + + vim.wait(0) + + assert.spy(vim.cmd).was_called_with "syntax match IvyMatch '[some\\-file]'" + end) +end) diff --git a/lua/ivy/controller_test.lua b/lua/ivy/controller_test.lua deleted file mode 100644 index 9865142..0000000 --- a/lua/ivy/controller_test.lua +++ /dev/null @@ -1,51 +0,0 @@ -local vim_mock = require "ivy.vim_mock" -local window = require "ivy.window" -local controller = require "ivy.controller" - --- The number of the mock buffer where all the test completions gets put -local buffer_number = 10 - -before_each(function() - vim_mock.reset() - window.initialize() -end) - -after_each(function() - controller.destroy() -end) - -it("will run", function(t) - controller.run("Testing", function() - return { { content = "Some content" } } - end, function() - return {} - end) - - local lines = vim_mock.get_lines() - local completion_lines = lines[buffer_number] - - t.assert_equal(#completion_lines, 1) - t.assert_equal(completion_lines[1], "Some content") -end) - -it("will not try and highlight the buffer if there is nothing to highlight", function(t) - controller.items = function() - return { { content = "Hello" } } - end - - controller.update "" - local commands = vim_mock.get_commands() - t.assert_equal(#commands, 1) -end) - -it("will escape a - when passing it to be highlighted", function(t) - controller.items = function() - return { { content = "Hello" } } - end - - controller.update "some-file" - local commands = vim_mock.get_commands() - local syntax_command = commands[2] - - t.assert_equal("syntax match IvyMatch '[some\\-file]'", syntax_command) -end) diff --git a/lua/ivy/init.lua b/lua/ivy/init.lua new file mode 100644 index 0000000..58672ff --- /dev/null +++ b/lua/ivy/init.lua @@ -0,0 +1,32 @@ +local controller = require "ivy.controller" +local config = require "ivy.config" +local register_backend = require "ivy.register_backend" + +local ivy = {} +ivy.run = controller.run +ivy.register_backend = register_backend + +-- Private variable to check if ivy has been setup, this is to prevent multiple +-- setups of ivy. This is only exposed for testing purposes. +---@private +ivy.has_setup = false + +---@class IvySetupOptions +---@field backends (IvyBackend | { ["1"]: string, ["2"]: IvyBackendOptions} | string)[] + +---@param user_config IvySetupOptions +function ivy.setup(user_config) + if ivy.has_setup then + return + end + + config.user_config = user_config or {} + + for _, backend in ipairs(config:get { "backends" } or {}) do + register_backend(backend) + end + + ivy.has_setup = true +end + +return ivy diff --git a/lua/ivy/init_spec.lua b/lua/ivy/init_spec.lua new file mode 100644 index 0000000..f8ea91b --- /dev/null +++ b/lua/ivy/init_spec.lua @@ -0,0 +1,30 @@ +local ivy = require "ivy" +local config = require "ivy.config" + +describe("ivy.setup", function() + before_each(function() + ivy.has_setup = false + config.user_config = {} + end) + + it("sets the users config options", function() + ivy.setup { backends = { "ivy.backends.files" } } + assert.is_equal("ivy.backends.files", config:get { "backends", 1 }) + end) + + it("will not reconfigure if its called twice", function() + ivy.setup { backends = { "ivy.backends.files" } } + ivy.setup { backends = { "some.backend" } } + assert.is_equal("ivy.backends.files", config:get { "backends", 1 }) + end) + + it("does not crash if you don't pass in any params to the setup function", function() + ivy.setup() + assert.is_equal("ivy.backends.buffers", config:get { "backends", 1 }) + end) + + it("will fallback if the key is not set at all in the users config", function() + ivy.setup { some_key = "some_value" } + assert.is_equal("ivy.backends.buffers", config:get { "backends", 1 }) + end) +end) diff --git a/lua/ivy/libivy_spec.lua b/lua/ivy/libivy_spec.lua new file mode 100644 index 0000000..8faf503 --- /dev/null +++ b/lua/ivy/libivy_spec.lua @@ -0,0 +1,36 @@ +require "busted.runner"() + +local libivy = require "ivy.libivy" + +describe("libivy", function() + it("should run a simple match", function() + local score = libivy.ivy_match("term", "I am a serch term") + + assert.is_true(score > 0) + end) + + it("should find a dot file", function() + local current_dir = libivy.ivy_cwd() + local results = libivy.ivy_files(".github/workflows/ci.yml", current_dir) + + assert.is_equal(2, results.length, "Incorrect number of results found") + assert.is_equal(".github/workflows/ci.yml", results[2].content, "Invalid matches") + end) + + it("will allow you to access the length via the metatable", function() + local current_dir = libivy.ivy_cwd() + local results = libivy.ivy_files(".github/workflows/ci.yml", current_dir) + + local mt = getmetatable(results) + + assert.is_equal(results.length, mt.__len(results), "The `length` property does not match the __len metamethod") + end) + + it("will create an iterator", function() + local iter = libivy.ivy_files(".github/workflows/ci.yml", libivy.ivy_cwd()) + local mt = getmetatable(iter) + + assert.is_equal(type(mt["__index"]), "function") + assert.is_equal(type(mt["__len"]), "function") + end) +end) diff --git a/lua/ivy/libivy_test.lua b/lua/ivy/libivy_test.lua deleted file mode 100644 index fe18455..0000000 --- a/lua/ivy/libivy_test.lua +++ /dev/null @@ -1,46 +0,0 @@ -local libivy = require "ivy.libivy" - -it("should run a simple match", function(t) - local score = libivy.ivy_match("term", "I am a serch term") - - if score <= 0 then - t.error("Score should not be less than 0 found " .. score) - end -end) - -it("should find a dot file", function(t) - local current_dir = libivy.ivy_cwd() - local results = libivy.ivy_files(".github/workflows/ci.yml", current_dir) - - if results.length ~= 2 then - t.error("Incorrect number of results found " .. results.length) - end - - if results[2].content ~= ".github/workflows/ci.yml" then - t.error("Invalid matches: " .. results[2].content) - end -end) - -it("will allow you to access the length via the metatable", function(t) - local current_dir = libivy.ivy_cwd() - local results = libivy.ivy_files(".github/workflows/ci.yml", current_dir) - - local mt = getmetatable(results) - - if results.length ~= mt.__len(results) then - t.error "The `length` property does not match the __len metamethod" - end -end) - -it("will create an iterator", function(t) - local iter = libivy.ivy_files(".github/workflows/ci.yml", libivy.ivy_cwd()) - local mt = getmetatable(iter) - - if type(mt["__index"]) ~= "function" then - t.error "The iterator does not have an __index metamethod" - end - - if type(mt["__len"]) ~= "function" then - t.error "The iterator does not have an __len metamethod" - end -end) diff --git a/lua/ivy/matcher_spec.lua b/lua/ivy/matcher_spec.lua new file mode 100644 index 0000000..7128a2c --- /dev/null +++ b/lua/ivy/matcher_spec.lua @@ -0,0 +1,29 @@ +local libivy = require "ivy.libivy" + +-- Helper function to test a that string `one` has a higher match score than +-- string `two`. If string `one` has a lower score than string `two` a string +-- will be returned that can be used in body of an error. If not then `nil` is +-- returned and all is good. +local match_test = function(term, one, two) + local score_one = libivy.ivy_match(term, one) + local score_two = libivy.ivy_match(term, two) + + assert.is_true( + score_one > score_two, + ("The score of %s (%d) ranked higher than %s (%d)"):format(one, score_one, two, score_two) + ) +end + +describe("ivy matcher", function() + it("should match path separator", function() + match_test("file", "some/file.lua", "somefile.lua") + end) + + -- it("should match pattern with spaces", function() + -- match_test("so fi", "some/file.lua", "somefile.lua") + -- end) + + it("should match the start of a string", function() + match_test("file", "file.lua", "somefile.lua") + end) +end) diff --git a/lua/ivy/matcher_test.lua b/lua/ivy/matcher_test.lua deleted file mode 100644 index f80f565..0000000 --- a/lua/ivy/matcher_test.lua +++ /dev/null @@ -1,37 +0,0 @@ -local libivy = require "ivy.libivy" - --- Helper function to test a that string `one` has a higher match score than --- string `two`. If string `one` has a lower score than string `two` a string --- will be returned that can be used in body of an error. If not then `nil` is --- returned and all is good. -local match_test = function(term, one, two) - local score_one = libivy.ivy_match(term, one) - local score_two = libivy.ivy_match(term, two) - - if score_one < score_two then - return one .. " should be ranked higher than " .. two - end - - return nil -end - -it("sould match path separator", function(t) - local result = match_test("file", "some/file.lua", "somefile.lua") - if result then - t.error(result) - end -end) - -it("sould match pattern with spaces", function(t) - local result = match_test("so fi", "some/file.lua", "somefile.lua") - if result then - t.error(result) - end -end) - -it("sould match the start of a string", function(t) - local result = match_test("file", "file.lua", "somefile.lua") - if result then - t.error(result) - end -end) diff --git a/lua/ivy/prompt_spec.lua b/lua/ivy/prompt_spec.lua new file mode 100644 index 0000000..452a091 --- /dev/null +++ b/lua/ivy/prompt_spec.lua @@ -0,0 +1,91 @@ +local prompt = require "ivy.prompt" + +-- 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 + +describe("prompt", function() + before_each(function() + prompt.destroy() + end) + + it("starts with empty text", function() + assert.is_same(prompt.text(), "") + end) + + it("can input some text", function() + input { "A", "d", "e" } + assert.is_same(prompt.text(), "Ade") + end) + + it("can delete a char", function() + input { "A", "d", "e", "BACKSPACE" } + assert.is_same(prompt.text(), "Ad") + end) + + it("will reset the text", function() + input { "A", "d", "e" } + prompt.set "New" + assert.is_same(prompt.text(), "New") + end) + + it("can move around the a word", function() + input { "P", "r", "o", "p", "t", "LEFT", "LEFT", "LEFT", "RIGHT", "m" } + assert.is_same(prompt.text(), "Prompt") + end) + + it("can delete a word", function() + prompt.set "Ade Attwood" + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), "Ade ") + end) + + it("can delete a word in the middle and leave the cursor at that word", function() + prompt.set "Ade middle A" + input { "LEFT", "LEFT", "DELETE_WORD", "a" } + + assert.is_same(prompt.text(), "Ade a A") + end) + + it("will delete the space and the word if the last word is single space", function() + prompt.set "some.thing " + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), "some.") + end) + + it("will only delete one word from path", function() + prompt.set "some/nested/path" + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), "some/nested/") + end) + + it("will delete tailing space", function() + prompt.set "word " + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), "") + end) + + it("will leave a random space", function() + prompt.set "some word " + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), "some ") + end) + + local special_characters = { ".", "/", "^" } + for _, char in ipairs(special_characters) do + it(string.format("will stop at a %s", char), function() + prompt.set(string.format("key%sValue", char)) + input { "DELETE_WORD" } + + assert.is_same(prompt.text(), string.format("key%s", char)) + end) + end +end) diff --git a/lua/ivy/prompt_test.lua b/lua/ivy/prompt_test.lua deleted file mode 100644 index ee834b6..0000000 --- a/lua/ivy/prompt_test.lua +++ /dev/null @@ -1,94 +0,0 @@ -local prompt = require "ivy.prompt" -local vim_mock = require "ivy.vim_mock" - -before_each(function() - 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 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) - -it("will delete the space and the word if the last word is single space", function(t) - prompt.set "some.thing " - input { "DELETE_WORD" } - assert_prompt(t, "some.") -end) - -it("will only delete one word from path", function(t) - prompt.set "some/nested/path" - input { "DELETE_WORD" } - assert_prompt(t, "some/nested/") -end) - -it("will delete tailing space", function(t) - prompt.set "word " - input { "DELETE_WORD" } - assert_prompt(t, "") -end) - -it("will leave a random space", function(t) - prompt.set "some word " - input { "DELETE_WORD" } - assert_prompt(t, "some ") -end) - -local special_characters = { ".", "/", "^" } -for _, char in ipairs(special_characters) do - it(string.format("will stop at a %s", char), function(t) - prompt.set(string.format("key%sValue", char)) - input { "DELETE_WORD" } - assert_prompt(t, string.format("key%s", char)) - end) -end diff --git a/lua/ivy/register_backend.lua b/lua/ivy/register_backend.lua new file mode 100644 index 0000000..f314588 --- /dev/null +++ b/lua/ivy/register_backend.lua @@ -0,0 +1,54 @@ +---@class IvyBackend +---@field command string The command this backend will have +---@field items fun(input: string): { content: string }[] | string The callback function to get the items to select from +---@field callback fun(result: string, action: string) The callback function to run when a item is selected +---@field description string? The description of the backend, this will be used in the keymaps +---@field name string? The name of the backend, this will fallback to the command if its not set +---@field keymap string? The keymap to trigger this backend + +---@class IvyBackendOptions +---@field command string The command this backend will have +---@field keymap string? The keymap to trigger this backend + +---Register a new backend +--- +---This will create all the commands and set all the keymaps for the backend +---@param backend IvyBackend +local register_backend_class = function(backend) + local user_command_options = { bang = true } + if backend.description ~= nil then + user_command_options.desc = backend.description + end + + local name = backend.name or backend.command + vim.api.nvim_create_user_command(backend.command, function() + vim.ivy.run(name, backend.items, backend.callback) + end, user_command_options) + + if backend.keymap ~= nil then + vim.api.nvim_set_keymap("n", backend.keymap, "" .. backend.command .. "", { nowait = true, silent = true }) + end +end + +---@param backend IvyBackend | { ["1"]: string, ["2"]: IvyBackendOptions} | string The backend or backend module +---@param options IvyBackendOptions? The options for the backend, that will be merged with the backend +local register_backend = function(backend, options) + if type(backend[1]) == "string" then + options = backend[2] + backend = require(backend[1]) + end + + if type(backend) == "string" then + backend = require(backend) + end + + if options then + for key, value in pairs(options) do + backend[key] = value + end + end + + register_backend_class(backend) +end + +return register_backend diff --git a/lua/ivy/register_backend_spec.lua b/lua/ivy/register_backend_spec.lua new file mode 100644 index 0000000..ab51e4e --- /dev/null +++ b/lua/ivy/register_backend_spec.lua @@ -0,0 +1,71 @@ +local register_backend = require "ivy.register_backend" + +local function get_command(name) + local command_iter = vim.api.nvim_get_commands {} + + for _, cmd in pairs(command_iter) do + if cmd.name == name then + return cmd + end + end + + return nil +end + +local function get_keymap(mode, rhs) + local keymap_iter = vim.api.nvim_get_keymap(mode) + for _, keymap in pairs(keymap_iter) do + if keymap.rhs == rhs then + return keymap + end + end + + return nil +end + +describe("register_backend", function() + after_each(function() + vim.api.nvim_del_user_command "IvyFd" + + local keymap = get_keymap("n", "IvyFd") + if keymap then + vim.api.nvim_del_keymap("n", keymap.lhs) + end + end) + + it("registers a backend from a string with the default options", function() + register_backend "ivy.backends.files" + + local command = get_command "IvyFd" + assert.is_not_nil(command) + + local keymap = get_keymap("n", "IvyFd") + assert.is_not_nil(keymap) + end) + + it("allows you to override the keymap", function() + register_backend("ivy.backends.files", { keymap = "" }) + + local keymap = get_keymap("n", "IvyFd") + assert(keymap ~= nil) + assert.are.equal("", keymap.lhs) + end) + + it("allows you to pass in a hole backend module", function() + register_backend(require "ivy.backends.files") + + local command = get_command "IvyFd" + assert.is_not_nil(command) + + local keymap = get_keymap("n", "IvyFd") + assert.is_not_nil(keymap) + end) + + it("allows you to pass in a hole backend module", function() + register_backend { "ivy.backends.files", { keymap = "" } } + + local keymap = get_keymap("n", "IvyFd") + assert(keymap ~= nil) + assert.are.equal("", keymap.lhs) + end) +end) diff --git a/lua/ivy/utils.lua b/lua/ivy/utils.lua index 910e57f..40489a8 100644 --- a/lua/ivy/utils.lua +++ b/lua/ivy/utils.lua @@ -99,7 +99,9 @@ end utils.line_action = function() return function(item) local line = item:match "^%s+(%d+):" - vim.cmd(line) + if line ~= nil then + vim.cmd(line) + end end end diff --git a/lua/ivy/utils_escape_spec.lua b/lua/ivy/utils_escape_spec.lua new file mode 100644 index 0000000..7a47331 --- /dev/null +++ b/lua/ivy/utils_escape_spec.lua @@ -0,0 +1,11 @@ +local utils = require "ivy.utils" + +it("will escape a dollar in the file name", function() + local result = utils.escape_file_name "/path/to/$file/$name.lua" + assert.is_same(result, "/path/to/\\$file/\\$name.lua") +end) + +it("will escape a brackets in the file name", function() + local result = utils.escape_file_name "/path/to/[file]/[name].lua" + assert.is_same(result, "/path/to/\\[file\\]/\\[name\\].lua") +end) diff --git a/lua/ivy/utils_line_action_spec.lua b/lua/ivy/utils_line_action_spec.lua new file mode 100644 index 0000000..8e4484e --- /dev/null +++ b/lua/ivy/utils_line_action_spec.lua @@ -0,0 +1,28 @@ +local utils = require "ivy.utils" +local line_action = utils.line_action() + +describe("utils line_action", function() + before_each(function() + spy.on(vim, "cmd") + end) + + it("will run the line command", function() + line_action " 4: Some text" + + assert.is_equal(#vim.cmd.calls, 1, "The `vim.cmd` function should be called once") + assert.spy(vim.cmd).was_called_with "4" + end) + + it("will run with more numbers", function() + line_action " 44: Some text" + + assert.is_equal(#vim.cmd.calls, 1, "The `vim.cmd` function should be called once") + assert.spy(vim.cmd).was_called_with "44" + end) + + it("dose not run any action if no line is found", function() + line_action "Some text" + + assert.spy(vim.cmd).was_not_called() + end) +end) diff --git a/lua/ivy/utils_line_action_test.lua b/lua/ivy/utils_line_action_test.lua deleted file mode 100644 index f68810e..0000000 --- a/lua/ivy/utils_line_action_test.lua +++ /dev/null @@ -1,39 +0,0 @@ -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_spec.lua similarity index 50% rename from lua/ivy/utils_vimgrep_action_test.lua rename to lua/ivy/utils_vimgrep_action_spec.lua index 0c08c09..51cb55d 100644 --- a/lua/ivy/utils_vimgrep_action_test.lua +++ b/lua/ivy/utils_vimgrep_action_spec.lua @@ -1,10 +1,5 @@ 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 = { { @@ -20,37 +15,42 @@ local test_data = { 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" }, + commands = { "buffer 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" }, + commands = { "vsplit | buffer 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" }, + commands = { "split | buffer 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 +describe("utils vimgrep_action", function() + before_each(function() + spy.on(vim, "cmd") end) -end + + after_each(function() + vim.cmd:revert() + end) + + for i = 1, #test_data do + local data = test_data[i] + it(data.it, function() + assert.is_true(#data.commands > 0, "You must assert that at least one command is run") + + vimgrep_action(data.completion, data.action) + assert.is_equal(#vim.cmd.calls, #data.commands, "The `vim.cmd` function should be called once") + + for j = 1, #data.commands do + assert.spy(vim.cmd).was_called_with(data.commands[j]) + end + end) + end +end) diff --git a/lua/ivy/window_spec.lua b/lua/ivy/window_spec.lua new file mode 100644 index 0000000..873504a --- /dev/null +++ b/lua/ivy/window_spec.lua @@ -0,0 +1,32 @@ +local window = require "ivy.window" +local controller = require "ivy.controller" + +describe("window", function() + before_each(function() + vim.cmd "highlight IvyMatch cterm=bold gui=bold" + window.initialize() + end) + + after_each(function() + controller.destroy() + end) + + it("can initialize and destroy the window", function() + assert.is_equal(vim.api.nvim_get_current_buf(), window.buffer) + + window.destroy() + assert.is_equal(nil, window.buffer) + end) + + it("can set items", function() + window.set_items { { content = "Line one" } } + assert.is_equal("Line one", window.get_current_selection()) + end) + + it("will set the items when a string is passed in", function() + local items = table.concat({ "One", "Two", "Three" }, "\n") + window.set_items(items) + + assert.is_equal(items, table.concat(vim.api.nvim_buf_get_lines(window.buffer, 0, -1, true), "\n")) + end) +end) diff --git a/lua/ivy/window_test.lua b/lua/ivy/window_test.lua deleted file mode 100644 index dcad637..0000000 --- a/lua/ivy/window_test.lua +++ /dev/null @@ -1,33 +0,0 @@ -local vim_mock = require "ivy.vim_mock" -local window = require "ivy.window" - -before_each(function() - vim_mock.reset() -end) - -it("can initialize and destroy the window", function(t) - window.initialize() - - t.assert_equal(10, window.get_buffer()) - t.assert_equal(10, window.buffer) - - window.destroy() - t.assert_equal(nil, window.buffer) -end) - -it("can set items", function(t) - window.initialize() - - window.set_items { { content = "Line one" } } - t.assert_equal("Line one", window.get_current_selection()) -end) - -it("will set the items when a string is passed in", function(t) - window.initialize() - - local items = table.concat({ "One", "Two", "Three" }, "\n") - window.set_items(items) - - local lines = table.concat(vim_mock.get_lines()[window.buffer], "\n") - t.assert_equal(items, lines) -end) diff --git a/plugin/ivy.lua b/plugin/ivy.lua index 383e866..cab4b9a 100644 --- a/plugin/ivy.lua +++ b/plugin/ivy.lua @@ -5,26 +5,6 @@ local controller = require "ivy.controller" -- luacheck: ignore vim.ivy = controller -local register_backend = function(backend) - assert(backend.command, "The backend must have a command") - assert(backend.items, "The backend must have a items function") - assert(backend.callback, "The backend must have a callback function") - - local user_command_options = { bang = true } - if backend.description ~= nil then - user_command_options.desc = backend.description - end - - local name = backend.name or backend.command - vim.api.nvim_create_user_command(backend.command, function() - vim.ivy.run(name, backend.items, backend.callback) - end, user_command_options) - - if backend.keymap ~= nil then - vim.api.nvim_set_keymap("n", backend.keymap, "" .. backend.command .. "", { nowait = true, silent = true }) - end -end - vim.paste = (function(overridden) return function(lines, phase) local file_type = vim.api.nvim_buf_get_option(0, "filetype") @@ -36,15 +16,4 @@ vim.paste = (function(overridden) end end)(vim.paste) -register_backend(require "ivy.backends.buffers") -register_backend(require "ivy.backends.files") -register_backend(require "ivy.backends.lines") -register_backend(require "ivy.backends.lsp-workspace-symbols") - -if vim.fn.executable "rg" then - register_backend(require "ivy.backends.rg") -elseif vim.fn.executable "ag" then - register_backend(require "ivy.backends.ag") -end - vim.cmd "highlight IvyMatch cterm=bold gui=bold" diff --git a/rust/finder.rs b/rust/finder.rs index f3baa97..37eff2a 100644 --- a/rust/finder.rs +++ b/rust/finder.rs @@ -26,7 +26,11 @@ pub fn find_files(options: Options) -> Vec { builder.overrides(overrides); for result in builder.build() { - let absolute_candidate = result.unwrap(); + let absolute_candidate = match result { + Ok(absolute_candidate) => absolute_candidate, + Err(..) => continue, + }; + let candidate_path = absolute_candidate.path().strip_prefix(base_path).unwrap(); if candidate_path.is_dir() { continue; diff --git a/scripts/busted.lua b/scripts/busted.lua new file mode 100755 index 0000000..753c0e6 --- /dev/null +++ b/scripts/busted.lua @@ -0,0 +1,8 @@ +-- Script to run the busted cli tool. You can use this under nvim using be +-- below command. Any arguments can be passed in the same as the busted cli. +-- +-- ```bash +-- nvim -l scripts/busted.lua +-- ``` +vim.opt.rtp:append(vim.fn.getcwd()) +require "busted.runner" { standalone = false }