diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5b46298 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# top-most EditorConfig file +root = true + +[Makefile] +indent_style = tab + +# Unix-style newlines with a newline ending every file +[{*.lua,*.md}] +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0d3987 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: Tests + +on: [push, pull_request] + +jobs: + unit_tests: + name: unit tests + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + rev: nightly/nvim-linux64.tar.gz + - os: ubuntu-22.04 + rev: v0.9.0/nvim-linux64.tar.gz + steps: + - uses: actions/checkout@v3 + - run: date +%F > todays-date + - name: Restore from todays cache + uses: actions/cache@v3 + with: + path: _neovim + key: ${{ runner.os }}-${{ matrix.rev }}-${{ hashFiles('todays-date') }} + + - name: Prepare + run: | + test -d _neovim || { + mkdir -p _neovim + curl -sL "https://github.com/neovim/neovim/releases/download/${{ matrix.rev }}" | tar xzf - --strip-components=1 -C "${PWD}/_neovim" + } + - name: Dependencies + run: | + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim + ln -s "$(pwd)" ~/.local/share/nvim/site/pack/vendor/start + - name: Run tests + run: | + export PATH="${PWD}/_neovim/bin:${PATH}" + nvim --version + make test + diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..a5f3ce3 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,26 @@ +name: Format + +on: [push, pull_request] + +jobs: + format: + name: Stylua + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: date +%W > weekly + + - name: Restore cache + id: cache + uses: actions/cache@v2 + with: + path: | + ~/.cargo/bin + key: ${{ runner.os }}-cargo-${{ hashFiles('weekly') }} + + - name: Install + if: steps.cache.outputs.cache-hit != 'true' + run: cargo install stylua + + - name: Format + run: stylua --check lua/ --config-path=.stylua.toml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..46759d5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,18 @@ +name: Lint + +on: [push, pull_request] + +jobs: + lint: + name: Luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup + run: | + sudo apt-get update + sudo apt-get install luarocks + sudo luarocks install luacheck + + - name: Lint + run: luacheck lua/ --globals vim diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..e1b9d70 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", + "Lua.workspace.checkThirdParty": false +} \ No newline at end of file diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..0b2e146 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,5 @@ +column_width = 80 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3011a8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2024 marlin.nvim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e5e9c43 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +.PHONY: test + +test: + nvim --headless --noplugin -u scripts/test/minimal.vim \ + -c "PlenaryBustedDirectory lua/marlin/test/ {minimal_init = 'scripts/tests/minimal.vim'}" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f057f6 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# marlin.nvim +##### Smooth sailing between buffers + +Persistent and extensible jumps across project buffers with ease. + +### Setup + +Example using the lazy plugin manager + +```lua +{ + "desdic/marlin.nvim", + opts = {}, + config = function(_, opts) + local marlin = require("marlin") + marlin.setup(opts) + local keymap = vim.keymap.set + + keymap("n", "fa", function() marlin.add_file() end, { desc = "add file" }) + keymap("n", "fd", function() marlin.remove_file() end, { desc = "remove file" }) + + for index = 1,4 do + keymap("n", ""..index, function() marlin.open_index(index) end, { desc = "goto "..index }) + end + end +} +``` + +### Default configuration + +```lua +local default = { + patterns = { ".git", ".svn" }, -- look for root of project + datafile = vim.fn.stdpath("data") .. "/marlin.json", -- location of data file + open_callback = function(bufnr, opts) -- default way to change to indexed buffer + vim.api.nvim_set_current_buf(bufnr) + end, +} +``` + +### Easy integration with most status lines + +Example with [lualine](https://github.com/nvim-lualine/lualine.nvim) + +```lua +return { + "nvim-lualine/lualine.nvim", + config = function() + local marlin = require("marlin") + + local marlin_component = function() + local indexes = marlin.get_number_of_indexes() + if indexes == 0 then + return "" + end + local cur_index = marlin.get_index_number() + + return " " .. cur_index .. "/" .. indexes + end + + require("lualine").setup({ + ... + sections = { + ... + lualine_c = { marlin_component }, + ... + }, + }) + end +``` + +### Extending behaviour + +Default behaviour is to replace the current buffer with the indexes one with + +```lua + open_callback = function(bufnr) + vim.api.nvim_set_current_buf(bufnr) + end, +``` + +But its possible to change the open_call function to get the behaviour you want. If you want to open new buffers in a vsplit you can + +```lua + open_callback = function(bufnr, _) + vim.cmd("vsplit") + vim.api.nvim_set_current_buf(bufnr) + end, +``` + +Or if want to add an options to open_index that switches to the buffer if already open in a split + +```lua + open_callback = function(bufnr, opts) + if opts.use_split then + local wins = vim.api.nvim_tabpage_list_wins(0) + for _, win in ipairs(wins) do + local winbufnr = vim.api.nvim_win_get_buf(win) + + if winbufnr == bufnr then + vim.api.nvim_set_current_win(win) + return + end + end + end + + vim.api.nvim_set_current_buf(bufnr) + end, +``` + +Choice is yours + +### TODO + + - Function to align indexes with buffers + - Function delete all indexes + - Currently no GUI and there might not be one + +### Why yet another .. + +When I first saw harpoon I was immediately hooked but I missed a few key features. + + - I use splits and wanted to have it jump to the buffer and not replace the current one. + - I wanted persistent jumps per project and not per directory. + +Like anyone else missing a feature I created a patch but it seems that many other did the same. + +### Credits + +Credit goes to [ThePrimeagen](https://github.com/ThePrimeagen/harpoon/) for the idea. diff --git a/lua/marlin/datafile.lua b/lua/marlin/datafile.lua new file mode 100644 index 0000000..b4442c5 --- /dev/null +++ b/lua/marlin/datafile.lua @@ -0,0 +1,35 @@ +local M = {} + +M.read_config = function(datafile) + if vim.fn.filereadable(datafile) ~= 0 then + local fd = io.open(datafile, "r") + if fd then + local content = fd:read("*a") + io.close(fd) + return vim.fn.json_decode(content) + end + end + return {} +end + +M.save_data = function(datafile, project, localdata) + local data = M.read_config(datafile) + data[project] = localdata + + -- If we have no more files we remove the project + if #data[project]["files"] == 0 then + data[project] = nil + end + + local content = vim.fn.json_encode(data) + local fd = io.open(datafile, "w") + if not fd then + vim.notify("Unable to open " .. datafile .. " for write") + return + end + + fd:write(content) + io.close(fd) +end + +return M diff --git a/lua/marlin/init.lua b/lua/marlin/init.lua new file mode 100644 index 0000000..528d1ea --- /dev/null +++ b/lua/marlin/init.lua @@ -0,0 +1,264 @@ +---@class marlin.commands +---@field open_index fun(index: number, opts: any?): nil +---@field remove_file fun(): nil +---@field get_index_number fun(): number +---@field get_number_of_indexes fun(): number +---@field add_file fun(): nil +---@field setup fun(opts: marlin.config): nil +local M = {} + +---@class marlin.config +---@field patterns? string[] patterns to detect root of project +---@field datafile? string location of datafile +---@field open_callback? fun(bufnr: number, opts: any?) function to set current buffer +local default = { + patterns = { ".git", ".svn" }, + datafile = vim.fn.stdpath("data") .. "/marlin.json", + open_callback = function(bufnr, opts) + vim.api.nvim_set_current_buf(bufnr) + end, +} + +local datafile = require("marlin.datafile") +local utils = require("marlin.utils") + +local get_cursor = function() + local cursor = vim.api.nvim_win_get_cursor(0) + return cursor[1] or 1, cursor[2] or 0 +end + +local update_location = function(marlin) + local cur_filename = utils.get_cur_filename() + if utils.is_empty(cur_filename) then + return + end + + if not marlin.project_files["files"] then + marlin.project_files["files"] = {} + end + + local row, col = get_cursor() + for idx, data in ipairs(marlin.get_indexes()) do + if data["filename"] == cur_filename then + marlin.project_files["files"][idx]["col"] = col + marlin.project_files["files"][idx]["row"] = row + break + end + end + + vim.schedule(function() + datafile.save_data( + marlin.opts.datafile, + marlin.project_path, + marlin.project_files + ) + end) +end + +local swap = function(table, index1, index2) + table[index1], table[index2] = table[index2], table[index1] + return table +end + +M.move_up = function() + local indexes = M.get_number_of_indexes() + if indexes < 2 then + return + end + local cur_index = M.get_index_number() + + if cur_index == 1 then + swap(M.project_files["files"], cur_index, indexes) + + return + end + + swap(M.project_files["files"], cur_index, cur_index - 1) +end + +M.move_down = function() + local indexes = M.get_number_of_indexes() + if indexes < 2 then + return + end + + local cur_index = M.get_index_number() + + if cur_index == indexes then + swap(M.project_files["files"], 1, cur_index) + + return + end + + swap(M.project_files["files"], cur_index, cur_index + 1) +end + +---@param index number index to load +---@param opts any? optional options to open_callback +M.open_index = function(index, opts) + if not M.project_files["files"] then + return + end + + opts = opts or {} + + local idx = tonumber(index) + if idx > M.get_number_of_indexes() then + return + end + + local cur_item = M.project_files["files"][idx] + local filename = cur_item.filename + local set_position = false + + -- Check if file already in a buffer + local bufnr = vim.fn.bufnr(filename) + if bufnr == -1 then + -- else create a buffer for it + bufnr = vim.fn.bufnr(filename, true) + set_position = true + end + + -- if the file is not loaded, load it and make it listed (visible) + if not vim.api.nvim_buf_is_loaded(bufnr) then + vim.fn.bufload(bufnr) + vim.api.nvim_set_option_value("buflisted", true, { + buf = bufnr, + }) + end + + M.opts.open_callback(bufnr, opts) + + if set_position then + vim.api.nvim_win_set_cursor(0, { + cur_item.row or 1, + cur_item.col or 0, + }) + end +end + +M.remove_file = function() + local cur_filename = utils.get_cur_filename() + if utils.is_empty(cur_filename) or not M.project_files["files"] then + return + end + + for idx, data in ipairs(M.project_files["files"]) do + if data["filename"] == cur_filename then + table.remove(M.project_files["files"], idx) + + vim.schedule(function() + datafile.save_data( + M.opts.datafile, + M.project_path, + M.project_files + ) + end) + + break + end + end +end + +---@return number retuns current index and 0 if not found +M.get_index_number = function() + local cur_filename = utils.get_cur_filename() + if utils.is_empty(cur_filename) then + return 0 + end + + if not M.project_files["files"] then + M.project_files["files"] = {} + end + + for idx, data in ipairs(M.project_files["files"]) do + if data["filename"] == cur_filename then + return idx + end + end + return 0 +end + +---@return number returns number of indexes in current project +M.get_number_of_indexes = function() + if not M.project_files["files"] then + return 0 + end + + return #M.project_files["files"] +end + +M.get_indexes = function() + if M.get_number_of_indexes() == 0 then + return {} + end + + return M.project_files["files"] +end + +M.add_file = function() + local cur_filename = utils.get_cur_filename() + if utils.is_empty(cur_filename) then + return + end + + if not M.project_files["files"] then + M.project_files["files"] = {} + end + + local row, col = get_cursor() + for idx, data in ipairs(M.get_indexes()) do + if data["filename"] == cur_filename then + M.project_files["files"][idx]["col"] = col + M.project_files["files"][idx]["row"] = row + return + end + end + + table.insert(M.project_files["files"], { + filename = cur_filename, + col = col, + row = row, + }) + + vim.schedule(function() + datafile.save_data(M.opts.datafile, M.project_path, M.project_files) + end) +end + +local search_for_project_path = function(patterns) + for _, pattern in ipairs(patterns) do + local match = utils.get_project_path(pattern) + if match ~= nil then + return match + end + end + return nil +end + +M.setup = function(opts) + M.opts = vim.tbl_deep_extend("force", default, opts or {}) + + -- Load project specific data + M.project_path = search_for_project_path(M.opts.patterns) + + M.project_files = {} + local data = datafile.read_config(M.opts.datafile) + for key, value in pairs(data) do + if key == M.project_path then + M.project_files = value + break + end + end + + local augroup = vim.api.nvim_create_augroup("marlin", {}) + vim.api.nvim_create_autocmd({ "BufLeave", "VimLeavePre" }, { + group = augroup, + pattern = "*", + callback = function(_) + update_location(M) + end, + }) +end + +return M diff --git a/lua/marlin/test/config_spec.lua b/lua/marlin/test/config_spec.lua new file mode 100644 index 0000000..4933259 --- /dev/null +++ b/lua/marlin/test/config_spec.lua @@ -0,0 +1,12 @@ +describe("config", function() + local marlin = require("marlin") + local eq = assert.equals + + local opts = { + patterns = { "Makefile" }, + } + + marlin.setup(opts) + + eq(marlin.opts.patterns[1], "Makefile") +end) diff --git a/lua/marlin/utils.lua b/lua/marlin/utils.lua new file mode 100644 index 0000000..488541e --- /dev/null +++ b/lua/marlin/utils.lua @@ -0,0 +1,15 @@ +local M = {} + +M.get_cur_filename = function() + return vim.fn.expand("%:p") +end + +M.get_project_path = function(patterns) + return vim.fs.dirname(vim.fs.find(patterns, { upward = true })[1]) +end + +M.is_empty = function(s) + return s == nil or s == "" +end + +return M diff --git a/scripts/test/minimal.vim b/scripts/test/minimal.vim new file mode 100644 index 0000000..23618bf --- /dev/null +++ b/scripts/test/minimal.vim @@ -0,0 +1,4 @@ +set noswapfile +set rtp+=. +set rtp+=../plenary.nvim +runtime! plugin/plenary.vim