diff --git a/.gitignore b/.gitignore index f554561..9671423 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.DS_Store .build/ .nono.json +deps/ diff --git a/.luarc.json b/.luarc.json index f4ce6c8..df51519 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], - "diagnostics.globals": ["vim"], + "diagnostics.globals": ["vim", "describe", "it", "before_each", "after_each"], "diagnostics.severity": { "unused-local": "Warning", "unused-function": "Warning", @@ -14,5 +14,5 @@ "unreachable-code": "Any" }, "workspace.checkThirdParty": false, - "workspace.ignoreDir": [".git", ".build"] + "workspace.ignoreDir": [".git", ".build", "deps"] } diff --git a/Makefile b/Makefile index 6740ef4..e2c4ad7 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ LUALS ?= lua-language-server NVIM ?= nvim BUILD := .build +MINI_TEST_VERSION := v0.18.0 +MINI_TEST := deps/mini.test-$(MINI_TEST_VERSION) + .PHONY: all check test lint fmt fmt-check clean all: check @@ -11,31 +14,24 @@ all: check ## check: everything CI runs check: lint fmt-check test -## test: run each suite in its own Neovim -test: SUITES := tests/smoke.lua tests/reflow.lua tests/orphans.lua tests/export.lua tests/spans.lua -test: - @fail=0; \ - for t in $(SUITES); do \ - printf '%-22s ' "$$t"; \ - out=$$($(NVIM) --headless --clean -u tests/minimal_init.lua \ - -c "lua local ok,e=pcall(dofile,'$$t') if not ok then io.write('FAIL: '..tostring(e)..'\n') end vim.cmd('qa!')" \ - 2>&1 | grep -E '^(FAIL|[a-z]+: ok)' || true); \ - case "$$out" in \ - FAIL*) echo "$$out"; fail=1 ;; \ - *) echo "ok" ;; \ - esac; \ - done; \ - exit $$fail +## test: run the specs +test: $(MINI_TEST) + @MINI_TEST=$(MINI_TEST) $(NVIM) --headless --clean -u tests/minit.lua -c "lua MiniTest.run()" + +## deps: mini.test, cloned once per version +$(MINI_TEST): + @git -c advice.detachedHead=false clone --quiet --depth 1 --branch $(MINI_TEST_VERSION) \ + https://github.com/nvim-mini/mini.test $@ ## lint: lua-language-server diagnostics and type checking over lua/ and tests/ ## ## Outside an editor nothing supplies Neovim's runtime types, so a build copy of -## .luarc.json adds $VIMRUNTIME/lua. Output goes to a file, not a pipe, so make -## sees lua-language-server's exit status rather than the filter's. -lint: +## .luarc.json adds $VIMRUNTIME/lua and mini.test. Output goes to a file, not a +## pipe, so make sees lua-language-server's exit status rather than the filter's. +lint: $(MINI_TEST) @mkdir -p $(BUILD) @$(NVIM) --headless --clean \ - -c 'lua local c = vim.json.decode(table.concat(vim.fn.readfile(".luarc.json"), "\n")); c["workspace.library"] = { vim.env.VIMRUNTIME .. "/lua" }; vim.fn.writefile({ vim.json.encode(c) }, "$(BUILD)/luarc.json")' \ + -c 'lua local c = vim.json.decode(table.concat(vim.fn.readfile(".luarc.json"), "\n")); c["workspace.library"] = { vim.env.VIMRUNTIME .. "/lua", "$(MINI_TEST)/lua" }; vim.fn.writefile({ vim.json.encode(c) }, "$(BUILD)/luarc.json")' \ -c 'qa!' @$(LUALS) --check "$(CURDIR)" --checklevel=Warning \ --configpath="$(CURDIR)/$(BUILD)/luarc.json" --logpath="$(CURDIR)/$(BUILD)/luals" \ diff --git a/README.md b/README.md index 0297d19..c1f737d 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,13 @@ to paste into an LLM or hand to a person. With `lazy.nvim`: ```lua -{ - "eleith/scratch-comments.nvim", - opts = {}, -} +{ "eleith/scratch-comments.nvim" } ``` With `vim.pack`: ```lua vim.pack.add({ { src = "https://git.eleith.com/eleith/scratch-comments.nvim" } }) -require("scratch_comments").setup() ``` ## use @@ -38,13 +34,14 @@ comment on them, or just a word or phrase to comment on only that: ``` a window opens in the middle of the screen, with the lines you're commenting on -at the top and your comment below. it opens in insert mode, and its title shows -the mode you're in. write as many lines as you like, then `:wq` to save. `:q` -closes it if you haven't changed anything, and `:q!` throws your changes away. -`w` moves between the two parts, or scroll them with the mouse. +at the top and your comment below. a new comment opens in insert mode, and the +title shows the mode you're in. write as many lines as you like, then `:wq` to +save. `:q` closes it if you haven't changed anything, and `:q!` throws your +changes away. `w` moves between the two parts, or scroll them with the +mouse. commented lines get a mark in the sign column: `│` for one line, and `╭` `│` `╰` -down a range. read a comment on the cursor line, in the same window: +down a range. to read the comment on the cursor line: ```vim :CommentShow @@ -71,10 +68,9 @@ it, pipe it to a command, or edit it: | Command | Does | | --- | --- | | `:Comment` | Comment on the current line or command range | -| `:CommentShow` | Show the comment at the cursor, with the lines it's on | +| `:CommentShow` | Show the comment at the cursor, with the lines it's on. Edit it there and `:w` to save | | `:CommentNext` | Go to the next comment in the file | | `:CommentPrev` | Go to the previous comment in the file | -| `:CommentEdit` | Edit the comment at the cursor | | `:CommentDelete` | Delete the comment at the cursor, or pick an orphaned one | | `:CommentList` | Put comments in the quickfix list and open it | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | @@ -98,10 +94,11 @@ vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comme ## browsing `:CommentNext` and `:CommentPrev` move between comments in the current file, -wrapping at the ends. With a `:CommentShow` window open, they show the next or -previous comment in that window instead. `:CommentList` puts all your comments in the quickfix -list, so `]q` and `[q` move between them across files, and any quickfix viewer -works: +wrapping at the ends. with a comment open, they show the next or previous +comment in that window instead. save or discard your changes first. + +`:CommentList` puts all your comments in the quickfix list, so `]q` and `[q` +move between them across files, and any quickfix viewer works: ```vim :copen " built in, no plugins @@ -137,12 +134,10 @@ vim.api.nvim_set_hl(0, "ScratchCommentSign", { fg = "#2fafff", bg = "#004065" }) ```lua local scratch = require("scratch_comments") -scratch.setup() scratch.add(start_line, end_line) -- default: the cursor line scratch.show() scratch.next() scratch.prev() -scratch.edit() scratch.delete() scratch.list() scratch.render(format) -- "markdown" (default) or "json" @@ -159,10 +154,36 @@ vim.fn.writefile(vim.split(scratch.render(), "\n"), "review.md") ## requirements -- Neovim 0.10 or newer. +- Neovim 0.12 or newer. - No plugin dependencies. - A clipboard provider. in tmux you may need `vim.g.clipboard = "osc52"`. +## development + +``` +plugin/scratch-comments.lua the commands +lua/scratch_comments/ + init.lua the lua API + actions.lua comment, show, delete, toggle, clear + navigate.lua next and prev + comments.lua changes comments and keeps the signs in sync + location.lua "line 3", "lines 3–5", "line 3, col 5–12" + paths.lua git root and relative paths + model/ the comments and where they are + ui/ the comment window, signs, quickfix, notifications + export/ markdown, json and the clipboard +tests/ + unit/ tests for single modules + features/ tests that run the commands +``` + +`mise install` gets the pinned tools. then: + +```sh +make check # lint, format check and tests, like CI +make test # tests only. the first run clones mini.test into deps/ +``` + ## credit forked from [annotator.nvim](https://github.com/chpeters/annotator.nvim) by diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 3765c94..d68d0af 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -20,11 +20,11 @@ and |:CommentShow| shows the comment on the cursor line. Comments live in the buffer. They go away when you close the buffer or clear them. -Setup: >lua - require("scratch_comments").setup() -< +The commands work as soon as the plugin is installed. There is no setup. Add your own mappings. See |scratch-comments-api| for examples. +Requires Neovim 0.12 or newer. + ============================================================================== 2. COMMANDS *scratch-comments-commands* @@ -32,42 +32,40 @@ Add your own mappings. See |scratch-comments-api| for examples. :[range]Comment Comment on the current line, or on [range]. To comment on a selection, select it and type ":Comment", which gives ":'<,'>Comment". A characterwise selection - (|v|) comments on just the selected text; a linewise - one (|V|) on whole lines. If that exact text already - has a comment, edit it. + (|v|) comments on just the selected text. A linewise + one (|V|) comments on whole lines. If that exact text + already has a comment, edit it. A window opens in the middle of the screen: the lines you are commenting on at the top, your comment below. - It opens in insert mode, and its title shows the mode - you are in. Write as many lines as you like, then - |:wq| to save. |:q| closes it if nothing changed, and - |:q!| throws your changes away. |CTRL-W_w| moves - between the two parts, or scroll them with the mouse. + A new comment opens in insert mode, and the title + shows the mode you are in. Write as many lines as you + like, then |:wq| to save. |:q| closes it if nothing + changed, and |:q!| throws your changes away. + |CTRL-W_w| moves between the two parts, or scroll them + with the mouse. *:CommentShow* -:CommentShow Show the comment at the cursor in the same window, - read-only, with the lines it is on. If more than one - comment covers the line, asks which one. |:q| closes - it. +:CommentShow Open the comment at the cursor, with the lines it is + on, in the same window |:Comment| uses. If more than + one comment covers the line, asks which one. It opens + in normal mode. Edit the comment and |:w| to save, or + |:q| to close it. *:CommentNext* :CommentNext Go to the next comment in the current file, wrapping at the end. Comments that start on the same line are one stop. - With a |:CommentShow| window open, show the next - comment in it instead, one comment at a time. The - cursor in the file follows. + With a comment open, show the next comment in that + window instead. Every comment is a stop, even on the + same line, and the cursor in the file follows. Save + or discard your changes first. *:CommentPrev* :CommentPrev Go to the previous comment in the current file, - wrapping at the start. With a |:CommentShow| window - open, show the previous comment in it. - - *:CommentEdit* -:CommentEdit Edit the comment at the cursor in the same window. If - more than one comment covers the line, asks which - one. + wrapping at the start. With a comment open, show the + previous comment in that window instead. *:CommentDelete* :CommentDelete Delete the comment at the cursor. If more than one @@ -78,6 +76,7 @@ Add your own mappings. See |scratch-comments-api| for examples. *:CommentList* :CommentList Put every comment in the |quickfix| list and open it. |]q| and |[q| then move between comments across files. + For a comment on selected text, they land on the text. Any quickfix front-end (trouble.nvim, nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) can show it. @@ -106,7 +105,7 @@ selected within them. - Commenting on the same lines again edits that comment. - Commenting on different lines adds a new comment, even if the lines overlap - another comment. A comment on lines 10-30 and one on line 14 are separate. + another comment. A comment on lines 10–30 and one on line 14 are separate. ============================================================================== 4. RETENTION *scratch-comments-retention* @@ -129,7 +128,7 @@ In tmux you may need to set the clipboard to OSC 52 yourself: >lua Each commented line gets a sign: "│" for a one-line comment, and "╭", "│", "╰" down a range. The signs share the column with plugins like gitsigns and can -cover their signs; |:CommentToggle| hides ours when you need to see theirs. +cover their signs. |:CommentToggle| hides ours when you need to see theirs. *ScratchCommentSign* The signs use ScratchCommentSign, which links to |hl-Todo|. To change it: >lua @@ -145,12 +144,10 @@ The signs use ScratchCommentSign, which links to |hl-Todo|. To change it: >lua >lua local scratch = require("scratch_comments") - scratch.setup() scratch.add(start_line, end_line) -- default: the cursor line scratch.show() scratch.next() scratch.prev() - scratch.edit() scratch.delete() scratch.list() scratch.render(format) -- "markdown" (default) or "json" diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua new file mode 100644 index 0000000..66ed421 --- /dev/null +++ b/lua/scratch_comments/actions.lua @@ -0,0 +1,214 @@ +local comments = require("scratch_comments.comments") +local location = require("scratch_comments.location") +local paths = require("scratch_comments.paths") +local views = require("scratch_comments.model.views") +local notify = require("scratch_comments.ui.notify") +local signs = require("scratch_comments.ui.signs") +local window = require("scratch_comments.ui.window") + +local M = {} + +---@param text string +---@return string +local function summary(text) + return vim.split(text, "\n")[1] +end + +---@param bufnr integer +---@param start_line integer +---@param end_line integer +---@param start_col? integer +---@param end_col? integer +---@return ScratchCommentView? +local function find_anchor(bufnr, start_line, end_line, start_col, end_col) + return vim.iter(views.in_buffer(bufnr)):find(function(view) + return view.start_line == start_line + and view.end_line == end_line + and view.start_col == start_col + and view.end_col == end_col + end) +end + +---@param start_line integer +---@param end_line integer +---@return integer? start_col +---@return integer? end_col +local function selected_columns(start_line, end_line) + local from, to = vim.fn.getpos("'<"), vim.fn.getpos("'>") + if vim.fn.visualmode() ~= "v" or from[2] ~= start_line or to[2] ~= end_line then + return nil, nil + end + local last_line = vim.fn.getline(end_line) + local last = math.min(to[3], #last_line) + local end_col = last == 0 and 0 or last + vim.str_utf_end(last_line, last) + return from[3] - 1, end_col +end + +---@return ScratchCommentView[] +local function cursor_comments() + local line = vim.api.nvim_win_get_cursor(0)[1] + return vim.tbl_filter(function(view) + return view.start_line <= line and line <= view.end_line + end, views.in_buffer(vim.api.nvim_get_current_buf())) +end + +---@param view ScratchCommentView +---@param source_win integer +function M.show(view, source_win) + local in_file = views.in_buffer(view.bufnr) + window.open({ + title = location.title(view.file_path, view), + context = vim.split(view.snippet, "\n"), + filetype = vim.bo[view.bufnr].filetype, + comment = view.comment, + comment_title = ("comment (%d of %d)"):format(views.index_of(in_file, view.id) or 0, #in_file), + on_save = function(text) + comments.edit(view.id, text) + notify.info("Updated comment") + end, + }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) +end + +---@param start_line integer +---@param end_line integer +---@param use_selection? boolean +function M.comment(start_line, end_line, use_selection) + local bufnr = vim.api.nvim_get_current_buf() + local name = vim.api.nvim_buf_get_name(bufnr) + if name == "" then + notify.warn("Save the buffer before commenting on it") + return + end + start_line, end_line = math.min(start_line, end_line), math.max(start_line, end_line) + local start_col, end_col + if use_selection then + start_col, end_col = selected_columns(start_line, end_line) + end + + local existing = find_anchor(bufnr, start_line, end_line, start_col, end_col) + if existing then + M.show(existing, vim.api.nvim_get_current_win()) + return + end + + local file_path = vim.fn.fnamemodify(name, ":p") + local relative_path = paths.relative_path(paths.git_root(file_path), file_path) + ---@type ScratchComment? + local added + ---@type ScratchCommentWindow + local record = { bufnr = bufnr, line = start_line, source_win = vim.api.nvim_get_current_win() } + window.open({ + title = location.title(file_path, { + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, + }), + context = views.text(bufnr, start_line, end_line, start_col, end_col), + filetype = vim.bo[bufnr].filetype, + comment = "", + on_save = function(text) + if added then + comments.edit(added.id, text) + return + end + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + added = comments.add({ + bufnr = bufnr, + comment = text, + file_path = file_path, + relative_path = relative_path, + }, { + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, + }) + record.id = added.id + notify.info("Added comment") + end, + }, record) +end + +---@param view ScratchCommentView +---@return string +local function describe(view) + return location.describe(view) .. ": " .. summary(view.comment) +end + +---@param candidates ScratchCommentView[] +---@param callback fun(view: ScratchCommentView) +local function choose(candidates, callback) + if #candidates == 1 then + callback(candidates[1]) + return + end + vim.ui.select(candidates, { prompt = "Comment: ", format_item = describe }, function(view) + if view then + callback(view) + end + end) +end + +---@param comment ScratchComment +local function delete(comment) + comments.delete(comment) + notify.info("Deleted comment") +end + +function M.show_current() + local at_cursor = cursor_comments() + if #at_cursor == 0 then + notify.info("No comment at cursor") + return + end + local source_win = vim.api.nvim_get_current_win() + choose(at_cursor, function(view) + M.show(view, source_win) + end) +end + +function M.delete_current() + local at_cursor = cursor_comments() + if #at_cursor > 0 then + choose(at_cursor, delete) + return + end + + local bufnr = vim.api.nvim_get_current_buf() + local orphans = vim.tbl_filter(function(comment) + return comment.bufnr == bufnr + end, views.orphans()) + if #orphans == 0 then + notify.info("No comment at cursor") + return + end + + vim.ui.select(orphans, { + prompt = "Delete orphaned comment: ", + format_item = function(comment) + return summary(comment.comment) + end, + }, function(comment) + if comment then + delete(comment) + end + end) +end + +---@param on? boolean +function M.toggle(on) + if on == nil then + on = not signs.is_visible() + end + comments.show_signs(on) +end + +function M.clear() + comments.clear() + notify.info("Cleared comments") +end + +return M diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua deleted file mode 100644 index 0327fde..0000000 --- a/lua/scratch_comments/annotate.lua +++ /dev/null @@ -1,338 +0,0 @@ -local context = require("scratch_comments.context") -local render = require("scratch_comments.render") -local state = require("scratch_comments.state") -local ui = require("scratch_comments.ui") - -local M = {} - ----@param bufnr integer ----@param start_line integer ----@param end_line integer ----@param start_col? integer ----@param end_col? integer ----@return ScratchCommentView? -local function find_anchor(bufnr, start_line, end_line, start_col, end_col) - return state.find(function(comment) - return comment.bufnr == bufnr - and comment.start_line == start_line - and comment.end_line == end_line - and comment.start_col == start_col - and comment.end_col == end_col - end) -end - ----@param start_line integer ----@param end_line integer ----@return integer? start_col ----@return integer? end_col -local function selected_columns(start_line, end_line) - local from, to = vim.fn.getpos("'<"), vim.fn.getpos("'>") - if vim.fn.visualmode() ~= "v" or from[2] ~= start_line or to[2] ~= end_line then - return nil, nil - end - local last_line = vim.fn.getline(end_line) - local last = math.min(to[3], #last_line) - local end_col = last == 0 and 0 or last + vim.str_utf_end(last_line, last) - return from[3] - 1, end_col -end - ----@return ScratchCommentView[] -local function cursor_comments() - local bufnr = vim.api.nvim_get_current_buf() - local line = vim.api.nvim_win_get_cursor(0)[1] - return state.find_all(function(comment) - return comment.bufnr == bufnr and comment.start_line <= line and line <= comment.end_line - end) -end - ----@param file_path string ----@param start_line integer ----@param end_line integer ----@param start_col? integer ----@param end_col? integer ----@return string -local function title(file_path, start_line, end_line, start_col, end_col) - local name = vim.fn.fnamemodify(file_path, ":t") - if start_col and end_col then - local first, last = start_col + 1, end_col - if start_line ~= end_line then - return ("%s [lines %d:%d–%d:%d]"):format(name, start_line, first, end_line, last) - end - if first == last then - return ("%s [line %d, col %d]"):format(name, start_line, first) - end - return ("%s [line %d, col %d–%d]"):format(name, start_line, first, last) - end - if start_line == end_line then - return name .. " [line " .. start_line .. "]" - end - return name .. " [lines " .. start_line .. "–" .. end_line .. "]" -end - ----@param view ScratchCommentView ----@param fields table on_save, on_close or comment_title for ui.open_frame ----@return integer comment_win -local function open_frame(view, fields) - return ui.open_frame(vim.tbl_extend("error", { - title = title(view.file_path, view.start_line, view.end_line, view.start_col, view.end_col), - context = vim.split(view.snippet, "\n"), - filetype = vim.bo[view.bufnr].filetype, - comment = view.comment, - }, fields)) -end - ----@class ScratchPreview ----@field id string ----@field bufnr integer ----@field source_win integer ----@field comment_win integer - ----@type ScratchPreview? -local preview - ----@param bufnr integer ----@return ScratchCommentView[] -local function file_views(bufnr) - return vim.tbl_filter(function(view) - return view.bufnr == bufnr - end, state.anchored()) -end - ----@param views ScratchCommentView[] ----@param id string ----@return integer? -local function index_of(views, id) - for i, view in ipairs(views) do - if view.id == id then - return i - end - end -end - ----@param view ScratchCommentView ----@param source_win integer -local function show(view, source_win) - local views = file_views(view.bufnr) - local comment_win - comment_win = open_frame(view, { - comment_title = ("comment (%d of %d)"):format(index_of(views, view.id) or 0, #views), - on_close = function() - if preview and preview.comment_win == comment_win then - preview = nil - end - end, - }) - preview = { id = view.id, bufnr = view.bufnr, source_win = source_win, comment_win = comment_win } -end - ----@param current ScratchPreview ----@param direction 1|-1 -local function cycle_preview(current, direction) - local views = file_views(current.bufnr) - if #views == 0 then - return - end - - local index = index_of(views, current.id) - local target - if index then - target = views[(index - 1 + direction) % #views + 1] - else - target = direction == 1 and views[1] or views[#views] - end - - vim.api.nvim_win_close(current.comment_win, true) - if vim.api.nvim_win_is_valid(current.source_win) then - vim.api.nvim_win_call(current.source_win, function() - vim.cmd("normal! m'") - vim.api.nvim_win_set_cursor(0, { target.start_line, target.start_col or 0 }) - end) - end - show(target, current.source_win) -end - ----@param view ScratchCommentView -local function edit(view) - open_frame(view, { - on_save = function(text) - state.update(view.id, { comment = text }) - ui.notify("Updated comment", "info") - end, - }) -end - ----@param start_line integer ----@param end_line integer ----@param use_selection? boolean -function M.range(start_line, end_line, use_selection) - local bufnr = vim.api.nvim_get_current_buf() - local name = vim.api.nvim_buf_get_name(bufnr) - if name == "" then - ui.notify("Save the buffer before commenting on it", "warn") - return - end - start_line, end_line = math.min(start_line, end_line), math.max(start_line, end_line) - local start_col, end_col - if use_selection then - start_col, end_col = selected_columns(start_line, end_line) - end - - local existing = find_anchor(bufnr, start_line, end_line, start_col, end_col) - if existing then - edit(existing) - return - end - - local file_path = vim.fn.fnamemodify(name, ":p") - local relative_path = context.relative_path(context.git_root(file_path), file_path) - ---@type ScratchComment? - local added - ui.open_frame({ - title = title(file_path, start_line, end_line, start_col, end_col), - context = state.text(bufnr, start_line, end_line, start_col, end_col), - filetype = vim.bo[bufnr].filetype, - comment = "", - on_save = function(text) - if added then - state.update(added.id, { comment = text }) - return - end - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - added = state.add({ - bufnr = bufnr, - comment = text, - file_path = file_path, - relative_path = relative_path, - charwise = start_col ~= nil, - }) - render.anchor(added, start_line, end_line, start_col, end_col) - render.draw_signs(bufnr, state.in_buffer(bufnr)) - ui.notify("Added comment", "info") - end, - }) -end - ----@param view ScratchCommentView ----@return string -local function describe(view) - local lines = view.start_line == view.end_line and tostring(view.start_line) - or (view.start_line .. "-" .. view.end_line) - return "lines " .. lines .. ": " .. ui.summary(view.comment) -end - ----@param views ScratchCommentView[] ----@param callback fun(view: ScratchCommentView) -local function choose(views, callback) - if #views == 1 then - callback(views[1]) - return - end - vim.ui.select(views, { prompt = "Comment: ", format_item = describe }, function(view) - if view then - callback(view) - end - end) -end - ----@param comment ScratchComment -local function delete(comment) - render.clear_all(state.remove_ids({ comment.id })) - render.draw_signs(comment.bufnr, state.in_buffer(comment.bufnr)) - ui.notify("Deleted comment", "info") -end - -function M.show_current() - local views = cursor_comments() - if #views == 0 then - ui.notify("No comment at cursor", "info") - return - end - local source_win = vim.api.nvim_get_current_win() - choose(views, function(view) - show(view, source_win) - end) -end - ----@param direction 1|-1 -function M.jump(direction) - if preview and vim.api.nvim_win_is_valid(preview.comment_win) then - cycle_preview(preview, direction) - return - end - - local bufnr = vim.api.nvim_get_current_buf() - local line = vim.api.nvim_win_get_cursor(0)[1] - local starts = {} - for _, view in ipairs(state.anchored()) do - if view.bufnr == bufnr then - starts[view.start_line] = true - end - end - local lines = vim.tbl_keys(starts) - if #lines == 0 then - ui.notify("No comments in this buffer", "info") - return - end - table.sort(lines) - - local target - if direction == 1 then - for _, start_line in ipairs(lines) do - if start_line > line then - target = start_line - break - end - end - target = target or lines[1] - else - for i = #lines, 1, -1 do - if lines[i] < line then - target = lines[i] - break - end - end - target = target or lines[#lines] - end - - vim.cmd("normal! m'") - vim.api.nvim_win_set_cursor(0, { target, 0 }) -end - -function M.edit_current() - local views = cursor_comments() - if #views == 0 then - ui.notify("No comment at cursor", "info") - return - end - choose(views, edit) -end - -function M.delete_current() - local views = cursor_comments() - if #views > 0 then - choose(views, delete) - return - end - - local bufnr = vim.api.nvim_get_current_buf() - local orphans = vim.tbl_filter(render.is_orphaned, state.in_buffer(bufnr)) - if #orphans == 0 then - ui.notify("No comment at cursor", "info") - return - end - - vim.ui.select(orphans, { - prompt = "Delete orphaned comment: ", - format_item = function(comment) - return ui.summary(comment.comment) - end, - }, function(comment) - if comment then - delete(comment) - end - end) -end - -return M diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua new file mode 100644 index 0000000..8dae360 --- /dev/null +++ b/lua/scratch_comments/comments.lua @@ -0,0 +1,98 @@ +local anchors = require("scratch_comments.model.anchors") +local signs = require("scratch_comments.ui.signs") +local store = require("scratch_comments.model.store") + +local M = {} + +local group = vim.api.nvim_create_augroup("scratch_comments", { clear = true }) + +---@param bufnr integer +function M.redraw(bufnr) + signs.draw(bufnr, store.in_buffer(bufnr)) +end + +---@return integer[] +local function commented_buffers() + local seen = {} + for _, comment in ipairs(store.all()) do + seen[comment.bufnr] = true + end + return vim.tbl_keys(seen) +end + +---@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string } +---@param location ScratchLocation +---@return ScratchComment +function M.add(fields, location) + local comment = store.add({ + bufnr = fields.bufnr, + comment = fields.comment, + file_path = fields.file_path, + relative_path = fields.relative_path, + charwise = location.start_col ~= nil, + }) + anchors.anchor( + comment, + location.start_line, + location.end_line, + location.start_col, + location.end_col + ) + M.redraw(comment.bufnr) + return comment +end + +---@param id string +---@param text string +function M.edit(id, text) + store.update(id, { comment = text }) +end + +---@param comment ScratchComment +function M.delete(comment) + anchors.clear_all(store.remove(function(candidate) + return candidate.id == comment.id + end)) + M.redraw(comment.bufnr) +end + +function M.clear() + for _, bufnr in ipairs(commented_buffers()) do + anchors.clear_buffer(bufnr) + signs.clear_buffer(bufnr) + end + store.clear() +end + +---@param bufnr integer +function M.forget_buffer(bufnr) + anchors.clear_buffer(bufnr) + signs.clear_buffer(bufnr) + store.remove(function(comment) + return comment.bufnr == bufnr + end) +end + +---@param on boolean +function M.show_signs(on) + signs.set_visible(on) + for _, bufnr in ipairs(commented_buffers()) do + M.redraw(bufnr) + end +end + +vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + group = group, + callback = function(args) + M.redraw(args.buf) + end, +}) + +vim.api.nvim_create_autocmd("BufDelete", { + group = group, + callback = function(args) + M.forget_buffer(args.buf) + end, +}) + +return M diff --git a/lua/scratch_comments/export.lua b/lua/scratch_comments/export/clipboard.lua similarity index 100% rename from lua/scratch_comments/export.lua rename to lua/scratch_comments/export/clipboard.lua diff --git a/lua/scratch_comments/export/init.lua b/lua/scratch_comments/export/init.lua new file mode 100644 index 0000000..e163ff8 --- /dev/null +++ b/lua/scratch_comments/export/init.lua @@ -0,0 +1,65 @@ +local clipboard = require("scratch_comments.export.clipboard") +local json = require("scratch_comments.export.json") +local markdown = require("scratch_comments.export.markdown") +local notify = require("scratch_comments.ui.notify") +local views = require("scratch_comments.model.views") + +local M = {} + +local formats = { markdown = markdown.render, json = json.render } + +---@return string[] +function M.names() + return vim.tbl_keys(formats) +end + +---@param format? "markdown"|"json" +---@return string +function M.render(format) + local render_format = formats[format or "markdown"] or error("unknown format: " .. format) + return render_format(views.anchored(), views.orphans()) +end + +---@param text string +---@param filetype string +local function open_scratch(text, filetype) + vim.cmd.new() + vim.bo.buftype = "nofile" + vim.bo.bufhidden = "wipe" + vim.bo.swapfile = false + vim.bo.filetype = filetype + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(text, "\n")) +end + +---@param format? string +---@param in_buffer? boolean +function M.export(format, in_buffer) + format = format or "markdown" + local render_format = formats[format] + if not render_format then + notify.error("Unknown format: " .. format) + return + end + + local anchored, orphans = views.anchored(), views.orphans() + local count = #anchored + #orphans + if count == 0 then + notify.info("No comments to export") + return + end + + local text = render_format(anchored, orphans) + if in_buffer then + open_scratch(text, format) + return + end + + if not clipboard.copy(text) then + notify.error("Could not copy to the clipboard; no provider configured?") + return + end + + notify.info("Copied " .. count .. " comment(s)") +end + +return M diff --git a/lua/scratch_comments/json.lua b/lua/scratch_comments/export/json.lua similarity index 90% rename from lua/scratch_comments/json.lua rename to lua/scratch_comments/export/json.lua index 29b5efb..20a3212 100644 --- a/lua/scratch_comments/json.lua +++ b/lua/scratch_comments/export/json.lua @@ -1,9 +1,9 @@ local M = {} ----@param items ScratchCommentView[] +---@param views ScratchCommentView[] ---@param orphans ScratchComment[] ---@return string -function M.render(items, orphans) +function M.render(views, orphans) local comments = vim.tbl_map(function(comment) return { id = comment.id, @@ -16,7 +16,7 @@ function M.render(items, orphans) snippet = comment.snippet, comment = comment.comment, } - end, items) + end, views) local orphaned = vim.tbl_map(function(comment) return { diff --git a/lua/scratch_comments/markdown.lua b/lua/scratch_comments/export/markdown.lua similarity index 77% rename from lua/scratch_comments/markdown.lua rename to lua/scratch_comments/export/markdown.lua index 6fe4cdc..3dcf441 100644 --- a/lua/scratch_comments/markdown.lua +++ b/lua/scratch_comments/export/markdown.lua @@ -1,3 +1,5 @@ +local location = require("scratch_comments.location") + local M = {} local function extension(path) @@ -11,21 +13,14 @@ local function fence_for(text) return "```" end -local function line_range(comment) - if comment.start_line == comment.end_line then - return "line " .. comment.start_line - end - return "lines " .. comment.start_line .. "-" .. comment.end_line -end - ----@param items ScratchCommentView[] +---@param views ScratchCommentView[] ---@param orphans ScratchComment[] ---@return string -function M.render(items, orphans) +function M.render(views, orphans) local lines = { "Comments:", "" } local current_file = nil - for _, comment in ipairs(items) do + for _, comment in ipairs(views) do local file = comment.relative_path if file ~= current_file then current_file = file @@ -34,7 +29,7 @@ function M.render(items, orphans) end local fence = fence_for(comment.snippet) - table.insert(lines, "- " .. line_range(comment) .. " (" .. comment.id .. ")") + table.insert(lines, "- " .. location.describe(comment) .. " (" .. comment.id .. ")") table.insert(lines, "") table.insert(lines, comment.comment or "") table.insert(lines, "") diff --git a/lua/scratch_comments/health.lua b/lua/scratch_comments/health.lua index f0b4558..dcd713a 100644 --- a/lua/scratch_comments/health.lua +++ b/lua/scratch_comments/health.lua @@ -3,10 +3,10 @@ local M = {} function M.check() vim.health.start("scratch-comments") - if vim.fn.has("nvim-0.10") == 1 then + if vim.fn.has("nvim-0.12") == 1 then vim.health.ok("Neovim version is supported") else - vim.health.warn("Neovim 0.10 or newer is required") + vim.health.warn("Neovim 0.12 or newer is required") end if vim.fn.has("clipboard") == 1 or vim.g.clipboard ~= nil then diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 3890284..beb280d 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,188 +1,38 @@ -local annotate = require("scratch_comments.annotate") -local clipboard = require("scratch_comments.export") -local json = require("scratch_comments.json") -local markdown = require("scratch_comments.markdown") -local render = require("scratch_comments.render") -local state = require("scratch_comments.state") -local ui = require("scratch_comments.ui") +local actions = require("scratch_comments.actions") +local export = require("scratch_comments.export") +local navigate = require("scratch_comments.navigate") +local quickfix = require("scratch_comments.ui.quickfix") +local views = require("scratch_comments.model.views") local M = {} -local formats = { markdown = markdown.render, json = json.render } - ---@param start_line? integer ---@param end_line? integer function M.add(start_line, end_line) start_line = start_line or vim.api.nvim_win_get_cursor(0)[1] - annotate.range(start_line, end_line or start_line) + actions.comment(start_line, end_line or start_line) end -function M.show() - annotate.show_current() -end +M.show = actions.show_current +M.delete = actions.delete_current +M.toggle = actions.toggle +M.clear = actions.clear +M.render = export.render +M.export = export.export function M.next() - annotate.jump(1) + navigate.jump(1) end function M.prev() - annotate.jump(-1) -end - -function M.edit() - annotate.edit_current() -end - -function M.delete() - annotate.delete_current() + navigate.jump(-1) end function M.list() - ui.list(state.anchored(), state.orphans()) + quickfix.list(views.anchored(), views.orphans()) end ----@param format? "markdown"|"json" ----@return string -function M.render(format) - local render_format = formats[format or "markdown"] or error("unknown format: " .. format) - return render_format(state.anchored(), state.orphans()) -end - ----@param format? string ----@param in_buffer? boolean -function M.export(format, in_buffer) - format = format or "markdown" - local render_format = formats[format] - if not render_format then - ui.notify("Unknown format: " .. format, "error") - return - end - - local items, orphans = state.anchored(), state.orphans() - local count = #items + #orphans - if count == 0 then - ui.notify("No comments to export", "info") - return - end - - local text = render_format(items, orphans) - if in_buffer then - ui.open_scratch(text, format) - return - end - - if not clipboard.copy(text) then - ui.notify("Could not copy to the clipboard; no provider configured?", "error") - return - end - - ui.notify("Copied " .. count .. " comment(s)", "info") -end - ----@return integer[] -local function commented_buffers() - local buffers = {} - for _, comment in ipairs(state.all()) do - buffers[comment.bufnr] = true - end - return vim.tbl_keys(buffers) -end - ----@param bufnr integer -local function redraw(bufnr) - render.draw_signs(bufnr, state.in_buffer(bufnr)) -end - ----@param on? boolean -function M.toggle(on) - if on == nil then - on = not render.is_visible() - end - render.set_visible(on) - for _, bufnr in ipairs(commented_buffers()) do - redraw(bufnr) - end -end - -function M.clear() - for _, bufnr in ipairs(commented_buffers()) do - render.clear_buffer(bufnr) - end - state.clear() - ui.notify("Cleared comments", "info") -end - -function M.setup() - render.setup() - - vim.api.nvim_create_user_command("Comment", function(ctx) - annotate.range(ctx.line1, ctx.line2, ctx.range == 2) - end, { range = true, desc = "Comment on the current line or range" }) - - vim.api.nvim_create_user_command( - "CommentShow", - M.show, - { desc = "Show the comments at the cursor" } - ) - vim.api.nvim_create_user_command( - "CommentEdit", - M.edit, - { desc = "Edit the comment at the cursor" } - ) - vim.api.nvim_create_user_command( - "CommentDelete", - M.delete, - { desc = "Delete the comment at the cursor" } - ) - vim.api.nvim_create_user_command("CommentNext", M.next, { desc = "Go to the next comment" }) - vim.api.nvim_create_user_command("CommentPrev", M.prev, { desc = "Go to the previous comment" }) - vim.api.nvim_create_user_command("CommentList", M.list, { desc = "Browse comments" }) - vim.api.nvim_create_user_command("CommentExport", function(ctx) - M.export(ctx.fargs[1], ctx.bang) - end, { - nargs = "?", - bang = true, - complete = function() - return vim.tbl_keys(formats) - end, - desc = "Copy comments to the clipboard; with ! open them in a scratch buffer", - }) - vim.api.nvim_create_user_command("CommentToggle", function(ctx) - local choice = ctx.fargs[1] - if choice ~= nil and choice ~= "on" and choice ~= "off" then - ui.notify("Usage: :CommentToggle [on|off]", "error") - return - end - if choice == nil then - M.toggle() - else - M.toggle(choice == "on") - end - end, { - nargs = "?", - complete = function() - return { "on", "off" } - end, - desc = "Show or hide the comment signs", - }) - vim.api.nvim_create_user_command("CommentClear", M.clear, { desc = "Clear all comments" }) - - local group = vim.api.nvim_create_augroup("scratch_comments", { clear = true }) - - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - group = group, - callback = function(args) - redraw(args.buf) - end, - }) - - vim.api.nvim_create_autocmd("BufDelete", { - group = group, - callback = function(args) - render.clear_buffer(args.buf) - state.remove_buffer(args.buf) - end, - }) -end +-- Kept so configs that call it keep working; the commands need no setup. +function M.setup() end return M diff --git a/lua/scratch_comments/location.lua b/lua/scratch_comments/location.lua new file mode 100644 index 0000000..1724bd6 --- /dev/null +++ b/lua/scratch_comments/location.lua @@ -0,0 +1,48 @@ +local M = {} + +---@class ScratchLocation +---@field start_line integer 1-based, inclusive. +---@field end_line integer 1-based, inclusive. +---@field start_col? integer 0-based byte, for a character span. +---@field end_col? integer 0-based byte, exclusive. + +---@param location ScratchLocation +---@return string +function M.describe(location) + local start_line, end_line = location.start_line, location.end_line + if location.start_col and location.end_col then + local first, last = location.start_col + 1, location.end_col + if start_line ~= end_line then + return ("lines %d:%d–%d:%d"):format(start_line, first, end_line, last) + end + if first == last then + return ("line %d, col %d"):format(start_line, first) + end + return ("line %d, col %d–%d"):format(start_line, first, last) + end + if start_line == end_line then + return "line " .. start_line + end + return "lines " .. start_line .. "–" .. end_line +end + +---@param file_path string +---@param location ScratchLocation +---@return string +function M.title(file_path, location) + return vim.fn.fnamemodify(file_path, ":t") .. " [" .. M.describe(location) .. "]" +end + +-- Cuts from the left so the line range and the file extension stay visible. +---@param text string +---@param width integer +---@return string +function M.fit(text, width) + local start = 0 + while vim.api.nvim_strwidth(vim.fn.strcharpart(text, start)) > width do + start = start + 1 + end + return start == 0 and text or "…" .. vim.fn.strcharpart(text, start + 1) +end + +return M diff --git a/lua/scratch_comments/render.lua b/lua/scratch_comments/model/anchors.lua similarity index 58% rename from lua/scratch_comments/render.lua rename to lua/scratch_comments/model/anchors.lua index 7fa435b..b1298d7 100644 --- a/lua/scratch_comments/render.lua +++ b/lua/scratch_comments/model/anchors.lua @@ -1,24 +1,6 @@ local M = {} local namespace = vim.api.nvim_create_namespace("scratch_comments") -local sign_namespace = vim.api.nvim_create_namespace("scratch_comments_signs") -local group = "ScratchCommentSign" -local signs = { single = "│", first = "╭", middle = "│", last = "╰" } -local visible = true - -function M.setup() - vim.api.nvim_set_hl(0, group, { link = "Todo", default = true }) -end - ----@return boolean -function M.is_visible() - return visible -end - ----@param on boolean -function M.set_visible(on) - visible = on -end ---@param comment ScratchComment ---@param start_line integer @@ -81,46 +63,8 @@ function M.is_orphaned(comment) return details ~= nil and details.invalid == true end ----@param bufnr integer ----@param text string ----@param start_line integer ----@param end_line? integer -local function sign(bufnr, text, start_line, end_line) - vim.api.nvim_buf_set_extmark(bufnr, sign_namespace, start_line - 1, 0, { - end_row = (end_line or start_line) - 1, - sign_text = text, - sign_hl_group = group, - }) -end - --- Signs are drawn from the anchors rather than stored on them: a mark shows --- its sign on every row it touches, and an anchor also touches the line after --- its range. ----@param bufnr integer ----@param comments ScratchComment[] -function M.draw_signs(bufnr, comments) - vim.api.nvim_buf_clear_namespace(bufnr, sign_namespace, 0, -1) - if not visible then - return - end - for _, comment in ipairs(comments) do - local start_line, end_line = M.range(comment) - if start_line and end_line then - if start_line == end_line then - sign(bufnr, signs.single, start_line) - else - sign(bufnr, signs.first, start_line) - if end_line - start_line > 1 then - sign(bufnr, signs.middle, start_line + 1, end_line - 1) - end - sign(bufnr, signs.last, end_line) - end - end - end -end - ---@param comment ScratchComment -function M.clear(comment) +local function clear(comment) if comment and comment.extmark_id @@ -131,10 +75,10 @@ function M.clear(comment) end end ----@param items ScratchComment[] -function M.clear_all(items) - for _, comment in ipairs(items) do - M.clear(comment) +---@param comments ScratchComment[] +function M.clear_all(comments) + for _, comment in ipairs(comments) do + clear(comment) end end @@ -142,7 +86,6 @@ end function M.clear_buffer(bufnr) if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, sign_namespace, 0, -1) end end diff --git a/lua/scratch_comments/model/store.lua b/lua/scratch_comments/model/store.lua new file mode 100644 index 0000000..dbb4641 --- /dev/null +++ b/lua/scratch_comments/model/store.lua @@ -0,0 +1,75 @@ +local M = {} + +---@class ScratchComment +---@field id string +---@field bufnr integer +---@field extmark_id? integer +---@field comment string +---@field file_path string +---@field relative_path string +---@field charwise? boolean + +---@type ScratchComment[] +local comments = {} +local next_id = 1 + +---@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string, charwise?: boolean } +---@return ScratchComment +function M.add(fields) + local comment = { + id = "comment-" .. next_id, + bufnr = fields.bufnr, + comment = fields.comment, + file_path = fields.file_path, + relative_path = fields.relative_path, + charwise = fields.charwise, + } + next_id = next_id + 1 + table.insert(comments, comment) + return comment +end + +---@param id string +---@param fields table +---@return ScratchComment? +function M.update(id, fields) + for _, comment in ipairs(comments) do + if comment.id == id then + for key, value in pairs(fields) do + comment[key] = value + end + return comment + end + end + return nil +end + +---@return ScratchComment[] +function M.all() + return comments +end + +---@param bufnr integer +---@return ScratchComment[] +function M.in_buffer(bufnr) + return vim.tbl_filter(function(comment) + return comment.bufnr == bufnr + end, comments) +end + +---@param predicate fun(comment: ScratchComment): boolean +---@return ScratchComment[] removed +function M.remove(predicate) + local kept, removed = {}, {} + for _, comment in ipairs(comments) do + table.insert(predicate(comment) and removed or kept, comment) + end + comments = kept + return removed +end + +function M.clear() + comments = {} +end + +return M diff --git a/lua/scratch_comments/model/views.lua b/lua/scratch_comments/model/views.lua new file mode 100644 index 0000000..ce2731e --- /dev/null +++ b/lua/scratch_comments/model/views.lua @@ -0,0 +1,87 @@ +local anchors = require("scratch_comments.model.anchors") +local store = require("scratch_comments.model.store") + +local M = {} + +---@class ScratchCommentView : ScratchComment, ScratchLocation +---@field snippet string + +---@param id string +---@return integer +local function number(id) + return assert(tonumber(id:match("%d+$"))) +end + +-- Orders views by file, then lines, then the order they were added. +---@param a ScratchCommentView +---@param b ScratchCommentView +---@return boolean +function M.by_position(a, b) + if a.relative_path ~= b.relative_path then + return a.relative_path < b.relative_path + end + if a.start_line ~= b.start_line then + return a.start_line < b.start_line + end + if a.end_line ~= b.end_line then + return a.end_line < b.end_line + end + return number(a.id) < number(b.id) +end + +---@param bufnr integer +---@param start_line integer +---@param end_line integer +---@param start_col? integer +---@param end_col? integer +---@return string[] +function M.text(bufnr, start_line, end_line, start_col, end_col) + if start_col and end_col then + return vim.api.nvim_buf_get_text(bufnr, start_line - 1, start_col, end_line - 1, end_col, {}) + end + return vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) +end + +---@return ScratchCommentView[] +function M.anchored() + local views = {} + for _, comment in ipairs(store.all()) do + local start_line, end_line, start_col, end_col = anchors.range(comment) + if start_line and end_line then + local view = vim.deepcopy(comment) --[[@as ScratchCommentView]] + view.start_line, view.end_line = start_line, end_line + view.start_col, view.end_col = start_col, end_col + view.snippet = + table.concat(M.text(comment.bufnr, start_line, end_line, start_col, end_col), "\n") + table.insert(views, view) + end + end + table.sort(views, M.by_position) + return views +end + +---@param bufnr integer +---@return ScratchCommentView[] +function M.in_buffer(bufnr) + return vim.tbl_filter(function(view) + return view.bufnr == bufnr + end, M.anchored()) +end + +---@param list ScratchCommentView[] +---@param id? string +---@return integer? +function M.index_of(list, id) + for i, view in ipairs(list) do + if view.id == id then + return i + end + end +end + +---@return ScratchComment[] +function M.orphans() + return vim.tbl_filter(anchors.is_orphaned, store.all()) +end + +return M diff --git a/lua/scratch_comments/navigate.lua b/lua/scratch_comments/navigate.lua new file mode 100644 index 0000000..feabb6c --- /dev/null +++ b/lua/scratch_comments/navigate.lua @@ -0,0 +1,92 @@ +local actions = require("scratch_comments.actions") +local notify = require("scratch_comments.ui.notify") +local views = require("scratch_comments.model.views") +local window = require("scratch_comments.ui.window") + +local M = {} + +---@param current ScratchCommentWindow +---@param direction 1|-1 +local function cycle(current, direction) + if vim.bo[vim.api.nvim_win_get_buf(current.comment_win)].modified then + notify.warn("Save or discard the comment first") + return + end + local in_file = views.in_buffer(current.bufnr) + if #in_file == 0 then + return + end + + local index = views.index_of(in_file, current.id) + local target + if index then + target = in_file[(index - 1 + direction) % #in_file + 1] + elseif direction == 1 then + target = vim.iter(in_file):find(function(view) + return view.start_line > current.line + end) or in_file[1] + else + target = vim.iter(in_file):rev():find(function(view) + return view.start_line < current.line + end) or in_file[#in_file] + end + + vim.api.nvim_win_close(current.comment_win, true) + if vim.api.nvim_win_is_valid(current.source_win) then + vim.api.nvim_win_call(current.source_win, function() + vim.cmd("normal! m'") + vim.api.nvim_win_set_cursor(0, { target.start_line, target.start_col or 0 }) + end) + end + actions.show(target, current.source_win) +end + +---@param direction 1|-1 +local function move_cursor(direction) + local bufnr = vim.api.nvim_get_current_buf() + local line = vim.api.nvim_win_get_cursor(0)[1] + local starts = {} + for _, view in ipairs(views.in_buffer(bufnr)) do + starts[view.start_line] = true + end + local lines = vim.tbl_keys(starts) + if #lines == 0 then + notify.info("No comments in this buffer") + return + end + table.sort(lines) + + local target + if direction == 1 then + for _, start_line in ipairs(lines) do + if start_line > line then + target = start_line + break + end + end + target = target or lines[1] + else + for i = #lines, 1, -1 do + if lines[i] < line then + target = lines[i] + break + end + end + target = target or lines[#lines] + end + + vim.cmd("normal! m'") + vim.api.nvim_win_set_cursor(0, { target, 0 }) +end + +---@param direction 1|-1 +function M.jump(direction) + local current = window.current() + if current then + cycle(current, direction) + else + move_cursor(direction) + end +end + +return M diff --git a/lua/scratch_comments/context.lua b/lua/scratch_comments/paths.lua similarity index 88% rename from lua/scratch_comments/context.lua rename to lua/scratch_comments/paths.lua index 7ca36d3..9d4357d 100644 --- a/lua/scratch_comments/context.lua +++ b/lua/scratch_comments/paths.lua @@ -1,13 +1,9 @@ local M = {} -local function trim(value) - return (value or ""):gsub("^%s+", ""):gsub("%s+$", "") -end - local function run(args) local result = vim.system(args, { text = true }):wait() if result.code == 0 then - return trim(result.stdout) + return vim.trim(result.stdout or "") end return nil end diff --git a/lua/scratch_comments/state.lua b/lua/scratch_comments/state.lua deleted file mode 100644 index e400f08..0000000 --- a/lua/scratch_comments/state.lua +++ /dev/null @@ -1,174 +0,0 @@ -local render = require("scratch_comments.render") - -local M = {} - ----@class ScratchComment ----@field id string ----@field bufnr integer ----@field extmark_id? integer ----@field comment string ----@field file_path string ----@field relative_path string ----@field charwise? boolean - ----@class ScratchCommentView : ScratchComment ----@field start_line integer 1-based, inclusive. ----@field end_line integer 1-based, inclusive. ----@field start_col? integer 0-based byte, for a character span. ----@field end_col? integer 0-based byte, exclusive. ----@field snippet string - ----@type ScratchComment[] -local items = {} -local next_id = 1 - ----@param a ScratchCommentView ----@param b ScratchCommentView -local function by_position(a, b) - if a.relative_path ~= b.relative_path then - return a.relative_path < b.relative_path - end - if a.start_line ~= b.start_line then - return a.start_line < b.start_line - end - if a.end_line ~= b.end_line then - return a.end_line < b.end_line - end - return a.id < b.id -end - ----@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string, charwise?: boolean } ----@return ScratchComment -function M.add(fields) - local comment = { - id = "comment-" .. next_id, - bufnr = fields.bufnr, - comment = fields.comment, - file_path = fields.file_path, - relative_path = fields.relative_path, - charwise = fields.charwise, - } - next_id = next_id + 1 - table.insert(items, comment) - return comment -end - ----@param bufnr integer ----@param start_line integer ----@param end_line integer ----@param start_col? integer ----@param end_col? integer ----@return string[] -function M.text(bufnr, start_line, end_line, start_col, end_col) - if start_col and end_col then - return vim.api.nvim_buf_get_text(bufnr, start_line - 1, start_col, end_line - 1, end_col, {}) - end - return vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) -end - ----@return ScratchCommentView[] -function M.anchored() - local views = {} - for _, comment in ipairs(items) do - local start_line, end_line, start_col, end_col = render.range(comment) - if start_line and end_line then - local view = vim.deepcopy(comment) --[[@as ScratchCommentView]] - view.start_line, view.end_line = start_line, end_line - view.start_col, view.end_col = start_col, end_col - view.snippet = - table.concat(M.text(comment.bufnr, start_line, end_line, start_col, end_col), "\n") - table.insert(views, view) - end - end - table.sort(views, by_position) - return views -end - ----@return ScratchComment[] -function M.orphans() - return vim.tbl_filter(render.is_orphaned, items) -end - ----@param predicate fun(comment: ScratchCommentView): boolean ----@return ScratchCommentView[] -function M.find_all(predicate) - return vim.tbl_filter(predicate, M.anchored()) -end - ----@param predicate fun(comment: ScratchCommentView): boolean ----@return ScratchCommentView? -function M.find(predicate) - return M.find_all(predicate)[1] -end - ----@param id string ----@param fields table ----@return ScratchComment? -function M.update(id, fields) - for _, comment in ipairs(items) do - if comment.id == id then - for key, value in pairs(fields) do - comment[key] = value - end - return comment - end - end - return nil -end - ----@return ScratchComment[] -function M.all() - return items -end - ----@param bufnr integer ----@return ScratchComment[] -function M.in_buffer(bufnr) - return vim.tbl_filter(function(comment) - return comment.bufnr == bufnr - end, items) -end - -function M.clear() - items = {} -end - ----@param ids string[] ----@return ScratchComment[] removed -function M.remove_ids(ids) - local idset = {} - for _, id in ipairs(ids) do - idset[id] = true - end - - local kept = {} - local removed = {} - for _, comment in ipairs(items) do - if idset[comment.id] then - table.insert(removed, comment) - else - table.insert(kept, comment) - end - end - - items = kept - return removed -end - ----@param bufnr integer ----@return ScratchComment[] removed -function M.remove_buffer(bufnr) - local kept = {} - local removed = {} - for _, comment in ipairs(items) do - if comment.bufnr == bufnr then - table.insert(removed, comment) - else - table.insert(kept, comment) - end - end - items = kept - return removed -end - -return M diff --git a/lua/scratch_comments/ui/notify.lua b/lua/scratch_comments/ui/notify.lua new file mode 100644 index 0000000..d882008 --- /dev/null +++ b/lua/scratch_comments/ui/notify.lua @@ -0,0 +1,27 @@ +local M = {} + +---@param message string +---@param level integer +---@param opts table +local function send(message, level, opts) + vim.schedule(function() + vim.notify(message, level, vim.tbl_extend("keep", opts, { title = "scratch-comments" })) + end) +end + +---@param message string +function M.info(message) + send(message, vim.log.levels.INFO, {}) +end + +---@param message string +function M.warn(message) + send(message, vim.log.levels.WARN, { timeout = 10000 }) +end + +---@param message string +function M.error(message) + send(message, vim.log.levels.ERROR, { timeout = 10000 }) +end + +return M diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua new file mode 100644 index 0000000..cd1b104 --- /dev/null +++ b/lua/scratch_comments/ui/quickfix.lua @@ -0,0 +1,42 @@ +local notify = require("scratch_comments.ui.notify") + +local M = {} + +---@param text string +---@return string +local function summary(text) + return vim.split(text, "\n")[1] +end + +-- Quickfix rather than a picker: any quickfix front-end (Trouble, Snacks, +-- Telescope, nvim-bqf) can display it. +---@param views ScratchCommentView[] +---@param orphans ScratchComment[] +function M.list(views, orphans) + if #views + #orphans == 0 then + notify.info("No comments") + return + end + + local entries = vim.tbl_map(function(comment) + return { + filename = comment.file_path, + lnum = comment.start_line, + end_lnum = comment.end_line, + col = comment.start_col and comment.start_col + 1, + end_col = comment.end_col and comment.end_col + 1, + text = summary(comment.comment), + } + end, views) + for _, comment in ipairs(orphans) do + table.insert( + entries, + { filename = comment.file_path, text = "[orphaned] " .. summary(comment.comment) } + ) + end + + vim.fn.setqflist({}, " ", { title = "Comments", items = entries }) + vim.cmd.copen() +end + +return M diff --git a/lua/scratch_comments/ui/signs.lua b/lua/scratch_comments/ui/signs.lua new file mode 100644 index 0000000..f79fc1b --- /dev/null +++ b/lua/scratch_comments/ui/signs.lua @@ -0,0 +1,67 @@ +local anchors = require("scratch_comments.model.anchors") + +local M = {} + +local namespace = vim.api.nvim_create_namespace("scratch_comments_signs") +local group = "ScratchCommentSign" +local signs = { single = "│", first = "╭", middle = "│", last = "╰" } +local visible = true + +vim.api.nvim_set_hl(0, group, { link = "Todo", default = true }) + +---@return boolean +function M.is_visible() + return visible +end + +---@param on boolean +function M.set_visible(on) + visible = on +end + +---@param bufnr integer +---@param text string +---@param start_line integer +---@param end_line? integer +local function sign(bufnr, text, start_line, end_line) + vim.api.nvim_buf_set_extmark(bufnr, namespace, start_line - 1, 0, { + end_row = (end_line or start_line) - 1, + sign_text = text, + sign_hl_group = group, + }) +end + +-- Signs are drawn from the anchors rather than stored on them: a mark shows +-- its sign on every row it touches, and an anchor also touches the line after +-- its range. +---@param bufnr integer +---@param comments ScratchComment[] +function M.draw(bufnr, comments) + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) + if not visible then + return + end + for _, comment in ipairs(comments) do + local start_line, end_line = anchors.range(comment) + if start_line and end_line then + if start_line == end_line then + sign(bufnr, signs.single, start_line) + else + sign(bufnr, signs.first, start_line) + if end_line - start_line > 1 then + sign(bufnr, signs.middle, start_line + 1, end_line - 1) + end + sign(bufnr, signs.last, end_line) + end + end + end +end + +---@param bufnr integer +function M.clear_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) + end +end + +return M diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui/window.lua similarity index 58% rename from lua/scratch_comments/ui.lua rename to lua/scratch_comments/ui/window.lua index a798846..4f37ef9 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui/window.lua @@ -1,3 +1,5 @@ +local location = require("scratch_comments.location") + local M = {} local mode_names = { @@ -10,39 +12,15 @@ local mode_names = { R = "REPLACE", } -local function level(name) - return vim.log.levels[(name or "info"):upper()] or vim.log.levels.INFO -end +---@class ScratchCommentWindow +---@field id? string unset until a new comment is saved +---@field bufnr integer +---@field line integer +---@field source_win integer +---@field comment_win? integer ----@param message string ----@param kind? "info"|"warn"|"error" -function M.notify(message, kind) - vim.schedule(function() - local opts = { title = "scratch-comments" } - if kind == "error" or kind == "warn" then - opts.timeout = 10000 - end - vim.notify(message, level(kind), opts) - end) -end - ----@param text string ----@return string -function M.summary(text) - return vim.split(text, "\n")[1] -end - --- Cuts from the left so the line range and the file extension stay visible. ----@param text string ----@param width integer ----@return string -local function fit(text, width) - local start = 0 - while vim.fn.strdisplaywidth(vim.fn.strcharpart(text, start)) > width do - start = start + 1 - end - return start == 0 and text or "…" .. vim.fn.strcharpart(text, start + 1) -end +---@type ScratchCommentWindow? +local current ---@param lines string[] ---@param filetype string @@ -61,13 +39,12 @@ end ---@field context string[] ---@field filetype string ---@field comment string ----@field on_save? fun(text: string) ----@field on_close? fun() +---@field on_save fun(text: string) ---@field comment_title? string ---@param frame ScratchFrame ---@return integer comment_win -function M.open_frame(frame) +local function open_frame(frame) local comment_lines = vim.split(frame.comment, "\n") local width = math.min(80, vim.o.columns - 4) local context_height = math.min(#frame.context, math.floor(vim.o.lines * 0.4)) @@ -86,7 +63,7 @@ function M.open_frame(frame) width = width, height = context_height, border = border, - title = " " .. fit(frame.title, width - 2) .. " ", + title = " " .. location.fit(frame.title, width - 2) .. " ", title_pos = "center", style = "minimal", }) @@ -99,14 +76,14 @@ function M.open_frame(frame) width = width, height = comment_height, border = border, - title = " " .. (frame.comment_title or "comment") .. " ", + title = " comment ", title_pos = "center", style = "minimal", }) vim.wo[comment_win].wrap = true vim.wo[comment_win].linebreak = true for _, win in ipairs({ context_win, comment_win }) do - vim.wo[win].winhighlight = "NormalFloat:Normal" + vim.wo[win].winhighlight = "NormalFloat:Normal,LineNr:Normal" vim.wo[win].statuscolumn = " " end @@ -115,8 +92,8 @@ function M.open_frame(frame) once = true, callback = function() pcall(vim.api.nvim_win_close, context_win, true) - if frame.on_close then - frame.on_close() + if current and current.comment_win == comment_win then + current = nil end end, }) @@ -129,19 +106,14 @@ function M.open_frame(frame) end, }) - if not frame.on_save then - vim.bo[comment_buf].modifiable = false - return comment_win - end - local function show_mode() if vim.api.nvim_win_is_valid(comment_win) then local mode = vim.api.nvim_get_mode().mode:sub(1, 1) local name = mode_names[mode] or "NORMAL" - vim.api.nvim_win_set_config( - comment_win, - { title = " comment [" .. name .. "] ", title_pos = "center" } - ) + vim.api.nvim_win_set_config(comment_win, { + title = " " .. (frame.comment_title or "comment") .. " [" .. name .. "] ", + title_pos = "center", + }) vim.cmd.redraw() end end @@ -167,44 +139,18 @@ function M.open_frame(frame) return comment_win end ----@param text string ----@param filetype string -function M.open_scratch(text, filetype) - vim.cmd.new() - vim.bo.buftype = "nofile" - vim.bo.bufhidden = "wipe" - vim.bo.swapfile = false - vim.bo.filetype = filetype - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(text, "\n")) +---@param frame ScratchFrame +---@param window ScratchCommentWindow +function M.open(frame, window) + window.comment_win = open_frame(frame) + current = window end --- Quickfix rather than a picker: any quickfix front-end (Trouble, Snacks, --- Telescope, nvim-bqf) can display it. ----@param items ScratchCommentView[] ----@param orphans ScratchComment[] -function M.list(items, orphans) - if #items + #orphans == 0 then - M.notify("No comments", "info") - return +---@return ScratchCommentWindow? window the open comment window, if any +function M.current() + if current and vim.api.nvim_win_is_valid(current.comment_win) then + return current end - - local entries = vim.tbl_map(function(comment) - return { - filename = comment.file_path, - lnum = comment.start_line, - end_lnum = comment.end_line, - text = M.summary(comment.comment), - } - end, items) - for _, comment in ipairs(orphans) do - table.insert( - entries, - { filename = comment.file_path, text = "[orphaned] " .. M.summary(comment.comment) } - ) - end - - vim.fn.setqflist({}, " ", { title = "Comments", items = entries }) - vim.cmd.copen() end return M diff --git a/plugin/scratch-comments.lua b/plugin/scratch-comments.lua new file mode 100644 index 0000000..3c07cac --- /dev/null +++ b/plugin/scratch-comments.lua @@ -0,0 +1,68 @@ +if vim.g.loaded_scratch_comments then + return +end +vim.g.loaded_scratch_comments = true + +local function scratch() + return require("scratch_comments") +end + +local command = vim.api.nvim_create_user_command + +command("Comment", function(ctx) + require("scratch_comments.actions").comment(ctx.line1, ctx.line2, ctx.range == 2) +end, { range = true, desc = "Comment on the current line or range" }) + +command("CommentShow", function() + scratch().show() +end, { desc = "Show the comments at the cursor" }) + +command("CommentDelete", function() + scratch().delete() +end, { desc = "Delete the comment at the cursor" }) + +command("CommentNext", function() + scratch().next() +end, { desc = "Go to the next comment" }) + +command("CommentPrev", function() + scratch().prev() +end, { desc = "Go to the previous comment" }) + +command("CommentList", function() + scratch().list() +end, { desc = "Browse comments" }) + +command("CommentExport", function(ctx) + scratch().export(ctx.fargs[1], ctx.bang) +end, { + nargs = "?", + bang = true, + complete = function() + return require("scratch_comments.export").names() + end, + desc = "Copy comments to the clipboard; with ! open them in a scratch buffer", +}) + +command("CommentToggle", function(ctx) + local choice = ctx.fargs[1] + if choice ~= nil and choice ~= "on" and choice ~= "off" then + require("scratch_comments.ui.notify").error("Usage: :CommentToggle [on|off]") + return + end + if choice == nil then + scratch().toggle() + else + scratch().toggle(choice == "on") + end +end, { + nargs = "?", + complete = function() + return { "on", "off" } + end, + desc = "Show or hide the comment signs", +}) + +command("CommentClear", function() + scratch().clear() +end, { desc = "Clear all comments" }) diff --git a/tests/export.lua b/tests/export.lua deleted file mode 100644 index 80ce738..0000000 --- a/tests/export.lua +++ /dev/null @@ -1,86 +0,0 @@ -local scratch = require("scratch_comments") - -local function fail(message) - error(message, 2) -end - -local function assert_equal(actual, expected, message) - if actual ~= expected then - fail( - (message or "values differ") - .. ": expected " - .. vim.inspect(expected) - .. ", got " - .. vim.inspect(actual) - ) - end -end - -local function assert_contains(haystack, needle, message) - if not haystack:find(needle, 1, true) then - fail((message or "missing text") .. ": " .. needle) - end -end - -local function write(comment) - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(comment, "\n")) - vim.cmd("wq") -end - -local copied ----@diagnostic disable-next-line: duplicate-set-field -- test fake -require("scratch_comments.export").copy = function(text) - copied = text - return true -end - -vim.cmd.enew() -vim.cmd.file("export-fixture.lua") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "local a = 1", "local b = 2", "local c = 3" }) -local source = vim.api.nvim_get_current_buf() - -vim.cmd("3Comment") -write("on c") -vim.cmd("1Comment") -write("on a") - -local data = vim.json.decode(scratch.render("json")) -assert_equal(#data.comments, 2, "json must list every comment") -assert_equal(data.comments[1].comment, "on a", "comments must be ordered by position") -assert_equal(data.comments[1].start_line, 1, "json must carry the line range") -assert_equal(data.comments[1].snippet, "local a = 1", "json must carry the commented text") -assert_equal(#data.orphaned, 0, "json must always carry the orphaned list") -assert_contains(scratch.render("json"), '"orphaned": []', "an empty list must encode as an array") - -vim.cmd("CommentExport json") -assert_equal( - vim.json.decode(copied).comments[2].comment, - "on c", - ":CommentExport json must copy json" -) - -copied = nil -vim.cmd("CommentExport bogus") -assert_equal(copied, nil, ":CommentExport must not copy an unknown format") - -vim.cmd("CommentExport!") -assert_equal(vim.bo.buftype, "nofile", ":CommentExport! must open a scratch buffer") -assert_equal(vim.bo.filetype, "markdown", ":CommentExport! must default to markdown") -assert_contains( - table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n"), - "on a", - ":CommentExport! must fill the scratch buffer" -) -vim.cmd("close") - -vim.cmd("CommentExport! json") -assert_equal(vim.bo.filetype, "json", ":CommentExport! json must open json") -vim.cmd("close") - -assert_equal( - vim.api.nvim_get_current_buf(), - source, - "closing the scratch window returns to the file" -) - -print("export: ok") diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua new file mode 100644 index 0000000..c16760d --- /dev/null +++ b/tests/features/anchoring_spec.lua @@ -0,0 +1,155 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local views = require("scratch_comments.model.views") +local store = require("scratch_comments.model.store") +local expect = MiniTest.expect + +local function only() + return views.anchored()[1] +end + +before_each(function() + helpers.reset() +end) + +describe("a line comment", function() + before_each(function() + helpers.buffer("anchoring.md", { "one", "two", "the quick brown fox", "four", "five" }) + vim.cmd("3Comment") + helpers.write("on the fox") + end) + + it("follows its text when lines are inserted above", function() + vim.api.nvim_buf_set_lines(0, 0, 0, false, { "new a", "new b", "new c" }) + expect.equality(only().start_line, 6) + expect.equality(only().end_line, 6) + expect.equality(scratch.render():match("%- (line %d+)"), "line 6") + end) + + it("follows its text when lines above are deleted", function() + vim.api.nvim_buf_set_lines(0, 0, 2, false, {}) + expect.equality(only().start_line, 1) + end) + + it("reads its snippet live", function() + vim.api.nvim_buf_set_text(0, 2, 4, 2, 9, { "slow" }) + expect.equality(only().snippet, "the slow brown fox") + end) + + it("stays anchored when part of the line is edited", function() + vim.api.nvim_win_set_cursor(0, { 3, 4 }) + vim.cmd("normal! ciwslow") + expect.equality(#views.anchored(), 1) + end) + + it("stays anchored when the whole line is rewritten", function() + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + vim.cmd("normal! ccthe slow fox") + expect.equality(#views.orphans(), 0) + end) +end) + +describe("a range comment", function() + before_each(function() + helpers.buffer("anchoring.md", { "a", "b", "c", "d", "e" }) + vim.cmd("2,4Comment") + helpers.write("on b through d") + end) + + it("covers its lines", function() + expect.equality(only().start_line, 2) + expect.equality(only().end_line, 4) + end) + + it("grows when lines are inserted inside it", function() + vim.api.nvim_buf_set_lines(0, 2, 2, false, { "inside" }) + expect.equality(only().start_line, 2) + expect.equality(only().end_line, 5) + end) + + it("shrinks when its last line is deleted, without taking in the next line", function() + vim.api.nvim_buf_set_lines(0, 3, 4, false, {}) + expect.equality(only().end_line, 3) + expect.equality(only().snippet, "b\nc") + end) + + it("stays anchored when one of its lines is rewritten", function() + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + vim.cmd("normal! ccrewritten") + expect.equality(#views.orphans(), 0) + end) + + it("shrinks when a middle line is deleted", function() + vim.cmd("3d") + expect.equality(#views.orphans(), 0) + expect.equality(only().end_line, 3) + end) +end) + +describe("an orphan", function() + before_each(function() + helpers.buffer("anchoring.md", { "one", "two", "the quick fox", "four", "five" }) + vim.cmd("3Comment") + helpers.write("on the fox") + helpers.undo_break() + vim.cmd("3d") + helpers.text_changed() + end) + + it("is what a comment becomes when its lines are deleted", function() + expect.equality(#views.anchored(), 0) + expect.equality(#views.orphans(), 1) + end) + + it("has no sign", function() + local namespace = vim.api.nvim_get_namespaces().scratch_comments_signs + expect.equality(#vim.api.nvim_buf_get_extmarks(0, namespace, 0, -1, {}), 0) + end) + + it("is exported in its own section, with its text", function() + local markdown = scratch.render() + expect.no_equality(markdown:find("## Orphaned", 1, true), nil) + expect.no_equality(markdown:find("on the fox", 1, true), nil) + end) + + it("is in the list, marked", function() + vim.cmd("CommentList") + local entries = vim.fn.getqflist() + expect.equality(#entries, 1) + expect.no_equality(entries[1].text:find("[orphaned]", 1, true), nil) + vim.cmd("cclose") + end) + + it("is anchored again by undo", function() + helpers.undo_break() + vim.cmd("silent undo") + expect.equality(#views.orphans(), 0) + expect.equality(only().start_line, 3) + end) + + it("is offered by :CommentDelete on a line without comments", function() + helpers.pick(1) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("CommentDelete") + expect.equality(#helpers.offered, 1) + expect.equality(#views.orphans(), 0) + end) +end) + +describe("a file's comments", function() + before_each(function() + vim.cmd.edit("tests/fixtures/lifecycle.txt") + vim.cmd("Comment") + helpers.write("on the first line") + end) + + it("survive :e!", function() + vim.cmd("edit!") + expect.equality(#views.anchored(), 1) + end) + + it("go with :bd", function() + vim.cmd("bdelete") + expect.equality(#store.all(), 0) + end) +end) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua new file mode 100644 index 0000000..ecedf22 --- /dev/null +++ b/tests/features/commenting_spec.lua @@ -0,0 +1,121 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local views = require("scratch_comments.model.views") +local store = require("scratch_comments.model.store") +local expect = MiniTest.expect + +before_each(function() + helpers.reset() + helpers.buffer("notes.md", { "one", "two", "three", "four", "five", "six" }) +end) + +describe(":Comment", function() + it("comments on the cursor line", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("Comment") + helpers.write("first comment") + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].start_line, 2) + expect.equality(views.anchored()[1].comment, "first comment") + end) + + it("on the same line edits that comment instead of adding one", function() + vim.cmd("Comment") + helpers.write("first comment") + vim.cmd("Comment") + expect.equality(helpers.text(), "first comment") + helpers.write("edited comment") + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].comment, "edited comment") + end) + + it("on a range adds a separate comment over those lines", function() + vim.cmd("1Comment") + helpers.write("on one") + vim.cmd("1,3Comment") + helpers.write("on one to three") + expect.equality(#store.all(), 2) + expect.equality(views.anchored()[2].end_line, 3) + end) +end) + +describe("the comment window", function() + it("titles a new comment with the mode as it changes", function() + vim.cmd("Comment") + local titles = {} + vim.api.nvim_create_autocmd("ModeChanged", { + buffer = 0, + callback = function() + table.insert(titles, helpers.title(0)) + end, + }) + vim.cmd("normal! ihello") + expect.equality(table.concat(titles, ","), " comment [INSERT] , comment [NORMAL] ") + end) + + it("keeps one comment when saved twice", function() + vim.cmd("Comment") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first line", "second line" }) + vim.cmd("w") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first line", "second line, revised" }) + vim.cmd("wq") + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "first line\nsecond line, revised") + end) + + it("adds nothing when discarded with :q!", function() + vim.cmd("Comment") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "hello" }) + vim.cmd("q!") + expect.equality(#store.all(), 0) + expect.equality(#helpers.floats(), 0) + end) + + it("closes with :q when untouched", function() + vim.cmd("Comment") + vim.cmd("quit") + expect.equality(#helpers.floats(), 0) + end) + + it("stays open on :q with unsaved text", function() + vim.cmd("Comment") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + pcall(vim.cmd.quit) + expect.equality(#helpers.floats(), 2) + end) + + it("keeps an unsaved comment open when the lines pane is closed", function() + vim.cmd("Comment") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.cmd("wincmd w") + pcall(vim.cmd.quit) + expect.equality(#helpers.floats(), 1) + vim.api.nvim_set_current_win(helpers.floats()[1]) + vim.cmd("q!") + expect.equality(#helpers.floats(), 0) + expect.equality(#store.all(), 0) + end) +end) + +describe("scratch.add()", function() + it("defaults to the cursor line, or takes a range", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + scratch.add() + helpers.write("on the cursor line") + scratch.add(4, 5) + helpers.write("on lines 4-5") + local ranges = vim.tbl_map(function(view) + return view.start_line .. "-" .. view.end_line + end, views.anchored()) + expect.equality(table.concat(ranges, ","), "2-2,4-5") + end) +end) + +describe("in a buffer with no name", function() + it("warns and opens nothing", function() + vim.cmd.enew() + vim.cmd("Comment") + expect.equality(#helpers.floats(), 0) + expect.equality(helpers.notified(), { "Save the buffer before commenting on it" }) + end) +end) diff --git a/tests/features/health_spec.lua b/tests/features/health_spec.lua new file mode 100644 index 0000000..9f8db3b --- /dev/null +++ b/tests/features/health_spec.lua @@ -0,0 +1,15 @@ +local helpers = require("helpers") +local expect = MiniTest.expect + +before_each(function() + helpers.reset() +end) + +describe(":checkhealth scratch_comments", function() + it("reports the Neovim version and the clipboard", function() + vim.cmd("silent checkhealth scratch_comments") + local report = helpers.text() + expect.no_equality(report:find("Neovim version is supported", 1, true), nil) + expect.no_equality(report:find("lipboard", 1, true), nil) + end) +end) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua new file mode 100644 index 0000000..3a1af6c --- /dev/null +++ b/tests/features/list_export_spec.lua @@ -0,0 +1,146 @@ +local clipboard = require("scratch_comments.export.clipboard") +local helpers = require("helpers") +local scratch = require("scratch_comments") +local expect = MiniTest.expect + +local copy = clipboard.copy +local copied +local source + +before_each(function() + helpers.reset() + copied = nil + ---@diagnostic disable-next-line: duplicate-set-field -- test fake + clipboard.copy = function(text) + copied = text + return true + end + source = helpers.buffer("export-fixture.lua", { "local a = 1", "local b = 2", "local c = 3" }) + vim.cmd("3Comment") + helpers.write("on c") + vim.cmd("1Comment") + helpers.write("on a") +end) + +after_each(function() + clipboard.copy = copy +end) + +describe("json", function() + it("lists every comment in position order, with its lines and text", function() + local data = vim.json.decode(scratch.render("json")) + expect.equality(#data.comments, 2) + expect.equality(data.comments[1].comment, "on a") + expect.equality(data.comments[1].start_line, 1) + expect.equality(data.comments[1].snippet, "local a = 1") + end) + + it("always has an orphaned list, encoded as an array", function() + expect.equality(#vim.json.decode(scratch.render("json")).orphaned, 0) + expect.no_equality(scratch.render("json"):find('"orphaned": []', 1, true), nil) + end) +end) + +describe(":CommentExport", function() + it("copies markdown by default, keeping the comments", function() + vim.cmd("CommentExport") + expect.no_equality(copied:find("## export-fixture.lua", 1, true), nil) + expect.no_equality(copied:find("on a", 1, true), nil) + expect.equality(#vim.json.decode(scratch.render("json")).comments, 2) + end) + + it("copies json", function() + vim.cmd("CommentExport json") + expect.equality(vim.json.decode(copied).comments[2].comment, "on c") + end) + + it("says so when the clipboard can't be written", function() + ---@diagnostic disable-next-line: duplicate-set-field -- test fake + clipboard.copy = function() + return false + end + vim.cmd("CommentExport") + expect.equality( + helpers.notified()[#helpers.notified()], + "Could not copy to the clipboard; no provider configured?" + ) + end) + + it("copies nothing for an unknown format", function() + vim.cmd("CommentExport bogus") + expect.equality(copied, nil) + end) +end) + +describe(":CommentExport!", function() + it("opens markdown in a scratch buffer by default", function() + vim.cmd("CommentExport!") + expect.equality(vim.bo.buftype, "nofile") + expect.equality(vim.bo.filetype, "markdown") + expect.no_equality(helpers.text():find("on a", 1, true), nil) + end) + + it("opens json when asked", function() + vim.cmd("CommentExport! json") + expect.equality(vim.bo.filetype, "json") + end) + + it("returns to the file when the scratch window closes", function() + vim.cmd("CommentExport!") + vim.cmd("close") + expect.equality(vim.api.nvim_get_current_buf(), source) + end) +end) + +describe(":CommentList", function() + it("fills a titled quickfix list with one entry per comment", function() + vim.cmd("CommentList") + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + expect.equality(qf.title, "Comments") + expect.equality(#qf.items, 2) + expect.equality(qf.items[1].text, "on a") + vim.cmd("cclose") + end) +end) + +describe("markdown", function() + it("names a range's lines", function() + vim.cmd("1,2Comment") + helpers.write("on a and b") + expect.no_equality(scratch.render():find("lines 1–2", 1, true), nil) + end) +end) + +describe("comments in several files", function() + before_each(function() + helpers.buffer("a-first.md", { "a" }) + vim.cmd("Comment") + helpers.write("in a-first") + end) + + it("are listed by file, then line", function() + vim.cmd("CommentList") + local files = vim.tbl_map(function(item) + return vim.fn.fnamemodify(vim.fn.bufname(item.bufnr), ":t") + end, vim.fn.getqflist()) + expect.equality(table.concat(files, ","), "a-first.md,export-fixture.lua,export-fixture.lua") + vim.cmd("cclose") + end) + + it("are exported by file, then line", function() + local markdown = scratch.render() + expect.equality( + markdown:find("## a-first.md", 1, true) < markdown:find("## export-fixture.lua", 1, true), + true + ) + end) +end) + +describe("a snippet containing a code fence", function() + it("is fenced with four backticks", function() + helpers.buffer("fence.md", { "```lua", "x = 1", "```" }) + vim.cmd("1,3Comment") + helpers.write("on a fence") + expect.no_equality(scratch.render():find("````md\n```lua", 1, true), nil) + end) +end) diff --git a/tests/features/navigation_spec.lua b/tests/features/navigation_spec.lua new file mode 100644 index 0000000..830c2f4 --- /dev/null +++ b/tests/features/navigation_spec.lua @@ -0,0 +1,134 @@ +local helpers = require("helpers") +local expect = MiniTest.expect + +local file_win + +local function cursor_line() + return vim.api.nvim_win_get_cursor(file_win)[1] +end + +---@param command string +---@return string lines the cursor visited, four steps +local function walk(command) + local lines = {} + for _ = 1, 4 do + vim.cmd(command) + table.insert(lines, cursor_line()) + end + return table.concat(lines, ",") +end + +before_each(function() + helpers.reset() + helpers.buffer("notes.md", { "1", "2", "3", "4", "5", "6", "7", "8" }) + file_win = vim.api.nvim_get_current_win() + vim.cmd("2Comment") + helpers.write("on two") + vim.cmd("4,5Comment") + helpers.write("on four and five") + vim.cmd("4Comment") + helpers.write("also on four") + vim.cmd("7Comment") + helpers.write("on seven") + vim.api.nvim_win_set_cursor(0, { 1, 0 }) +end) + +describe("without a comment open", function() + it(":CommentNext visits each start line once, then wraps", function() + expect.equality(walk("CommentNext"), "2,4,7,2") + end) + + it(":CommentPrev goes back, then wraps", function() + expect.equality(walk("CommentPrev"), "7,4,2,7") + end) + + it("jumps can be undone with ", function() + vim.cmd("CommentNext") + vim.cmd([[execute "normal! \"]]) + expect.equality(cursor_line(), 1) + end) + + it("only moves the cursor", function() + vim.cmd("CommentNext") + expect.equality(#helpers.floats(), 0) + end) +end) + +describe("with a comment open", function() + before_each(function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("CommentShow") + end) + + it("counts the file's comments", function() + expect.equality(helpers.title(0), " comment (1 of 4) [NORMAL] ") + end) + + it(":CommentNext shows each comment in turn, then wraps, the cursor following", function() + local shown, lines = {}, {} + for _ = 1, 4 do + vim.cmd("CommentNext") + table.insert(shown, helpers.text()) + table.insert(lines, cursor_line()) + end + expect.equality(table.concat(shown, ","), "also on four,on four and five,on seven,on two") + expect.equality(table.concat(lines, ","), "4,4,7,2") + expect.equality(#helpers.floats(), 2) + expect.equality(helpers.title(0), " comment (1 of 4) [NORMAL] ") + end) + + it(":CommentPrev goes back", function() + vim.cmd("CommentPrev") + expect.equality(helpers.text(), "on seven") + end) + + it("won't move while the comment has unsaved changes", function() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.cmd("CommentNext") + expect.equality(helpers.text(), "unsaved") + end) +end) + +describe("from a new comment", function() + it("moves to the next comment after its line", function() + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + vim.cmd("Comment") + vim.cmd("stopinsert") + vim.cmd("CommentNext") + expect.equality(helpers.text(), "also on four") + end) +end) + +describe("with comments in another file", function() + it("stays in the current file", function() + local other = helpers.buffer("other.md", { "1", "2", "3" }) + vim.cmd("3Comment") + helpers.write("in the other file") + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("CommentNext") + vim.cmd("CommentNext") + expect.equality(vim.api.nvim_get_current_buf(), other) + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) + end) +end) + +describe("when things change under an open comment", function() + it("moves on from its line when its comment was deleted", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("CommentShow") + vim.api.nvim_set_current_win(file_win) + vim.cmd("CommentDelete") + vim.cmd("CommentNext") + expect.equality(helpers.text(), "also on four") + end) + + it("still cycles when the window it came from is closed", function() + vim.cmd("split") + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + local split = vim.api.nvim_get_current_win() + vim.cmd("CommentShow") + vim.api.nvim_win_close(split, true) + vim.cmd("CommentNext") + expect.equality(helpers.text(), "also on four") + end) +end) diff --git a/tests/features/plugin_spec.lua b/tests/features/plugin_spec.lua new file mode 100644 index 0000000..662cd1d --- /dev/null +++ b/tests/features/plugin_spec.lua @@ -0,0 +1,36 @@ +local expect = MiniTest.expect + +---@param lua string run in a fresh Neovim with only this plugin +---@return string output +local function fresh(lua) + local result = vim + .system({ + vim.v.progpath, + "--headless", + "--clean", + "--cmd", + "set runtimepath^=" .. vim.fn.getcwd(), + "-c", + "lua " .. lua, + "-c", + "qa!", + }, { text = true }) + :wait() + return result.stdout +end + +describe("the plugin", function() + it("defines its commands without setup()", function() + expect.equality(fresh("io.write(vim.fn.exists(':CommentShow'))"), "2") + end) + + it("loads nothing until a command runs", function() + local loaded = "io.write(tostring(package.loaded['scratch_comments.actions'] ~= nil))" + expect.equality(fresh(loaded), "false") + expect.equality(fresh("vim.cmd('CommentList') " .. loaded), "true") + end) + + it("still accepts setup()", function() + expect.equality(fresh("require('scratch_comments').setup() io.write('ok')"), "ok") + end) +end) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua new file mode 100644 index 0000000..a44fdf1 --- /dev/null +++ b/tests/features/show_delete_spec.lua @@ -0,0 +1,81 @@ +local helpers = require("helpers") +local views = require("scratch_comments.model.views") +local store = require("scratch_comments.model.store") +local expect = MiniTest.expect + +before_each(function() + helpers.reset() + helpers.buffer("notes.md", { "one", "two", "three", "four" }) + vim.cmd("1Comment") + helpers.write("on one") + vim.cmd("1,3Comment") + helpers.write("on one to three") + vim.api.nvim_win_set_cursor(0, { 1, 0 }) +end) + +describe(":CommentShow", function() + it("asks which comment when several cover the line", function() + helpers.pick(2) + vim.cmd("CommentShow") + expect.equality(#helpers.offered, 2) + expect.equality(helpers.text(), "on one to three") + end) + + it("names each comment by its location and first line", function() + helpers.pick(0) + vim.cmd("CommentShow") + expect.equality(helpers.labels, { "line 1: on one", "lines 1–3: on one to three" }) + end) + + it("saves edits to the comment it shows", function() + helpers.pick(2) + vim.cmd("CommentShow") + helpers.write("picked the range") + expect.equality(#store.all(), 2) + expect.equality(views.anchored()[2].comment, "picked the range") + end) + + it("opens nothing when the picker is cancelled", function() + helpers.pick(0) + vim.cmd("CommentShow") + expect.equality(#helpers.floats(), 0) + end) + + it("closes with :q", function() + helpers.pick(2) + vim.cmd("CommentShow") + vim.cmd("quit") + expect.equality(#helpers.floats(), 0) + end) +end) + +describe(":CommentDelete", function() + it("deletes the picked comment", function() + helpers.pick(1) + vim.cmd("CommentDelete") + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].comment, "on one to three") + end) + + it("deletes nothing when the picker is cancelled", function() + helpers.pick(0) + vim.cmd("CommentDelete") + expect.equality(#store.all(), 2) + end) + + it("says so on a line with no comment and no orphans", function() + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + vim.cmd("CommentDelete") + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "No comment at cursor") + end) +end) + +describe(":CommentClear", function() + it("removes every comment and its marks", function() + vim.cmd("CommentClear") + local namespace = vim.api.nvim_get_namespaces().scratch_comments + expect.equality(#store.all(), 0) + expect.equality(#vim.api.nvim_buf_get_extmarks(0, namespace, 0, -1, {}), 0) + end) +end) diff --git a/tests/features/signs_spec.lua b/tests/features/signs_spec.lua new file mode 100644 index 0000000..9514543 --- /dev/null +++ b/tests/features/signs_spec.lua @@ -0,0 +1,69 @@ +local helpers = require("helpers") +local expect = MiniTest.expect + +before_each(function() + helpers.reset() + helpers.buffer("notes.md", { "one", "two", "three", "four", "five", "six" }) +end) + +describe("signs", function() + it("mark a one-line comment with one sign", function() + vim.cmd("1Comment") + helpers.write("on one") + expect.equality(helpers.sign_at(1), "│") + end) + + it("mark a range from its first line to its last", function() + vim.cmd("2,5Comment") + helpers.write("on four lines") + expect.equality(helpers.sign_at(1), nil) + expect.equality(helpers.sign_at(2), "╭") + expect.equality(helpers.sign_at(3), "│") + expect.equality(helpers.sign_at(4), "│") + expect.equality(helpers.sign_at(5), "╰") + expect.equality(helpers.sign_at(6), nil) + end) + + it("go with :CommentClear", function() + vim.cmd("1Comment") + helpers.write("on one") + vim.cmd("CommentClear") + expect.equality(helpers.sign_at(1), nil) + end) +end) + +describe(":CommentToggle", function() + before_each(function() + vim.cmd("1Comment") + helpers.write("on one") + end) + + it("off hides signs, on shows them", function() + vim.cmd("CommentToggle off") + expect.equality(helpers.sign_at(1), nil) + vim.cmd("CommentToggle on") + expect.equality(helpers.sign_at(1), "│") + end) + + it("with no argument flips them", function() + vim.cmd("CommentToggle") + expect.equality(helpers.sign_at(1), nil) + vim.cmd("CommentToggle") + expect.equality(helpers.sign_at(1), "│") + end) + + it("with anything but on or off shows its usage and changes nothing", function() + vim.cmd("CommentToggle bogus") + expect.equality(helpers.sign_at(1), "│") + expect.equality(helpers.notified()[#helpers.notified()], "Usage: :CommentToggle [on|off]") + end) + + it("keeps comments added while hidden hidden, until shown", function() + vim.cmd("CommentToggle off") + vim.cmd("2Comment") + helpers.write("added while hidden") + expect.equality(helpers.sign_at(2), nil) + vim.cmd("CommentToggle on") + expect.equality(helpers.sign_at(2), "│") + end) +end) diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua new file mode 100644 index 0000000..2eb3ca8 --- /dev/null +++ b/tests/features/spans_spec.lua @@ -0,0 +1,155 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local views = require("scratch_comments.model.views") +local expect = MiniTest.expect + +---@param keys string a visual selection, such as "viw" +local function select(keys) + vim.cmd("normal! " .. keys .. "\27") +end + +local function lines_title() + return helpers.title((helpers.panes())) +end + +before_each(function() + helpers.reset() + helpers.buffer("spans.md", { "the quick brown fox", "café → bar", "one two", "three four" }) +end) + +describe("a charwise selection", function() + before_each(function() + vim.api.nvim_win_set_cursor(0, { 1, 4 }) + select("viw") + vim.cmd("'<,'>Comment") + end) + + it("names its columns in the title", function() + expect.equality(lines_title(), " spans.md [line 1, col 5–9] ") + end) + + it("makes a span over exactly the selected text", function() + helpers.write("on quick") + local quick = views.anchored()[1] + expect.equality(quick.snippet, "quick") + expect.equality(quick.start_col, 4) + expect.equality(quick.end_col, 9) + end) + + it("is separate from a comment on the whole line", function() + helpers.write("on quick") + vim.cmd("Comment") + helpers.write("on the line") + vim.cmd("1Comment") + expect.equality(helpers.text(), "on the line") + vim.cmd("quit") + expect.equality(#views.anchored(), 2) + end) +end) + +describe("a span", function() + local quick + + before_each(function() + vim.api.nvim_win_set_cursor(0, { 1, 4 }) + select("viw") + vim.cmd("'<,'>Comment") + helpers.write("on quick") + quick = views.anchored()[1] + end) + + local function current() + return vim.tbl_filter(function(view) + return view.id == quick.id + end, views.anchored())[1] + end + + it("follows its text", function() + vim.api.nvim_buf_set_text(0, 0, 4, 0, 4, { "very " }) + expect.equality(current().snippet, "quick") + end) + + it("is orphaned when its text is replaced, and restored by undo", function() + helpers.undo_break() + vim.api.nvim_buf_set_text(0, 0, 4, 0, 9, { "slow" }) + expect.equality(current(), nil) + expect.equality(#views.orphans(), 1) + helpers.undo_break() + vim.cmd("silent undo") + expect.equality(current().snippet, "quick") + end) + + it("is listed in quickfix with its columns", function() + vim.cmd("CommentList") + local item = vim.fn.getqflist()[1] + expect.equality({ item.col, item.end_col }, { 5, 10 }) + vim.cmd("cclose") + end) + + it("exports 1-based columns to json, ending on the last byte", function() + local exported = vim.json.decode(scratch.render("json")).comments[1] + expect.equality(exported.start_col, 5) + expect.equality(exported.end_col, 9) + end) +end) + +describe("a selection", function() + it("keeps a multibyte character whole", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + select("v3l") + vim.cmd("'<,'>Comment") + helpers.write("on café") + expect.equality(views.anchored()[1].snippet, "café") + end) + + it("to $ ends at the end of the line", function() + vim.api.nvim_win_set_cursor(0, { 3, 4 }) + select("v$") + vim.cmd("'<,'>Comment") + helpers.write("to the end") + expect.equality(views.anchored()[1].snippet, "two") + end) + + it("can cross lines, titled with line:col", function() + vim.api.nvim_win_set_cursor(0, { 3, 4 }) + select("vj") + vim.cmd("'<,'>Comment") + expect.equality(lines_title(), " spans.md [lines 3:5–4:5] ") + helpers.write("across lines") + expect.equality(views.anchored()[1].snippet, "two\nthree") + end) + + it("that is linewise makes a line comment", function() + vim.api.nvim_win_set_cursor(0, { 3, 4 }) + select("V") + vim.cmd("'<,'>Comment") + expect.equality(lines_title(), " spans.md [line 3] ") + helpers.write("whole line") + expect.equality(views.anchored()[1].start_col, nil) + expect.equality(views.anchored()[1].snippet, "one two") + end) +end) + +describe("a long title", function() + local columns + + before_each(function() + columns = vim.o.columns + vim.cmd.file(("a-very-long-name"):rep(6) .. ".md") + vim.api.nvim_win_set_cursor(0, { 1, 4 }) + select("viw") + vim.o.columns = 60 + vim.cmd("'<,'>Comment") + end) + + after_each(function() + vim.o.columns = columns + end) + + it("fits the window, cut from the left with an ellipsis, keeping the range", function() + local title = lines_title() + expect.equality(vim.api.nvim_strwidth(title), 56) + expect.equality(title:sub(1, 4), " …") + expect.equality(vim.endswith(title, "name.md [line 1, col 5–9] "), true) + end) +end) diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua new file mode 100644 index 0000000..9d110a6 --- /dev/null +++ b/tests/features/window_spec.lua @@ -0,0 +1,87 @@ +local helpers = require("helpers") +local expect = MiniTest.expect + +local source + +before_each(function() + helpers.reset() + source = helpers.buffer("notes.md", { "one", "two", "three", "four" }) + vim.cmd("1,3Comment") + helpers.write("on one to three") + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("CommentShow") +end) + +describe("the comment window", function() + it("shows the commented lines above the comment", function() + local lines_win, comment_win = helpers.panes() + expect.equality(#helpers.floats(), 2) + expect.equality(helpers.text(vim.api.nvim_win_get_buf(comment_win)), "on one to three") + expect.equality(helpers.text(vim.api.nvim_win_get_buf(lines_win)), "one\ntwo\nthree") + expect.equality(helpers.text(source), "one\ntwo\nthree\nfour") + end) + + it("titles the lines pane with the file and lines", function() + expect.equality(helpers.title((helpers.panes())), " notes.md [lines 1–3] ") + end) + + it("titles the comment pane with the count and mode", function() + local _, comment_win = helpers.panes() + expect.equality(helpers.title(comment_win), " comment (1 of 1) [NORMAL] ") + end) + + it("shows the mode as it changes", function() + local titles = {} + vim.api.nvim_create_autocmd("ModeChanged", { + buffer = 0, + callback = function() + table.insert(titles, helpers.title(0)) + end, + }) + vim.cmd("normal! ihello") + expect.equality( + table.concat(titles, ","), + " comment (1 of 1) [INSERT] , comment (1 of 1) [NORMAL] " + ) + end) + + it("can edit the comment, but not the lines", function() + local lines_win = helpers.panes() + expect.equality(vim.bo.modifiable, true) + expect.equality(vim.bo[vim.api.nvim_win_get_buf(lines_win)].modifiable, false) + end) + + it("uses the editor background, padding included", function() + expect.equality(vim.wo[(helpers.panes())].winhighlight, "NormalFloat:Normal,LineNr:Normal") + end) + + it("closes both panes when one closes", function() + vim.cmd("close") + expect.equality(#helpers.floats(), 0) + end) +end) + +describe("the border", function() + local winborder + + before_each(function() + winborder = vim.o.winborder + vim.cmd("quit") + end) + + after_each(function() + vim.o.winborder = winborder + end) + + it("is rounded when 'winborder' is not set", function() + vim.o.winborder = "" + vim.cmd("CommentShow") + expect.equality(vim.api.nvim_win_get_config(0).border[1], "╭") + end) + + it("follows 'winborder' when it is set", function() + vim.o.winborder = "single" + vim.cmd("CommentShow") + expect.equality(vim.api.nvim_win_get_config(0).border[1], "┌") + end) +end) diff --git a/tests/fixtures/lifecycle.txt b/tests/fixtures/lifecycle.txt new file mode 100644 index 0000000..20aeba2 --- /dev/null +++ b/tests/fixtures/lifecycle.txt @@ -0,0 +1,3 @@ +first line +second line +third line diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..9ffbe3b --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,130 @@ +local M = {} + +---@type string[] +M.notifications = {} + +-- Notifications are scheduled; this runs them before reading the list. +---@return string[] +function M.notified() + vim.wait(0) + return M.notifications +end + +---@diagnostic disable-next-line: duplicate-set-field -- record instead of printing +vim.notify = function(message) + table.insert(M.notifications, message) +end + +-- A named buffer holding lines, not backed by a file on disk. +---@param name string +---@param lines string[] +---@return integer bufnr +function M.buffer(name, lines) + vim.cmd.enew() + vim.cmd.file(name) + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + return vim.api.nvim_get_current_buf() +end + +-- Types a comment into the open comment window and saves it. +---@param text string +function M.write(text) + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(text, "\n")) + vim.cmd("wq") +end + +---@return integer[] +function M.floats() + return vim.tbl_filter(function(win) + return vim.api.nvim_win_get_config(win).relative ~= "" + end, vim.api.nvim_list_wins()) +end + +-- The two panes of the open comment window, from inside the comment pane. +---@return integer lines_win +---@return integer comment_win +function M.panes() + local comment_win = vim.api.nvim_get_current_win() + local lines_win = vim.tbl_filter(function(win) + return win ~= comment_win + end, M.floats())[1] + return lines_win, comment_win +end + +---@param win integer +---@return string +function M.title(win) + return vim.api.nvim_win_get_config(win).title[1][1] +end + +---@param bufnr? integer +---@return string +function M.text(bufnr) + return table.concat(vim.api.nvim_buf_get_lines(bufnr or 0, 0, -1, false), "\n") +end + +---@param line integer +---@return string? sign +function M.sign_at(line) + local namespace = vim.api.nvim_get_namespaces().scratch_comments_signs + local marks = vim.api.nvim_buf_get_extmarks( + 0, + namespace, + { line - 1, 0 }, + { line - 1, -1 }, + { details = true, overlap = true } + ) + return marks[1] and vim.trim(marks[1][4].sign_text) +end + +local select = vim.ui.select + +---@type any[] the items the last vim.ui.select offered +M.offered = {} + +---@type string[] how the last vim.ui.select showed its items +M.labels = {} + +-- Answers each vim.ui.select with the next index given, then with 1. An index +-- past the items (such as 0) cancels. +---@param ... integer +function M.pick(...) + local choices = { ... } + ---@diagnostic disable-next-line: duplicate-set-field -- test fake + vim.ui.select = function(items, opts, callback) + M.offered = items + M.labels = vim.tbl_map(opts.format_item or tostring, items) + callback(items[table.remove(choices, 1) or 1]) + end +end + +-- Headless edits share one undo block unless it is broken explicitly. +function M.undo_break() + vim.o.undolevels = vim.o.undolevels +end + +-- Neovim fires TextChanged from its main loop, which a spec never returns to. +function M.text_changed() + vim.api.nvim_exec_autocmds("TextChanged", { buffer = 0 }) +end + +-- Every spec starts from one empty window with no comments. +function M.reset() + for _, win in ipairs(M.floats()) do + -- Closing one pane of a comment window closes the other. + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end + vim.cmd("silent! only!") + require("scratch_comments").clear() + require("scratch_comments").toggle(true) + vim.cmd("silent! %bwipeout!") + vim.ui.select = select + M.offered = {} + M.labels = {} + vim.wait(0) + M.notifications = {} +end + +return M diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua deleted file mode 100644 index e3144a0..0000000 --- a/tests/minimal_init.lua +++ /dev/null @@ -1,4 +0,0 @@ -vim.opt.runtimepath:prepend(vim.fn.getcwd()) -vim.opt.swapfile = false - -require("scratch_comments").setup() diff --git a/tests/minit.lua b/tests/minit.lua new file mode 100644 index 0000000..e112b7a --- /dev/null +++ b/tests/minit.lua @@ -0,0 +1,13 @@ +vim.opt.runtimepath:prepend(vim.fn.getcwd()) +vim.opt.runtimepath:prepend(assert(vim.env.MINI_TEST, "MINI_TEST is not set; run make test")) +vim.opt.swapfile = false +vim.opt.showmode = false +package.path = vim.fn.getcwd() .. "/tests/?.lua;" .. package.path + +require("mini.test").setup({ + collect = { + find_files = function() + return vim.fn.globpath("tests", "**/*_spec.lua", true, true) + end, + }, +}) diff --git a/tests/orphans.lua b/tests/orphans.lua deleted file mode 100644 index 3dc694f..0000000 --- a/tests/orphans.lua +++ /dev/null @@ -1,109 +0,0 @@ -local scratch = require("scratch_comments") -local state = require("scratch_comments.state") - -local function fail(message) - error(message, 2) -end - -local function assert_equal(actual, expected, message) - if actual ~= expected then - fail( - (message or "values differ") - .. ": expected " - .. vim.inspect(expected) - .. ", got " - .. vim.inspect(actual) - ) - end -end - -local function assert_contains(haystack, needle, message) - if not haystack:find(needle, 1, true) then - fail((message or "missing text") .. ": " .. needle) - end -end - -local function write(comment) - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(comment, "\n")) - vim.cmd("wq") -end - -local offered = {} ----@diagnostic disable-next-line: duplicate-set-field -- test fake -vim.ui.select = function(items, _, callback) - offered = items - callback(items[1]) -end - --- Headless edits share one undo block unless it is broken explicitly. -local function undo_break() - vim.o.undolevels = vim.o.undolevels -end - --- Neovim fires TextChanged from its main loop, which a headless script never --- returns to, so fire it the way the loop would after an edit. -local function text_changed() - vim.api.nvim_exec_autocmds("TextChanged", { buffer = 0 }) -end - -vim.cmd.enew() -vim.cmd.file("orphans-fixture.md") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "one", "two", "the quick fox", "four", "five" }) - -vim.api.nvim_win_set_cursor(0, { 3, 0 }) -vim.cmd("Comment") -write("a comment on the fox") - -vim.cmd("normal! ciwslow") -assert_equal(#state.anchored(), 1, "editing part of a line must keep its comment anchored") - -vim.cmd("normal! ccthe slow fox") -assert_equal(#state.orphans(), 0, "rewriting a whole line must keep its comment") - -undo_break() -vim.cmd("3d") -assert_equal(#state.anchored(), 0, "a comment whose lines are deleted is not anchored") -assert_equal(#state.orphans(), 1, "a comment whose lines are deleted is orphaned") -text_changed() -local sign_namespace = vim.api.nvim_get_namespaces().scratch_comments_signs -assert_equal( - #vim.api.nvim_buf_get_extmarks(0, sign_namespace, 0, -1, {}), - 0, - "an orphan has no sign" -) - -local markdown = scratch.render() -assert_contains(markdown, "## Orphaned", "export must list orphans in their own section") -assert_contains(markdown, "a comment on the fox", "export must keep the orphan's text") - -vim.cmd("CommentList") -local entries = vim.fn.getqflist() -assert_equal(#entries, 1, "the list must include the orphan") -assert_contains(entries[1].text, "[orphaned]", "the list must mark the orphan") -vim.cmd("cclose") - -undo_break() -vim.cmd("silent undo") -assert_equal(#state.orphans(), 0, "undo must re-anchor the orphan") -assert_equal(state.anchored()[1].start_line, 3, "undo must restore the comment's line") - -undo_break() -vim.cmd("3d") -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -vim.cmd("CommentDelete") -assert_equal(#offered, 1, ":CommentDelete must offer the orphan when no comment is at the cursor") -assert_equal(#state.orphans(), 0, ":CommentDelete must delete the chosen orphan") - -state.clear() -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "a", "b", "c", "d", "e" }) -vim.cmd("2,4Comment") -write("a comment on b through d") -vim.api.nvim_win_set_cursor(0, { 3, 0 }) -vim.cmd("normal! ccrewritten") -assert_equal(#state.orphans(), 0, "rewriting one line of a range must not orphan the comment") -undo_break() -vim.cmd("3d") -assert_equal(#state.orphans(), 0, "deleting part of a range must not orphan the comment") -assert_equal(state.anchored()[1].end_line, 3, "deleting part of a range shrinks it") - -print("orphans: ok") diff --git a/tests/reflow.lua b/tests/reflow.lua deleted file mode 100644 index 36347e6..0000000 --- a/tests/reflow.lua +++ /dev/null @@ -1,79 +0,0 @@ -local scratch = require("scratch_comments") -local state = require("scratch_comments.state") - -local function fail(message) - error(message, 2) -end - -local function assert_equal(actual, expected, message) - if actual ~= expected then - fail( - (message or "values differ") - .. ": expected " - .. vim.inspect(expected) - .. ", got " - .. vim.inspect(actual) - ) - end -end - -local text = "the quick brown fox" -local function write(comment) - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(comment, "\n")) - vim.cmd("wq") -end - -vim.cmd.enew() -vim.cmd.file("reflow-fixture.md") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "one", "two", text, "four", "five" }) - -vim.api.nvim_win_set_cursor(0, { 3, 0 }) -vim.cmd("Comment") -write("a comment on the fox") - -local function only() - return state.anchored()[1] -end - -assert_equal(only().start_line, 3, "comment should anchor to line 3") - -vim.api.nvim_buf_set_lines(0, 0, 0, false, { "new a", "new b", "new c" }) - -assert_equal(only().start_line, 6, "start_line must follow the text when lines are inserted above") -assert_equal(only().end_line, 6, "end_line must follow the text when lines are inserted above") -assert_equal( - scratch.render():match("%- (line %d+)"), - "line 6", - "export must report the current line" -) - -vim.api.nvim_buf_set_text(0, 5, 4, 5, 9, { "slow" }) -assert_equal( - only().snippet, - "the slow brown fox", - "snippet must be read live, not frozen at creation" -) - -vim.api.nvim_buf_set_lines(0, 0, 3, false, {}) -assert_equal(only().start_line, 3, "start_line must follow the text when lines above are deleted") - -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "a", "b", "c", "d", "e" }) -state.clear() -vim.cmd("2,4Comment") -write("a comment on b through d") -assert_equal(only().start_line, 2, "range start") -assert_equal(only().end_line, 4, "range end") - -vim.api.nvim_buf_set_lines(0, 2, 2, false, { "inserted inside the range" }) -assert_equal(only().start_line, 2, "range start holds when text is inserted inside") -assert_equal(only().end_line, 5, "range end must grow when text is inserted inside it") - -vim.api.nvim_buf_set_lines(0, 4, 5, false, {}) -assert_equal(only().end_line, 4, "deleting a range's last line must shrink it") -assert_equal( - only().snippet, - "b\ninserted inside the range\nc", - "the range must not take in the next line" -) - -print("reflow: ok") diff --git a/tests/smoke.lua b/tests/smoke.lua deleted file mode 100644 index 9b71ed5..0000000 --- a/tests/smoke.lua +++ /dev/null @@ -1,329 +0,0 @@ -local scratch = require("scratch_comments") -local state = require("scratch_comments.state") - -local function fail(message) - error(message, 2) -end - -local function assert_equal(actual, expected, message) - if actual ~= expected then - fail( - (message or "values differ") - .. ": expected " - .. vim.inspect(expected) - .. ", got " - .. vim.inspect(actual) - ) - end -end - -local function assert_contains(haystack, needle, message) - if not haystack:find(needle, 1, true) then - fail((message or "missing text") .. ": " .. needle) - end -end - -local function assert_not_contains(haystack, needle, message) - if haystack:find(needle, 1, true) then - fail((message or "unexpected text") .. ": " .. needle) - end -end - -local function count() - return #state.all() -end - -local function sign_at(line) - local namespace = vim.api.nvim_get_namespaces().scratch_comments_signs - local marks = vim.api.nvim_buf_get_extmarks( - 0, - namespace, - { line - 1, 0 }, - { line - 1, -1 }, - { details = true, overlap = true } - ) - return marks[1] and vim.trim(marks[1][4].sign_text) -end - -local function write(comment) - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(comment, "\n")) - vim.cmd("wq") -end - -local selections = {} ----@diagnostic disable-next-line: duplicate-set-field -- test fake -vim.ui.select = function(items, _, callback) - local index = table.remove(selections, 1) or 1 - callback(items[index]) -end - -local copied ----@diagnostic disable-next-line: duplicate-set-field -- test fake -require("scratch_comments.export").copy = function(text) - copied = text - return true -end - -vim.cmd.edit("README.md") -vim.api.nvim_win_set_cursor(0, { 1, 0 }) - -vim.cmd("Comment") -write("first comment") -assert_equal(count(), 1, ":Comment should create a comment") -assert_contains(scratch.render(), "## README.md") -assert_contains(scratch.render(), "first comment") -assert_equal(sign_at(1), "│", "a one-line comment gets a single sign") - -vim.cmd("Comment") -write("edited comment") -assert_equal(count(), 1, "the same anchor should edit, not duplicate") -assert_contains(scratch.render(), "edited comment") -assert_not_contains(scratch.render(), "first comment") - -vim.cmd("1,3Comment") -write("range comment") -assert_equal(count(), 2, "a different anchor should add a second comment") -assert_contains(scratch.render(), "lines 1-3") - -local function floats() - return vim.tbl_filter(function(win) - return vim.api.nvim_win_get_config(win).relative ~= "" - end, vim.api.nvim_list_wins()) -end - -local function text_of(win) - return table.concat(vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win), 0, -1, false), "\n") -end - -local source = vim.api.nvim_get_current_buf() -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -selections = { 2 } -vim.cmd("CommentShow") -local comment_win = vim.api.nvim_get_current_win() -local context_win = vim.tbl_filter(function(win) - return win ~= comment_win -end, floats())[1] -assert_equal(#floats(), 2, ":CommentShow opens the commented lines and the comment") -assert_equal(text_of(comment_win), "range comment", ":CommentShow shows the picked comment") -assert_equal( - text_of(context_win), - table.concat(vim.api.nvim_buf_get_lines(source, 0, 3, false), "\n"), - ":CommentShow shows the commented lines" -) -assert_contains( - vim.api.nvim_win_get_config(context_win).title[1][1], - "README.md [lines 1–3]", - "the frame is titled with the commented lines" -) -assert_equal(vim.bo.modifiable, false, ":CommentShow is read-only") -assert_equal( - vim.bo[vim.api.nvim_win_get_buf(context_win)].modifiable, - false, - "the lines pane is read-only" -) -assert_equal( - vim.wo[context_win].winhighlight, - "NormalFloat:Normal", - "the panes use the editor background" -) -vim.cmd("close") -assert_equal(#floats(), 0, "closing one pane closes the frame") - -selections = { 2 } -vim.cmd("CommentShow") -vim.cmd("quit") -assert_equal(#floats(), 0, ":q closes the show window") - -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -selections = { 2 } -vim.cmd("CommentEdit") -write("picked the range") -assert_equal(count(), 2, ":CommentEdit should not duplicate") -assert_contains(scratch.render(), "picked the range") - -vim.cmd("CommentExport") -assert_contains(copied or "", "picked the range", ":CommentExport should copy rendered markdown") -assert_equal(count(), 2, ":CommentExport must not clear comments") - -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -selections = { 1 } -vim.cmd("CommentDelete") -assert_equal(count(), 1, ":CommentDelete should remove one comment") - -vim.cmd("CommentClear") -assert_equal(count(), 0, ":CommentClear should remove every comment") -assert_equal( - #vim.api.nvim_buf_get_extmarks(0, vim.api.nvim_get_namespaces().scratch_comments, 0, -1, {}), - 0, - ":CommentClear should remove extmarks" -) - -vim.cmd("Comment") -write("quickfix check") -vim.cmd("CommentList") -local qf = vim.fn.getqflist({ title = 1, items = 1 }) -assert_equal(qf.title, "Comments", ":CommentList should title the quickfix list") -assert_equal(#qf.items, 1, ":CommentList should populate quickfix") -assert_equal(qf.items[1].text, "quickfix check", "quickfix entry should carry the comment text") -vim.cmd("cclose") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 2, 0 }) -vim.cmd("Comment") -local function title() - return vim.api.nvim_win_get_config(0).title[1][1] -end -local titles = {} -vim.api.nvim_create_autocmd("ModeChanged", { - buffer = 0, - callback = function() - table.insert(titles, title()) - end, -}) -vim.cmd("normal! ihello") -assert_equal( - table.concat(titles, ","), - " comment [INSERT] , comment [NORMAL] ", - "the title shows the current mode" -) -vim.cmd("q!") -assert_equal(count(), 0, "cancelling with :q! adds nothing") -assert_equal(#floats(), 0, "cancelling closes the frame") - -vim.cmd("Comment") -vim.cmd("quit") -assert_equal(#floats(), 0, ":q closes an untouched editor") - -vim.cmd("Comment") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) -pcall(vim.cmd.quit) -assert_equal(#floats(), 2, ":q keeps an editor with unsaved text") -vim.cmd("wincmd w") -pcall(vim.cmd.quit) -assert_equal(#floats(), 1, "closing the lines pane keeps an unsaved comment open") -vim.api.nvim_set_current_win(floats()[1]) -vim.cmd("q!") -assert_equal(#floats(), 0, ":q! discards the unsaved comment") -assert_equal(count(), 0, "a discarded comment is not saved") - -vim.cmd("Comment") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first line", "second line" }) -vim.cmd("w") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first line", "second line, revised" }) -vim.cmd("wq") -assert_equal(count(), 1, "saving twice keeps one comment") -assert_equal(state.all()[1].comment, "first line\nsecond line, revised", "comments can span lines") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -vim.cmd("Comment") -write("toggle check") -vim.cmd("CommentToggle off") -assert_equal(sign_at(1), nil, ":CommentToggle off must hide signs") -vim.cmd("CommentToggle on") -assert_equal(sign_at(1), "│", ":CommentToggle on must show signs") -vim.cmd("CommentToggle") -assert_equal(sign_at(1), nil, ":CommentToggle must hide shown signs") -vim.cmd("2Comment") -write("added while hidden") -assert_equal(sign_at(2), nil, "comments added while hidden have no sign") -vim.cmd("CommentToggle") -assert_equal(sign_at(2), "│", ":CommentToggle must show hidden signs") -vim.cmd("CommentClear") -assert_equal(sign_at(1), nil, ":CommentClear must remove signs") - -vim.cmd("2,5Comment") -write("on four lines") -assert_equal(sign_at(1), nil, "no sign above a range") -assert_equal(sign_at(2), "╭", "a range starts with its first sign") -assert_equal(sign_at(3), "│", "a range's middle lines get the middle sign") -assert_equal(sign_at(4), "│", "a range's middle lines get the middle sign") -assert_equal(sign_at(5), "╰", "a range ends with its last sign") -assert_equal(sign_at(6), nil, "no sign below a range") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 2, 0 }) -scratch.add() -write("on the cursor line") -scratch.add(4, 5) -write("on lines 4-5") -local ranges = vim.tbl_map(function(comment) - return comment.start_line .. "-" .. comment.end_line -end, state.anchored()) -assert_equal(table.concat(ranges, ","), "2-2,4-5", "scratch.add() defaults to the cursor line") -vim.cmd("CommentClear") - -vim.cmd("2Comment") -write("on two") -vim.cmd("4,5Comment") -write("on four and five") -vim.cmd("4Comment") -write("also on four") -vim.cmd("7Comment") -write("on seven") - -local function cursor_line() - return vim.api.nvim_win_get_cursor(0)[1] -end -local function walk(command) - local lines = {} - for _ = 1, 4 do - vim.cmd(command) - table.insert(lines, cursor_line()) - end - return table.concat(lines, ",") -end - -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -assert_equal(walk("CommentNext"), "2,4,7,2", ":CommentNext visits each start once, then wraps") -assert_equal(walk("CommentPrev"), "7,4,2,7", ":CommentPrev goes back, then wraps") - -vim.api.nvim_win_set_cursor(0, { 1, 0 }) -vim.cmd("CommentNext") -vim.cmd([[execute "normal! \"]]) -assert_equal(cursor_line(), 1, " returns from a comment jump") - -local file_win = vim.api.nvim_get_current_win() -vim.api.nvim_win_set_cursor(0, { 2, 0 }) -vim.cmd("CommentShow") -local function comment_title() - return vim.api.nvim_win_get_config(0).title[1][1] -end -assert_equal(comment_title(), " comment (1 of 4) ", "the preview counts the file's comments") -local previewed = {} -local file_lines = {} -for _ = 1, 4 do - vim.cmd("CommentNext") - table.insert(previewed, text_of(vim.api.nvim_get_current_win())) - table.insert(file_lines, vim.api.nvim_win_get_cursor(file_win)[1]) -end -assert_equal( - table.concat(previewed, ","), - "also on four,on four and five,on seven,on two", - ":CommentNext in a preview shows each comment in turn, then wraps" -) -assert_equal(table.concat(file_lines, ","), "4,4,7,2", "the cursor in the file follows the preview") -assert_equal(#floats(), 2, "cycling keeps one preview open") -assert_equal(comment_title(), " comment (1 of 4) ", "the count follows the preview") -vim.cmd("CommentPrev") -assert_equal( - text_of(vim.api.nvim_get_current_win()), - "on seven", - ":CommentPrev goes back in a preview" -) -vim.cmd("quit") -assert_equal(#floats(), 0, "closing the preview") -vim.api.nvim_set_current_win(file_win) -vim.cmd("CommentNext") -assert_equal(#floats(), 0, "without a preview, :CommentNext only moves the cursor") -vim.cmd("CommentClear") - -vim.cmd.edit("LICENSE") -vim.cmd("Comment") -write("license comment") -vim.cmd("edit!") -assert_equal(#state.anchored(), 1, ":e! must keep the buffer's comments") -vim.cmd("bdelete") -assert_equal(count(), 0, ":bd must drop the buffer's comments") - -print("smoke: ok") diff --git a/tests/spans.lua b/tests/spans.lua deleted file mode 100644 index 4adafc1..0000000 --- a/tests/spans.lua +++ /dev/null @@ -1,156 +0,0 @@ -local scratch = require("scratch_comments") -local state = require("scratch_comments.state") - -local function fail(message) - error(message, 2) -end - -local function assert_equal(actual, expected, message) - if actual ~= expected then - fail( - (message or "values differ") - .. ": expected " - .. vim.inspect(expected) - .. ", got " - .. vim.inspect(actual) - ) - end -end - -local function write(comment) - vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(comment, "\n")) - vim.cmd("wq") -end - --- Headless edits share one undo block unless it is broken explicitly. -local function undo_break() - vim.o.undolevels = vim.o.undolevels -end - -local function select(keys) - vim.cmd("normal! " .. keys .. "\27") -end - -local function views() - return state.anchored() -end - -local function lines_pane_title() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative ~= "" and win ~= vim.api.nvim_get_current_win() then - return config.title[1][1] - end - end -end - -vim.cmd.enew() -vim.cmd.file("spans-fixture.md") -vim.api.nvim_buf_set_lines(0, 0, -1, false, { - "the quick brown fox", - "café → bar", - "one two", - "three four", -}) - -vim.api.nvim_win_set_cursor(0, { 1, 4 }) -select("viw") -vim.cmd("'<,'>Comment") -assert_equal( - lines_pane_title(), - " spans-fixture.md [line 1, col 5–9] ", - "the title names the columns" -) -write("on quick") -local quick = views()[1] -assert_equal(quick.snippet, "quick", "a selected word is the snippet") -assert_equal(quick.start_col, 4, "the span starts at the word") -assert_equal(quick.end_col, 9, "the span ends after the word") - -vim.cmd("Comment") -write("on the line") -vim.cmd("1Comment") -assert_equal(#vim.api.nvim_list_wins(), 3, ":1Comment on the same line edits the line comment") -vim.cmd("q") -assert_equal(#views(), 2, "a line comment and a word comment are separate") -local line = vim.tbl_filter(function(view) - return view.start_col == nil -end, views())[1] -assert_equal(line.snippet, "the quick brown fox", ":Comment without a selection covers the line") - -vim.api.nvim_buf_set_text(0, 0, 4, 0, 4, { "very " }) -local function quick_view() - return vim.tbl_filter(function(view) - return view.id == quick.id - end, views())[1] -end -assert_equal(quick_view().snippet, "quick", "the span follows its text") - -undo_break() -vim.api.nvim_buf_set_text(0, 0, 9, 0, 14, { "slow" }) -assert_equal(quick_view(), nil, "replacing the word orphans its comment") -assert_equal(#state.orphans(), 1, "only the word comment is orphaned") -undo_break() -vim.cmd("silent undo") -assert_equal(quick_view().snippet, "quick", "undo restores the span") - -local data = vim.json.decode(scratch.render("json")) -local exported = vim.tbl_filter(function(comment) - return comment.id == quick.id -end, data.comments)[1] -assert_equal(exported.start_col, 10, "json columns are 1-based") -assert_equal(exported.end_col, 14, "json end column is the last byte") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 2, 0 }) -select("v3l") -vim.cmd("'<,'>Comment") -write("on café") -assert_equal(views()[1].snippet, "café", "a multibyte character stays whole") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 3, 4 }) -select("v$") -vim.cmd("'<,'>Comment") -write("to the end") -assert_equal(views()[1].snippet, "two", "a selection to $ ends at the line's end") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 3, 4 }) -select("vj") -vim.cmd("'<,'>Comment") -assert_equal( - lines_pane_title(), - " spans-fixture.md [lines 3:5–4:5] ", - "a span across lines uses line:col" -) -write("across lines") -assert_equal(views()[1].snippet, "two\nthree", "a selection can cross lines") -vim.cmd("CommentClear") - -vim.api.nvim_win_set_cursor(0, { 3, 4 }) -select("V") -vim.cmd("'<,'>Comment") -assert_equal(lines_pane_title(), " spans-fixture.md [line 3] ", "a line comment has no columns") -write("whole line") -assert_equal(views()[1].start_col, nil, "a linewise selection makes a line comment") -assert_equal(views()[1].snippet, "one two", "a linewise selection covers the line") - -vim.cmd("CommentClear") - -vim.cmd.file(("a-very-long-name"):rep(6) .. ".md") -vim.api.nvim_win_set_cursor(0, { 1, 4 }) -select("viw") -vim.o.columns = 60 -vim.cmd("'<,'>Comment") -local long_title = lines_pane_title() -assert_equal(vim.fn.strdisplaywidth(long_title), 56, "a long title fits the window") -assert_equal(long_title:sub(1, 4), " …", "a long title starts with an ellipsis") -assert_equal( - vim.endswith(long_title, "name.md [line 1, col 5–9] "), - true, - "a long title keeps the range" -) -vim.cmd("q!") - -print("spans: ok") diff --git a/tests/unit/json_spec.lua b/tests/unit/json_spec.lua new file mode 100644 index 0000000..8b6ecf1 --- /dev/null +++ b/tests/unit/json_spec.lua @@ -0,0 +1,58 @@ +local json = require("scratch_comments.export.json") +local expect = MiniTest.expect + +---@param fields table +---@return ScratchCommentView +local function view(fields) + return vim.tbl_extend("keep", fields, { + id = "comment-1", + bufnr = 1, + comment = "a comment", + file_path = "/repo/lua/app.lua", + relative_path = "lua/app.lua", + start_line = 3, + end_line = 4, + snippet = "a\nb", + }) +end + +describe("json", function() + it("has each comment's file, lines, snippet and text", function() + local data = vim.json.decode(json.render({ view({}) }, {})) + expect.equality(data.comments, { + { + id = "comment-1", + file = "/repo/lua/app.lua", + relative_path = "lua/app.lua", + start_line = 3, + end_line = 4, + snippet = "a\nb", + comment = "a comment", + }, + }) + end) + + it("has a span's columns, 1-based and inclusive", function() + local data = vim.json.decode(json.render({ view({ start_col = 4, end_col = 9 }) }, {})) + expect.equality(data.comments[1].start_col, 5) + expect.equality(data.comments[1].end_col, 9) + end) + + it("has orphans without lines", function() + local data = vim.json.decode(json.render({}, { view({ id = "comment-9" }) })) + expect.equality(data.orphaned, { + { + id = "comment-9", + file = "/repo/lua/app.lua", + relative_path = "lua/app.lua", + comment = "a comment", + }, + }) + end) + + it("encodes empty lists as arrays", function() + local rendered = json.render({}, {}) + expect.no_equality(rendered:find('"comments": []', 1, true), nil) + expect.no_equality(rendered:find('"orphaned": []', 1, true), nil) + end) +end) diff --git a/tests/unit/location_spec.lua b/tests/unit/location_spec.lua new file mode 100644 index 0000000..a3ba608 --- /dev/null +++ b/tests/unit/location_spec.lua @@ -0,0 +1,51 @@ +local location = require("scratch_comments.location") +local expect = MiniTest.expect + +describe("describe", function() + it("names a line", function() + expect.equality(location.describe({ start_line = 3, end_line = 3 }), "line 3") + end) + + it("names a range of lines with an en dash", function() + expect.equality(location.describe({ start_line = 3, end_line = 5 }), "lines 3–5") + end) + + it("names a span's columns, 1-based and inclusive", function() + local span = { start_line = 3, end_line = 3, start_col = 4, end_col = 12 } + expect.equality(location.describe(span), "line 3, col 5–12") + end) + + it("names a one-column span by its column", function() + local span = { start_line = 3, end_line = 3, start_col = 4, end_col = 5 } + expect.equality(location.describe(span), "line 3, col 5") + end) + + it("names a span across lines by line:col", function() + local span = { start_line = 3, end_line = 5, start_col = 4, end_col = 4 } + expect.equality(location.describe(span), "lines 3:5–5:4") + end) +end) + +describe("title", function() + it("is the file's name and its location", function() + local title = location.title("/repo/lua/app.lua", { start_line = 3, end_line = 5 }) + expect.equality(title, "app.lua [lines 3–5]") + end) +end) + +describe("fit", function() + it("leaves text that fits alone", function() + expect.equality(location.fit("app.lua [line 3]", 16), "app.lua [line 3]") + end) + + it("cuts from the left with an ellipsis, to exactly the width", function() + expect.equality(location.fit("long-name.lua [line 3]", 12), "…ua [line 3]") + end) + + it("counts wide characters by their width", function() + local fitted = location.fit("日本語の名前.md [line 3]", 15) + expect.equality(vim.startswith(fitted, "…"), true) + expect.equality(vim.api.nvim_strwidth(fitted) <= 15, true) + expect.equality(vim.endswith(fitted, ".md [line 3]"), true) + end) +end) diff --git a/tests/unit/markdown_spec.lua b/tests/unit/markdown_spec.lua new file mode 100644 index 0000000..fe6d368 --- /dev/null +++ b/tests/unit/markdown_spec.lua @@ -0,0 +1,98 @@ +local markdown = require("scratch_comments.export.markdown") +local expect = MiniTest.expect + +---@param fields table +---@return ScratchCommentView +local function view(fields) + return vim.tbl_extend("keep", fields, { + id = "comment-1", + bufnr = 1, + comment = "a comment", + file_path = "/repo/lua/app.lua", + relative_path = "lua/app.lua", + start_line = 3, + end_line = 3, + snippet = "local x = 1", + }) +end + +---@param lines string[] +---@return string +local function text(lines) + return table.concat(lines, "\n") +end + +describe("markdown", function() + it("groups comments by file, each with its lines, text and snippet", function() + local rendered = markdown.render({ + view({ id = "comment-1", start_line = 3, end_line = 3 }), + view({ id = "comment-2", start_line = 5, end_line = 6, snippet = "a\nb", comment = "two" }), + view({ id = "comment-3", relative_path = "README.md", comment = "in the readme" }), + }, {}) + expect.equality( + rendered, + text({ + "Comments:", + "", + "## lua/app.lua", + "", + "- line 3 (comment-1)", + "", + "a comment", + "", + "```lua", + "local x = 1", + "```", + "", + "- lines 5–6 (comment-2)", + "", + "two", + "", + "```lua", + "a", + "b", + "```", + "", + "## README.md", + "", + "- line 3 (comment-3)", + "", + "in the readme", + "", + "```md", + "local x = 1", + "```", + "", + }) + ) + end) + + it("names a span's columns", function() + local rendered = markdown.render({ view({ start_col = 6, end_col = 11 }) }, {}) + expect.no_equality(rendered:find("- line 3, col 7–11 (comment-1)", 1, true), nil) + end) + + it("fences a snippet that contains a fence with four backticks", function() + local rendered = markdown.render({ view({ snippet = "```\ncode\n```" }) }, {}) + expect.no_equality(rendered:find("````lua\n```\ncode\n```\n````", 1, true), nil) + end) + + it("lists orphans in their own section", function() + local orphan = view({ id = "comment-9", comment = "lost" }) + expect.equality( + markdown.render({}, { orphan }), + text({ + "Comments:", + "", + "## Orphaned", + "", + "The lines these comments were on have been deleted.", + "", + "- lua/app.lua (comment-9)", + "", + "lost", + "", + }) + ) + end) +end) diff --git a/tests/unit/paths_spec.lua b/tests/unit/paths_spec.lua new file mode 100644 index 0000000..89c66ef --- /dev/null +++ b/tests/unit/paths_spec.lua @@ -0,0 +1,42 @@ +local paths = require("scratch_comments.paths") +local expect = MiniTest.expect + +describe("git_root", function() + it("finds the repository a file is in", function() + local root = vim.fn.getcwd() + expect.equality(paths.git_root(root .. "/lua/scratch_comments/paths.lua"), root) + end) + + it("uses the working directory for a file with no path", function() + expect.equality(paths.git_root(""), vim.fn.getcwd()) + end) + + it("is nil outside a repository", function() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + expect.equality(paths.git_root(dir .. "/file.txt"), nil) + end) +end) + +describe("relative_path", function() + it("is the path inside the root", function() + expect.equality(paths.relative_path("/repo", "/repo/lua/app.lua"), "lua/app.lua") + end) + + it("accepts a root with a trailing slash", function() + expect.equality(paths.relative_path("/repo/", "/repo/lua/app.lua"), "lua/app.lua") + end) + + it("keeps a path outside the root", function() + expect.equality(paths.relative_path("/repo", "/elsewhere/app.lua"), "/elsewhere/app.lua") + end) + + it("keeps the path when there is no root", function() + expect.equality(paths.relative_path(nil, "/elsewhere/app.lua"), "/elsewhere/app.lua") + expect.equality(paths.relative_path("", "/elsewhere/app.lua"), "/elsewhere/app.lua") + end) + + it("doesn't mistake a sibling with the same prefix for the root", function() + expect.equality(paths.relative_path("/repo", "/repo-two/app.lua"), "/repo-two/app.lua") + end) +end) diff --git a/tests/unit/store_spec.lua b/tests/unit/store_spec.lua new file mode 100644 index 0000000..3d6f351 --- /dev/null +++ b/tests/unit/store_spec.lua @@ -0,0 +1,53 @@ +local store = require("scratch_comments.model.store") +local expect = MiniTest.expect + +---@param bufnr integer +---@param text string +local function add(bufnr, text) + return store.add({ bufnr = bufnr, comment = text, file_path = "/f", relative_path = "f" }) +end + +before_each(function() + store.clear() +end) + +describe("store", function() + it("adds comments with unique ids, in order", function() + local first, second = add(1, "first"), add(1, "second") + expect.no_equality(first.id, second.id) + expect.equality(store.all(), { first, second }) + end) + + it("updates a comment's fields by id", function() + local comment = add(1, "before") + expect.equality(store.update(comment.id, { comment = "after" }), comment) + expect.equality(comment.comment, "after") + end) + + it("updates nothing for an unknown id", function() + add(1, "kept") + expect.equality(store.update("comment-none", { comment = "after" }), nil) + expect.equality(store.all()[1].comment, "kept") + end) + + it("finds the comments in a buffer", function() + local in_one = add(1, "in one") + add(2, "in two") + expect.equality(store.in_buffer(1), { in_one }) + end) + + it("removes what matches and returns it, keeping the rest in order", function() + local a, b, c = add(1, "a"), add(2, "b"), add(1, "c") + local removed = store.remove(function(comment) + return comment.bufnr == 1 + end) + expect.equality(removed, { a, c }) + expect.equality(store.all(), { b }) + end) + + it("clears everything", function() + add(1, "a") + store.clear() + expect.equality(store.all(), {}) + end) +end) diff --git a/tests/unit/views_spec.lua b/tests/unit/views_spec.lua new file mode 100644 index 0000000..20072f6 --- /dev/null +++ b/tests/unit/views_spec.lua @@ -0,0 +1,39 @@ +local views = require("scratch_comments.model.views") +local expect = MiniTest.expect + +---@param id string +---@param relative_path? string +---@param start_line? integer +local function view(id, relative_path, start_line) + return { + id = id, + relative_path = relative_path or "f", + start_line = start_line or 1, + end_line = 1, + } +end + +---@param list table[] +---@return string +local function ids(list) + table.sort(list, views.by_position) + return table.concat( + vim.tbl_map(function(v) + return v.id + end, list), + "," + ) +end + +describe("by_position", function() + it("orders by file, then line", function() + expect.equality( + ids({ view("comment-1", "b"), view("comment-2", "a", 2), view("comment-3", "a", 1) }), + "comment-3,comment-2,comment-1" + ) + end) + + it("orders comments on the same lines by when they were added", function() + expect.equality(ids({ view("comment-10"), view("comment-2") }), "comment-2,comment-10") + end) +end)