From d550b939e983ed335dab3d2eb91e934047d025d5 Mon Sep 17 00:00:00 2001 From: Kim Gert Nielsen Date: Mon, 12 Feb 2024 14:31:30 +0100 Subject: [PATCH] Initial commit of marlin --- .editorconfig | 13 ++ .github/workflows/ci.yml | 41 ++++ .github/workflows/format.yml | 26 +++ .github/workflows/lint.yml | 18 ++ .gitignore | 1 + .luarc.json | 4 + .stylua.toml | 5 + LICENSE | 21 ++ Makefile | 24 +++ README.md | 131 ++++++++++++ TODO.md | 3 + doc/marlin.txt | 241 ++++++++++++++++++++++ doc/tags | 19 ++ lua/marlin/callbacks.lua | 23 +++ lua/marlin/datafile.lua | 35 ++++ lua/marlin/init.lua | 348 ++++++++++++++++++++++++++++++++ lua/marlin/sorters.lua | 36 ++++ lua/marlin/test/marlin_spec.lua | 65 ++++++ lua/marlin/test/utils_spec.lua | 11 + lua/marlin/utils.lua | 41 ++++ scripts/minimal_init_doc.lua | 15 ++ scripts/test/minimal.vim | 4 + 22 files changed, 1125 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/format.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 .stylua.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 doc/marlin.txt create mode 100644 doc/tags create mode 100644 lua/marlin/callbacks.lua create mode 100644 lua/marlin/datafile.lua create mode 100644 lua/marlin/init.lua create mode 100644 lua/marlin/sorters.lua create mode 100644 lua/marlin/test/marlin_spec.lua create mode 100644 lua/marlin/test/utils_spec.lua create mode 100644 lua/marlin/utils.lua create mode 100644 scripts/minimal_init_doc.lua create mode 100644 scripts/test/minimal.vim 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..136e557 --- /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@v4 + - run: date +%W > weekly + + - name: Restore cache + id: cache + uses: actions/cache@v4 + 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..4e5069f --- /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@v4 + - 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..3b73a24 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# marlin.nvim +##### Smooth sailing between buffers of interest + +Persistent and extensible jumps across project buffers of interest 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 order they where opened in) +- 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..03d1c3d --- /dev/null +++ b/doc/marlin.txt @@ -0,0 +1,241 @@ +============================================================================== +------------------------------------------------------------------------------ +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" }) + keymap("n", "0", function() marlin.open_all() end, { desc = "open all" }) + + 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 +{open_all} `(fun(): nil)` +{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.open_all()* + `marlin.open_all`() +Open all indexes + +Usage ~ +`require('marlin').open_all()` + +------------------------------------------------------------------------------ + *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..11fb3b5 --- /dev/null +++ b/doc/tags @@ -0,0 +1,19 @@ +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.open_all() marlin.txt /*marlin.open_all()* +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..3cbfe37 --- /dev/null +++ b/lua/marlin/init.lua @@ -0,0 +1,348 @@ +--- 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" }) +--- keymap("n", "0", function() marlin.open_all() end, { desc = "open all" }) +--- +--- 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 open_all fun(): nil +---@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 + +--- Open all indexes +--- +---@usage `require('marlin').open_all()` +marlin.open_all = function() + for idx, _ in ipairs(marlin.get_indexes()) do + marlin.open(idx) + 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