diff --git a/lua/ivy/libivy.lua b/lua/ivy/libivy.lua index f9f8857..36946dc 100644 --- a/lua/ivy/libivy.lua +++ b/lua/ivy/libivy.lua @@ -1,43 +1,71 @@ local library_path = (function() local root = string.sub(debug.getinfo(1).source, 2, #"/libivy.lua" * -1) local release_path = root .. "../../target/release" return package.searchpath("libivyrs", release_path .. "/?.so;" .. release_path .. "/?.dylib;") end)() local ffi = require "ffi" local ok, ivy_c = pcall(ffi.load, library_path) if not ok then vim.api.nvim_err_writeln( "libivyrs.so not found! Please ensure you have complied the shared library." .. " For more info refer to the documentation, https://github.com/AdeAttwood/ivy.nvim#compiling" ) return end ffi.cdef [[ void ivy_init(const char*); char* ivy_cwd(); int ivy_match(const char*, const char*); char* ivy_files(const char*, const char*); + + int ivy_files_iter(const char*, const char*); + int ivy_files_iter_len(int); + char* ivy_files_iter_at(int, int); + void ivy_files_iter_delete(int); ]] +local iter_mt = { + __len = function(self) + return self.length + end, + __index = function(self, index) + -- Pass in our index -1. This will map lua's one based indexing to zero + -- based indexing that we are using in the rust lib. + local item = ffi.string(ivy_c.ivy_files_iter_at(self.id, index - 1)) + return { content = item } + end, + __newindex = function(_, _, _) + error("attempt to update a read-only table", 2) + end, + __gc = function(self) + ivy_c.ivy_files_iter_delete(self.id) + end, +} + local libivy = {} libivy.ivy_init = function(dir) ivy_c.ivy_init(dir) end libivy.ivy_cwd = function() return ffi.string(ivy_c.ivy_cwd()) end libivy.ivy_match = function(pattern, text) return ivy_c.ivy_match(pattern, text) end libivy.ivy_files = function(pattern, base_dir) - return ffi.string(ivy_c.ivy_files(pattern, base_dir)) + local iter_id = ivy_c.ivy_files_iter(pattern, base_dir) + local iter_len = ivy_c.ivy_files_iter_len(iter_id) + local iter = { id = iter_id, length = iter_len } + setmetatable(iter, iter_mt) + + return iter end return libivy diff --git a/lua/ivy/libivy_test.lua b/lua/ivy/libivy_test.lua index c7fe89b..fe18455 100644 --- a/lua/ivy/libivy_test.lua +++ b/lua/ivy/libivy_test.lua @@ -1,27 +1,46 @@ 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 matches = libivy.ivy_files(".github/workflows/ci.yml", current_dir) + local results = libivy.ivy_files(".github/workflows/ci.yml", current_dir) - local results = {} - for line in string.gmatch(matches, "[^\r\n]+") do - table.insert(results, line) + if results.length ~= 2 then + t.error("Incorrect number of results found " .. results.length) end - if #results ~= 2 then - t.error "Incorrect number of results" + 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 results[2] ~= ".github/workflows/ci.yml" then - t.error("Invalid matches: " .. results[2]) + if type(mt["__len"]) ~= "function" then + t.error "The iterator does not have an __len metamethod" end end) diff --git a/lua/ivy/window.lua b/lua/ivy/window.lua index c53d6e1..087e94b 100644 --- a/lua/ivy/window.lua +++ b/lua/ivy/window.lua @@ -1,147 +1,167 @@ -- 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 function get_items_length(items) + local mt = getmetatable(items) + if mt ~= nil and mt.__len ~= nil then + return mt.__len(items) + end + + return #items +end + +local function call_gc(items) + local mt = getmetatable(items) + if mt ~= nil and mt.__gc ~= nil then + return mt.__gc(items) + end +end + local window = {} window.index = 0 window.origin = nil window.window = nil window.buffer = nil window.origin_buffer = nil window.initialize = function() window.make_buffer() end window.make_buffer = function() window.origin = vim.api.nvim_get_current_win() window.origin_buffer = vim.api.nvim_win_get_buf(0) 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(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 + local items_length = get_items_length(items) + -- 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 + if items_length == 0 then + items_length = 1 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() + + call_gc(items) 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.origin_buffer = nil window.index = 0 end return window diff --git a/lua/ivy/window_test.lua b/lua/ivy/window_test.lua index c42b380..cd293fc 100644 --- a/lua/ivy/window_test.lua +++ b/lua/ivy/window_test.lua @@ -1,23 +1,33 @@ 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/rust/lib.rs b/rust/lib.rs index 6d9ba73..a4ac000 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -1,107 +1,179 @@ mod finder; mod matcher; mod sorter; use std::collections::HashMap; use std::ffi::CStr; use std::ffi::CString; use std::os::raw::{c_char, c_int}; use std::sync::Mutex; use std::sync::OnceLock; +// A store to the singleton instance of the ivy struct. This must not be accessed directly it must +// be use via the Ivy::global() function. Accessing this directly may cause a panic if its been +// initialized correctly. +static INSTANCE: OnceLock> = OnceLock::new(); + struct Ivy { + // The file cache so we don't have to keep iterating the filesystem. The map key is the root + // directory that has been search and the value an a vector containing all of the files that as + // in the root. The value will be relative from the root. pub file_cache: HashMap>, + // The sequence number of the last iterator created. This will use as a pointer value to the + // iterator so we can access it though lua and rust without having to copy strings. + pub iter_sequence: i32, + // A store of all the iterators that have been created. The key is the sequence number and the + // value is the vector of matches that were matched in the search. + pub iter_map: HashMap>, } -static INSTANCE: OnceLock> = OnceLock::new(); - impl Ivy { - pub fn new() -> Self { - Self { - file_cache: HashMap::new(), - } - } - + // Get the global instance of the ivy struct. This will initialize the struct if it has not + // initialized yet. pub fn global() -> &'static Mutex { - INSTANCE.get_or_init(|| Mutex::new(Ivy::new())) + INSTANCE.get_or_init(|| { + Mutex::new(Ivy { + file_cache: HashMap::new(), + iter_sequence: 0, + iter_map: HashMap::new(), + }) + }) } } fn to_string(input: *const c_char) -> String { unsafe { CStr::from_ptr(input) } .to_str() .unwrap() .to_string() } fn get_files(directory: &String) -> Vec { let mut ivy = Ivy::global().lock().unwrap(); if !ivy.file_cache.contains_key(directory) { let finder_options = finder::Options { directory: directory.clone(), }; ivy.file_cache .insert(directory.clone(), finder::find_files(finder_options)); } return ivy.file_cache.get(directory).unwrap().to_vec(); } #[no_mangle] pub extern "C" fn ivy_init(c_base_dir: *const c_char) { let directory = to_string(c_base_dir); get_files(&directory); } #[no_mangle] pub extern "C" fn ivy_cwd() -> *const c_char { return CString::new(std::env::current_dir().unwrap().to_str().unwrap()) .unwrap() .into_raw(); } #[no_mangle] pub extern "C" fn ivy_match(c_pattern: *const c_char, c_text: *const c_char) -> c_int { let pattern = to_string(c_pattern); let text = to_string(c_text); inner_match(pattern, text) } pub fn inner_match(pattern: String, text: String) -> i32 { let m = matcher::Matcher::new(pattern); m.score(text.as_str()) as i32 } +// Create a new iterator that will iterate over all the files in the given directory that match a +// pattern. It will return the pointer to the iterator so it can be retrieve later. The iterator +// can be deleted with `ivy_files_iter_delete` +#[no_mangle] +pub extern "C" fn ivy_files_iter(c_pattern: *const c_char, c_base_dir: *const c_char) -> i32 { + let directory = to_string(c_base_dir); + let pattern = to_string(c_pattern); + + let files = get_files(&directory); + + let mut ivy = Ivy::global().lock().unwrap(); + + // Convert the matches into CStrings so we can pass the pointers out while still maintaining + // ownership. If we didn't do this the CString would be dropped and the pointer would be freed + // while its being used externally. + let sorter_options = sorter::Options::new(pattern); + let matches = sorter::sort_strings(sorter_options, files) + .into_iter() + .map(|m| CString::new(m.content.as_str()).unwrap()) + .collect::>(); + + ivy.iter_sequence += 1; + let new_sequence = ivy.iter_sequence; + ivy.iter_map.insert(new_sequence, matches); + + new_sequence +} + +// Delete the iterator with the given id. This will free the memory used by the iterator that was +// created with `ivy_files_iter` +#[no_mangle] +pub extern "C" fn ivy_files_iter_delete(iter_id: i32) { + let mut ivy = Ivy::global().lock().unwrap(); + ivy.iter_map.remove(&iter_id); +} + +// Returns the length of a given iterator. This will return the number of items that were matched +// when the iterator was created with `ivy_files_iter` +#[no_mangle] +pub extern "C" fn ivy_files_iter_len(iter_id: i32) -> i32 { + let ivy = Ivy::global().lock().unwrap(); + + let items = ivy.iter_map.get(&iter_id).unwrap(); + items.len() as i32 +} + +// Returns the item at the given index in the iterator. This will return the full match that was +// given in the iterator. This will return a pointer to the string so it can be used in lua. +#[no_mangle] +pub extern "C" fn ivy_files_iter_at(iter_id: i32, index: i32) -> *const c_char { + let ivy = Ivy::global().lock().unwrap(); + + let items = ivy.iter_map.get(&iter_id).unwrap(); + let item = items.get(index as usize).unwrap(); + + item.as_ptr() +} + #[no_mangle] pub extern "C" fn ivy_files(c_pattern: *const c_char, c_base_dir: *const c_char) -> *const c_char { let pattern = to_string(c_pattern); let directory = to_string(c_base_dir); let output = inner_files(pattern, directory); CString::new(output).unwrap().into_raw() } pub fn inner_files(pattern: String, base_dir: String) -> String { let mut output = String::new(); // Bail out early if the pattern is empty; it's never going to find anything if pattern.is_empty() { return output; } let files = get_files(&base_dir); let sorter_options = sorter::Options::new(pattern); let files = sorter::sort_strings(sorter_options, files); for file in files.iter() { output.push_str(&file.content); output.push('\n'); } output }