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..439bdca --- /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@v4 + - run: date +%F > todays-date + - name: Restore from todays cache + uses: actions/cache@v4 + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..6912fef --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +deps/ 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..539d166 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: test fmt link deps documentation + +default: all + +all: fmt lint test documentation + +fmt: + stylua lua/ --config-path=.stylua.toml + +lint: + luacheck lua/ --globals vim + +test: + nvim --headless --noplugin -u scripts/test/minimal.vim \ + -c "PlenaryBustedDirectory lua/marlin/test/ {minimal_init = 'scripts/test/minimal.vim'}" + +deps: + @mkdir -p deps + git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim + +documentation: + nvim --headless --noplugin -u ./scripts/minimal_init_doc.lua -c "lua require('mini.doc').generate()" -c "qa!" + +documentation-ci: deps documentation diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fac4b6 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# marlin.nvim +##### Smooth sailing between buffers of interest + +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() end, { desc = "add file" }) + keymap("n", "fd", function() marlin.remove() end, { desc = "remove file" }) + keymap("n", "fx", function() marlin.remove_all() end, { desc = "remove all for current project" }) + keymap("n", "f]", function() marlin.move_up() end, { desc = "move up" }) + keymap("n", "f[", function() marlin.move_down() end, { desc = "move down" }) + keymap("n", "fs", function() marlin.sort() end, { desc = "sort" }) + + for index = 1,4 do + keymap("n", ""..index, function() marlin.open(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 = callbacks.change_buffer -- default way to open buffer + sorter = sorter.by_buffer -- sort by bufferid +} +``` + +### 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.num_indexes() + if indexes == 0 then + return "" + end + local cur_index = marlin.cur_index() + + return " " .. cur_index .. "/" .. indexes + end + + require("lualine").setup({ + ... + sections = { + ... + lualine_c = { marlin_component }, + ... + }, + }) + end +``` + +### Extending behaviour + +`marlin.callbacks` has a few options like + +- change_buffer (which does what it says, default) +- use_split (if file is already open in a split switch to it) + +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, +``` + +Sorting also has a few options like + +- by_buffer sorts by buffer id (The way they where opened) +- by_name (Sorts by path+filename) + +But they can also be change if you want to write your own sorter. + +Choice is yours + +### 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/TODO.md b/TODO.md new file mode 100644 index 0000000..b19e0d3 --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# TODO + + - Currently no GUI and there might not be one diff --git a/doc/marlin.txt b/doc/marlin.txt new file mode 100644 index 0000000..5f9a89e --- /dev/null +++ b/doc/marlin.txt @@ -0,0 +1,231 @@ +============================================================================== +------------------------------------------------------------------------------ +Marlin is a plugin for quickly navigating in buffers of interest + +------------------------------------------------------------------------------ +Usage ~ +Example using the lazy plugin manager + + "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() end, { desc = "add file" }) + keymap("n", "fd", function() marlin.remove() end, { desc = "remove file" }) + keymap("n", "fx", function() marlin.remove_all() end, { desc = "remove all for current project" }) + keymap("n", "f]", function() marlin.move_up() end, { desc = "move up" }) + keymap("n", "f[", function() marlin.move_down() end, { desc = "move down" }) + keymap("n", "fs", function() marlin.sort() end, { desc = "sort" }) + + for index = 1,4 do + keymap("n", ""..index, function() marlin.open(index) end, { desc = "goto "..index }) + end + end + + +------------------------------------------------------------------------------ + *marlin* + `marlin` +Class ~ +{marlin.commands} +Fields ~ +{add} `(fun(filename?: string): nil)` -- add file +{cur_index} `(fun(): number)` -- get index of current file +{get_indexes} `(fun(): marlin.file[])` -- get indexes +{move} `(fun(table: marlin.file[], direction: marlin.movefun): nil)` +{move_down} `(fun(): nil)` -- move index down +{move_up} `(fun(): nil)` -- move index up +{num_indexes} `(fun(): number)` -- get number of indexes +{open} `(fun(index: number, opts: any?): nil)` -- open index +{remove} `(fun(filename?: string): nil)` -- remove current file +{remove_all} `(fun(): nil)` -- clear all indexes +{setup} `(fun(opts: marlin.config): nil)` -- setup +{sort} `(fun(sort_func?: fun(table: marlin.file[])): nil)` -- sorting + +------------------------------------------------------------------------------ +Class ~ +{marlin.file} +Fields ~ +{col} `(number)` +{row} `(number)` +{filename} `(string)` + +------------------------------------------------------------------------------ + *default* + `default` +Class ~ +{marlin.config} +Fields ~ +{patterns} `(optional)` `(string[])` patterns to detect root of project +{datafile} `(optional)` `(string)` location of datafile +{open_callback} `(optional)` `(fun(bufnr: number, opts: any?))` function to set current buffer +{sorter} `(optional)` `(fun(table: marlin.file[]))` sort function +Default config +> + local default = { + patterns = { ".git", ".svn" }, + datafile = vim.fn.stdpath("data") .. "/marlin.json", + open_callback = callbacks.change_buffer, + sorter = sorter.by_buffer, + } +< + +------------------------------------------------------------------------------ + *marlin.add()* + `marlin.add`({filename}) +Add a file + +Parameters ~ +{filename} `(optional)` `(string)` -- optional filename + +Usage ~ +`require('marlin').add()` + +------------------------------------------------------------------------------ + *marlin.cur_index()* + `marlin.cur_index`() +Return index for current filename + +Return ~ +`(number)` retuns current index and 0 if not found + +Usage ~ +`require('marlin').cur_index()` + +------------------------------------------------------------------------------ + *marlin.get_indexes()* + `marlin.get_indexes`() +Returns list of indexes + +Return ~ +marlin.file[] returns indexes + +Usage ~ +`require('marlin').get_indexes()` + +------------------------------------------------------------------------------ + *marlin.move()* + `marlin.move`({table}, {direction}) +Generic move function for moving indexes + +Parameters ~ +{table} `(string[])` index table +{direction} `(fun(table: marlin.file[], cur_index: number, num_indexes: number))` + +------------------------------------------------------------------------------ + *marlin.move_down()* + `marlin.move_down`() +Move current index down + +Usage ~ +`require('marlin').move_down()` + +------------------------------------------------------------------------------ + *marlin.move_up()* + `marlin.move_up`() +Move current index up + +Usage ~ +`require('marlin').move_up()` + +------------------------------------------------------------------------------ + *marlin.num_indexes()* + `marlin.num_indexes`() +Return number of indexes for current project + +Return ~ +`(number)` returns number of indexes in current project + +Usage ~ +`require('marlin').num_indexes()` + +------------------------------------------------------------------------------ + *marlin.open()* + `marlin.open`({index}, {opts}) +Open index + +Parameters ~ +{index} `(number)` index to load +{opts} `(any?)` optional options to open_callback + +Usage ~ +`require('marlin').open()` + +------------------------------------------------------------------------------ + *marlin.remove()* + `marlin.remove`({filename}) +Remove index + +Parameters ~ +{filename} `(optional)` `(string)` -- optional filename + +Usage ~ +`require('marlin').remove()` + +------------------------------------------------------------------------------ + *marlin.remove_all()* + `marlin.remove_all`() +Remove all indexes for current project + +Usage ~ +`require('marlin').remove_all()` + +------------------------------------------------------------------------------ + *marlin.setup()* + `marlin.setup`({opts}) +Setup (required) + +Parameters ~ +{opts} `(optional)` marlin.config + +Usage ~ +`require('marlin').setup()` + +------------------------------------------------------------------------------ + *marlin.sort()* + `marlin.sort`({sort_func}) +Sort indexes + +Parameters ~ +{sort_func} `(optional)` `(fun(table: marlin.file[]))` optional sort function else default + +Usage ~ +`require('marlin').sort()` + + +============================================================================== +------------------------------------------------------------------------------ + *M.change_buffer()* + `M.change_buffer`({bufnr}, {_}) +Parameters ~ +{bufnr} `(number)` buffer id + +------------------------------------------------------------------------------ + *M.use_split()* + `M.use_split`({bufnr}, {_}) +Parameters ~ +{bufnr} `(number)` buffer id + + +============================================================================== +------------------------------------------------------------------------------ + *M.by_buffer()* + `M.by_buffer`({filelist}) +Sort indexes by open buffers (Same order like bufferline shows them) + +Parameters ~ +{filelist} marlin.file[] + +------------------------------------------------------------------------------ + *M.by_name()* + `M.by_name`({filelist}) +Sort indexes by path + filename + +Parameters ~ +{filelist} marlin.file[] + + + vim:tw=78:ts=8:noet:ft=help:norl: \ No newline at end of file diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..1bf2f50 --- /dev/null +++ b/doc/tags @@ -0,0 +1,18 @@ +M.by_buffer() marlin.txt /*M.by_buffer()* +M.by_name() marlin.txt /*M.by_name()* +M.change_buffer() marlin.txt /*M.change_buffer()* +M.use_split() marlin.txt /*M.use_split()* +default marlin.txt /*default* +marlin marlin.txt /*marlin* +marlin.add() marlin.txt /*marlin.add()* +marlin.cur_index() marlin.txt /*marlin.cur_index()* +marlin.get_indexes() marlin.txt /*marlin.get_indexes()* +marlin.move() marlin.txt /*marlin.move()* +marlin.move_down() marlin.txt /*marlin.move_down()* +marlin.move_up() marlin.txt /*marlin.move_up()* +marlin.num_indexes() marlin.txt /*marlin.num_indexes()* +marlin.open() marlin.txt /*marlin.open()* +marlin.remove() marlin.txt /*marlin.remove()* +marlin.remove_all() marlin.txt /*marlin.remove_all()* +marlin.setup() marlin.txt /*marlin.setup()* +marlin.sort() marlin.txt /*marlin.sort()* diff --git a/lua/marlin/callbacks.lua b/lua/marlin/callbacks.lua new file mode 100644 index 0000000..638e64c --- /dev/null +++ b/lua/marlin/callbacks.lua @@ -0,0 +1,23 @@ +local M = {} + +---@param bufnr number buffer id +M.change_buffer = function(bufnr, _) + vim.api.nvim_set_current_buf(bufnr) +end + +---@param bufnr number buffer id +M.use_split = function(bufnr, _) + 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 + + vim.api.nvim_set_current_buf(bufnr) +end + +return M 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..a9a34d1 --- /dev/null +++ b/lua/marlin/init.lua @@ -0,0 +1,337 @@ +--- Marlin is a plugin for quickly navigating in buffers of interest + +---@usage Example using the lazy plugin manager +---{ +--- "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() end, { desc = "add file" }) +--- keymap("n", "fd", function() marlin.remove() end, { desc = "remove file" }) +--- keymap("n", "fx", function() marlin.remove_all() end, { desc = "remove all for current project" }) +--- keymap("n", "f]", function() marlin.move_up() end, { desc = "move up" }) +--- keymap("n", "f[", function() marlin.move_down() end, { desc = "move down" }) +--- keymap("n", "fs", function() marlin.sort() end, { desc = "sort" }) +--- +--- for index = 1,4 do +--- keymap("n", ""..index, function() marlin.open(index) end, { desc = "goto "..index }) +--- end +--- end +---} + +-- Module definition ========================================================== +---@class marlin.commands +---@field add fun(filename?: string): nil -- add file +---@field cur_index fun(): number -- get index of current file +---@field get_indexes fun(): marlin.file[] -- get indexes +---@field move fun(table: marlin.file[], direction: marlin.movefun): nil +---@field move_down fun(): nil -- move index down +---@field move_up fun(): nil -- move index up +---@field num_indexes fun(): number -- get number of indexes +---@field open fun(index: number, opts: any?): nil -- open index +---@field remove fun(filename?: string): nil -- remove current file +---@field remove_all fun(): nil -- clear all indexes +---@field setup fun(opts: marlin.config): nil -- setup +---@field sort fun(sort_func?: fun(table: marlin.file[])): nil -- sorting +local marlin = {} + +---@class marlin.file +---@field col number +---@field row number +---@field filename string + +---@alias marlin.movefun fun(table: marlin.file[], cur_index: number, num_indexes: number) + +local callbacks = require("marlin.callbacks") +local sorter = require("marlin.sorters") +local datafile = require("marlin.datafile") +local utils = require("marlin.utils") + +---@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 +---@field sorter? fun(table: marlin.file[]) sort function +--- Default config +---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) +local default = { + patterns = { ".git", ".svn" }, + datafile = vim.fn.stdpath("data") .. "/marlin.json", + open_callback = callbacks.change_buffer, + sorter = sorter.by_buffer, +} +--minidoc_afterlines_end + +local get_cursor = function() + local cursor = vim.api.nvim_win_get_cursor(0) + return cursor[1] or 1, cursor[2] or 0 +end + +local save = function(m) + vim.schedule(function() + datafile.save_data(m.opts.datafile, m.project_path, m.project_files) + end) +end + +local update_location = function(m) + 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 + break + end + end + + save(m) +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 + +--- Add a file +--- +---@param filename? string -- optional filename +--- +---@usage `require('marlin').add()` +marlin.add = function(filename) + filename = filename or utils.get_cur_filename() + if utils.is_empty(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"] == filename then + marlin.project_files["files"][idx]["col"] = col + marlin.project_files["files"][idx]["row"] = row + return + end + end + + table.insert(marlin.project_files["files"], { + filename = filename, + col = col, + row = row, + }) + + save(marlin) +end + +--- Return index for current filename +--- +---@return number retuns current index and 0 if not found +--- +---@usage `require('marlin').cur_index()` +marlin.cur_index = function() + local cur_filename = utils.get_cur_filename() + if utils.is_empty(cur_filename) then + return 0 + end + + if not marlin.project_files["files"] then + marlin.project_files["files"] = {} + end + + for idx, data in ipairs(marlin.project_files["files"]) do + if data["filename"] == cur_filename then + return idx + end + end + return 0 +end + +--- Returns list of indexes +--- +---@return marlin.file[] returns indexes +--- +---@usage `require('marlin').get_indexes()` +marlin.get_indexes = function() + if marlin.num_indexes() == 0 then + return {} + end + + return marlin.project_files["files"] +end + +--- Generic move function for moving indexes +--- +---@param table string[] index table +---@param direction marlin.movefun +marlin.move = function(table, direction) + local indexes = marlin.num_indexes() + if indexes < 2 then + return + end + + local cur_index = marlin.cur_index() + direction(table, cur_index, indexes) +end + +local up = function(table, cur_index, indexes) + if cur_index == 1 then + utils.swap(table, cur_index, indexes) + return + end + + utils.swap(table, cur_index, cur_index - 1) +end + +local down = function(table, cur_index, indexes) + if cur_index == indexes then + utils.swap(table, 1, cur_index) + return + end + + utils.swap(marlin.project_files["files"], cur_index, cur_index + 1) +end + +--- Move current index down +--- +---@usage `require('marlin').move_down()` +marlin.move_down = function() + marlin.move(marlin.project_files["files"], down) +end + +--- Move current index up +--- +---@usage `require('marlin').move_up()` +marlin.move_up = function() + marlin.move(marlin.project_files["files"], up) +end + +--- Return number of indexes for current project +--- +---@return number returns number of indexes in current project +--- +---@usage `require('marlin').num_indexes()` +marlin.num_indexes = function() + if not marlin.project_files["files"] then + return 0 + end + + return #marlin.project_files["files"] +end + +--- Open index +--- +---@param index number index to load +---@param opts any? optional options to open_callback +--- +---@usage `require('marlin').open()` +marlin.open = function(index, opts) + local idx = tonumber(index) + if idx > marlin.num_indexes() then + return + end + + opts = opts or {} + + local cur_item = marlin.project_files["files"][idx] + local bufnr, set_position = utils.load_buffer(cur_item.filename) + + marlin.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 + +--- Remove index +--- +---@param filename? string -- optional filename +--- +---@usage `require('marlin').remove()` +marlin.remove = function(filename) + filename = filename or utils.get_cur_filename() + if utils.is_empty(filename) or not marlin.project_files["files"] then + return + end + + for idx, data in ipairs(marlin.project_files["files"]) do + if data["filename"] == filename then + table.remove(marlin.project_files["files"], idx) + + save(marlin) + + break + end + end +end + +--- Remove all indexes for current project +--- +---@usage `require('marlin').remove_all()` +marlin.remove_all = function() + marlin.project_files["files"] = {} +end + +--- Setup (required) +--- +---@param opts? marlin.config +--- +---@usage `require('marlin').setup()` +marlin.setup = function(opts) + marlin.opts = vim.tbl_deep_extend("force", default, opts or {}) + + -- Load project specific data + marlin.project_path = search_for_project_path(marlin.opts.patterns) + + marlin.project_files = {} + local data = datafile.read_config(marlin.opts.datafile) + for key, value in pairs(data) do + if key == marlin.project_path then + marlin.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(marlin) + end, + }) +end +--- Sort indexes +--- +---@param sort_func? fun(table: marlin.file[]) optional sort function else default +--- +---@usage `require('marlin').sort()` +marlin.sort = function(sort_func) + sort_func = sort_func or marlin.opts.sorter + + if sort_func then + sort_func(marlin.project_files["files"]) + end +end + +return marlin diff --git a/lua/marlin/sorters.lua b/lua/marlin/sorters.lua new file mode 100644 index 0000000..36aa7c6 --- /dev/null +++ b/lua/marlin/sorters.lua @@ -0,0 +1,36 @@ +local M = {} + +local utils = require("marlin.utils") + +--- Sort indexes by open buffers (Same order like bufferline shows them) +--- +---@param filelist marlin.file[] +M.by_buffer = function(filelist) + local index = 1 + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if vim.fn.getbufinfo(bufnr)[1].listed == 1 then + local filename = vim.api.nvim_buf_get_name(bufnr) + + for idx, row in ipairs(filelist) do + if row.filename == filename then + if index ~= idx then + utils.swap(filelist, idx, index) + end + index = index + 1 + break + end + end + end + end +end + +--- Sort indexes by path + filename +--- +---@param filelist marlin.file[] +M.by_name = function(filelist) + table.sort(filelist, function(a, b) + return a.filename > b.filename + end) +end + +return M diff --git a/lua/marlin/test/marlin_spec.lua b/lua/marlin/test/marlin_spec.lua new file mode 100644 index 0000000..5883db2 --- /dev/null +++ b/lua/marlin/test/marlin_spec.lua @@ -0,0 +1,65 @@ +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) + +describe("marlin", function() + local eq = assert.equals + local marlin = require("marlin") + local opts = { + datafile = "/tmp/marlin.tmp", + } + marlin.setup(opts) + marlin.remove_all() + + vim.cmd("e /tmp/filea") + marlin.add() + eq(marlin.num_indexes(), 1) + + vim.cmd("e /tmp/fileb") + marlin.add() + eq(marlin.num_indexes(), 2) + + vim.cmd("e /tmp/filec") + marlin.add() + eq(marlin.num_indexes(), 3) + + vim.cmd("e /tmp/filed") + marlin.add() + eq(marlin.num_indexes(), 4) + + local indexes = marlin.get_indexes() + eq(indexes[1].filename, "/tmp/filea") + eq(indexes[2].filename, "/tmp/fileb") + eq(indexes[3].filename, "/tmp/filec") + eq(indexes[4].filename, "/tmp/filed") + + marlin.remove() + eq(marlin.num_indexes(), 3) + vim.cmd("bd") + + vim.cmd("bprev") + marlin.move_up() + + indexes = marlin.get_indexes() + eq(indexes[1].filename, "/tmp/fileb") + eq(indexes[2].filename, "/tmp/filea") + eq(indexes[3].filename, "/tmp/filec") + + marlin.sort() + indexes = marlin.get_indexes() + eq(indexes[1].filename, "/tmp/filea") + eq(indexes[2].filename, "/tmp/fileb") + eq(indexes[3].filename, "/tmp/filec") + + marlin.remove_all() + eq(marlin.num_indexes(), 0) +end) diff --git a/lua/marlin/test/utils_spec.lua b/lua/marlin/test/utils_spec.lua new file mode 100644 index 0000000..98c856a --- /dev/null +++ b/lua/marlin/test/utils_spec.lua @@ -0,0 +1,11 @@ +describe("swap", function() + local utils = require("marlin.utils") + local eq = assert.equals + + local list = { "/tmp/bfile", "/tmp/afile" } + + utils.swap(list, 1, 2) + + eq(list[1], "/tmp/afile") + eq(list[2], "/tmp/bfile") +end) diff --git a/lua/marlin/utils.lua b/lua/marlin/utils.lua new file mode 100644 index 0000000..67aff4c --- /dev/null +++ b/lua/marlin/utils.lua @@ -0,0 +1,41 @@ +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 + +M.swap = function(table, index1, index2) + table[index1], table[index2] = table[index2], table[index1] + return table +end + +M.load_buffer = function(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 + + return bufnr, set_position +end + +return M diff --git a/scripts/minimal_init_doc.lua b/scripts/minimal_init_doc.lua new file mode 100644 index 0000000..362995b --- /dev/null +++ b/scripts/minimal_init_doc.lua @@ -0,0 +1,15 @@ +-- Add current directory to 'runtimepath' to be able to use 'lua' files +vim.cmd([[let &rtp.=','.getcwd()]]) + +-- Set up 'mini.test' and 'mini.doc' only when calling headless Neovim (like with `make test` or `make documentation`) +if #vim.api.nvim_list_uis() == 0 then + -- Add 'mini.nvim' to 'runtimepath' to be able to use 'mini.test' + -- Assumed that 'mini.nvim' is stored in 'deps/mini.nvim' + vim.cmd("set rtp+=deps/mini.nvim") + + -- Set up 'mini.test' + require("mini.test").setup() + + -- Set up 'mini.doc' + require("mini.doc").setup() +end 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