From b723bfe8d00e22a625fd134a6a80dbfe21e97170 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 07:19:00 -0700 Subject: [PATCH 01/22] Color the comment window's padding like its background The padding column is a statuscolumn, which draws in LineNr, so it showed as a strip in colorschemes that give LineNr its own background. --- lua/scratch_comments/ui.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui.lua index a798846..08312df 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui.lua @@ -106,7 +106,7 @@ function M.open_frame(frame) 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 -- 2.54.0 From 5feecab097d5c67733d7d62476f0476ca58b4056 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 07:48:44 -0700 Subject: [PATCH 02/22] Report failing test suites The test runner matched FAIL only at the start of a line, but Neovim can print mode messages before it, so failures showed as ok. It now finds FAIL anywhere and treats a suite that never reports ok as failed. That exposed two failures: the padding test still expected the old winhighlight, and the long title test expected the wrong columns and measured with strdisplaywidth, which counts linebreak padding in the current window. Title fitting now uses nvim_strwidth too. --- Makefile | 7 ++++--- lua/scratch_comments/ui.lua | 2 +- tests/smoke.lua | 2 +- tests/spans.lua | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 6740ef4..95e3314 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,11 @@ test: 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); \ + 2>&1 | grep -oE '(FAIL: .*|[a-z]+: ok)' || true); \ case "$$out" in \ - FAIL*) echo "$$out"; fail=1 ;; \ - *) echo "ok" ;; \ + *FAIL*) echo "$$out"; fail=1 ;; \ + *": ok") echo "ok" ;; \ + *) echo "FAIL: no result"; fail=1 ;; \ esac; \ done; \ exit $$fail diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui.lua index 08312df..3905d3e 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui.lua @@ -38,7 +38,7 @@ end ---@return string local function fit(text, width) local start = 0 - while vim.fn.strdisplaywidth(vim.fn.strcharpart(text, start)) > width do + 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) diff --git a/tests/smoke.lua b/tests/smoke.lua index 9b71ed5..02feb29 100644 --- a/tests/smoke.lua +++ b/tests/smoke.lua @@ -123,7 +123,7 @@ assert_equal( ) assert_equal( vim.wo[context_win].winhighlight, - "NormalFloat:Normal", + "NormalFloat:Normal,LineNr:Normal", "the panes use the editor background" ) vim.cmd("close") diff --git a/tests/spans.lua b/tests/spans.lua index 4adafc1..cca8995 100644 --- a/tests/spans.lua +++ b/tests/spans.lua @@ -144,10 +144,10 @@ 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(vim.api.nvim_strwidth(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] "), + vim.endswith(long_title, "name.md [line 1, col 5–8] "), true, "a long title keeps the range" ) -- 2.54.0 From c60840457659b9b27cb874e5d6cf62372a1f0b4b Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 07:48:53 -0700 Subject: [PATCH 03/22] Make :CommentShow the one comment window A comment opens in one window, as in Google Docs: :CommentShow shows it in normal mode, and you edit it there and :w to save. :CommentEdit and scratch.edit() are gone, and :Comment on a commented range opens the same window. :CommentNext and :CommentPrev move any open comment window to the next or previous comment, a new one included. With unsaved changes they refuse, like :bnext, so nothing is lost. The title shows the count and the mode: "comment (3 of 4) [NORMAL]". --- README.md | 8 +- doc/scratch-comments.txt | 23 ++--- lua/scratch_comments/annotate.lua | 144 +++++++++++++++--------------- lua/scratch_comments/init.lua | 9 -- lua/scratch_comments/ui.lua | 17 ++-- tests/smoke.lua | 28 ++++-- 6 files changed, 112 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 0297d19..f1e9ba3 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,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,8 +97,8 @@ 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 +wrapping at the ends. With a comment open, they show the next or previous +comment in its place. 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: @@ -142,7 +141,6 @@ 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/doc/scratch-comments.txt b/doc/scratch-comments.txt index 3765c94..5e480f7 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -46,28 +46,24 @@ Add your own mappings. See |scratch-comments-api| for examples. *: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. + with the lines it is on. 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 its + place instead, one comment at a time. 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 its place. *:CommentDelete* :CommentDelete Delete the comment at the cursor. If more than one @@ -150,7 +146,6 @@ The signs use ScratchCommentSign, which links to |hl-Todo|. To change it: >lua 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/annotate.lua b/lua/scratch_comments/annotate.lua index 0327fde..6c72a87 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -69,26 +69,29 @@ local function title(file_path, start_line, end_line, start_col, end_col) 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 +---@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 +---@field comment_win? integer ----@type ScratchPreview? -local preview +---@type ScratchCommentWindow? +local open_window + +---@param frame ScratchFrame +---@param window ScratchCommentWindow +local function open(frame, window) + local comment_win + frame.on_close = function() + if open_window and open_window.comment_win == comment_win then + open_window = nil + end + end + comment_win = ui.open_frame(frame) + window.comment_win = comment_win + open_window = window +end ---@param bufnr integer ---@return ScratchCommentView[] @@ -99,7 +102,7 @@ local function file_views(bufnr) end ---@param views ScratchCommentView[] ----@param id string +---@param id? string ---@return integer? local function index_of(views, id) for i, view in ipairs(views) do @@ -113,52 +116,53 @@ end ---@param source_win integer local function show(view, source_win) local views = file_views(view.bufnr) - local comment_win - comment_win = open_frame(view, { + open({ + 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, 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, - }) + }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) +end + +---@param window ScratchCommentWindow +---@param direction 1|-1 +local function cycle(window, direction) + if vim.bo[vim.api.nvim_win_get_buf(window.comment_win)].modified then + ui.notify("Save or discard the comment first", "warn") + return + end + local views = file_views(window.bufnr) + if #views == 0 then + return + end + + local index = index_of(views, window.id) + local target + if index then + target = views[(index - 1 + direction) % #views + 1] + elseif direction == 1 then + target = vim.iter(views):find(function(view) + return view.start_line > window.line + end) or views[1] + else + target = vim.iter(views):rev():find(function(view) + return view.start_line < window.line + end) or views[#views] + end + + vim.api.nvim_win_close(window.comment_win, true) + if vim.api.nvim_win_is_valid(window.source_win) then + vim.api.nvim_win_call(window.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, window.source_win) end ---@param start_line integer @@ -179,7 +183,7 @@ function M.range(start_line, end_line, use_selection) local existing = find_anchor(bufnr, start_line, end_line, start_col, end_col) if existing then - edit(existing) + show(existing, vim.api.nvim_get_current_win()) return end @@ -187,7 +191,9 @@ function M.range(start_line, end_line, use_selection) local relative_path = context.relative_path(context.git_root(file_path), file_path) ---@type ScratchComment? local added - ui.open_frame({ + ---@type ScratchCommentWindow + local window = { bufnr = bufnr, line = start_line, source_win = vim.api.nvim_get_current_win() } + open({ 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, @@ -207,11 +213,12 @@ function M.range(start_line, end_line, use_selection) relative_path = relative_path, charwise = start_col ~= nil, }) + window.id = added.id 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, - }) + }, window) end ---@param view ScratchCommentView @@ -257,8 +264,8 @@ 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) + if open_window and vim.api.nvim_win_is_valid(open_window.comment_win) then + cycle(open_window, direction) return end @@ -300,15 +307,6 @@ function M.jump(direction) 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 diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 3890284..211e26a 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -29,10 +29,6 @@ function M.prev() annotate.jump(-1) end -function M.edit() - annotate.edit_current() -end - function M.delete() annotate.delete_current() end @@ -124,11 +120,6 @@ function M.setup() 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, diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui.lua index 3905d3e..b64f41c 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui.lua @@ -61,7 +61,7 @@ end ---@field context string[] ---@field filetype string ---@field comment string ----@field on_save? fun(text: string) +---@field on_save fun(text: string) ---@field on_close? fun() ---@field comment_title? string @@ -99,7 +99,7 @@ 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", }) @@ -129,19 +129,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 diff --git a/tests/smoke.lua b/tests/smoke.lua index 02feb29..0f11cfb 100644 --- a/tests/smoke.lua +++ b/tests/smoke.lua @@ -115,7 +115,7 @@ assert_contains( "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.modifiable, true, ":CommentShow can edit the comment") assert_equal( vim.bo[vim.api.nvim_win_get_buf(context_win)].modifiable, false, @@ -136,9 +136,9 @@ assert_equal(#floats(), 0, ":q closes the show window") vim.api.nvim_win_set_cursor(0, { 1, 0 }) selections = { 2 } -vim.cmd("CommentEdit") +vim.cmd("CommentShow") write("picked the range") -assert_equal(count(), 2, ":CommentEdit should not duplicate") +assert_equal(count(), 2, "editing from :CommentShow should not duplicate") assert_contains(scratch.render(), "picked the range") vim.cmd("CommentExport") @@ -289,7 +289,11 @@ 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") +assert_equal( + comment_title(), + " comment (1 of 4) [NORMAL] ", + "the preview counts the file's comments" +) local previewed = {} local file_lines = {} for _ = 1, 4 do @@ -304,13 +308,27 @@ assert_equal( ) 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") +assert_equal(comment_title(), " comment (1 of 4) [NORMAL] ", "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.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) +vim.cmd("CommentNext") +assert_equal(text_of(vim.api.nvim_get_current_win()), "unsaved", "unsaved changes stop a jump") +vim.cmd("quit!") +vim.api.nvim_set_current_win(file_win) +vim.api.nvim_win_set_cursor(0, { 3, 0 }) +vim.cmd("Comment") +vim.cmd("stopinsert") +vim.cmd("CommentNext") +assert_equal( + text_of(vim.api.nvim_get_current_win()), + "also on four", + "a new comment jumps from its line" +) vim.cmd("quit") assert_equal(#floats(), 0, "closing the preview") vim.api.nvim_set_current_win(file_win) -- 2.54.0 From 63245276bb9bec61adbc6d21c36d5e5aae2b5bb6 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 08:48:10 -0700 Subject: [PATCH 04/22] Run specs with mini.test make test clones mini.test at a pinned version into deps/, once per version, and runs tests/**/*_spec.lua in-process before the old suites. Specs use describe/it/before_each with mini.test's expect, and each one starts from a reset editor through tests/helpers.lua. The export suite is the first one ported, as list_export_spec.lua. --- .gitignore | 1 + .luarc.json | 4 +- Makefile | 23 +++++--- tests/export.lua | 86 ----------------------------- tests/features/list_export_spec.lua | 74 +++++++++++++++++++++++++ tests/helpers.lua | 53 ++++++++++++++++++ tests/minit.lua | 14 +++++ 7 files changed, 160 insertions(+), 95 deletions(-) delete mode 100644 tests/export.lua create mode 100644 tests/features/list_export_spec.lua create mode 100644 tests/helpers.lua create mode 100644 tests/minit.lua 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 95e3314..7f1288a 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,9 +14,10 @@ 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: +## test: run the specs, then the old suites +test: SUITES := tests/smoke.lua tests/reflow.lua tests/orphans.lua tests/spans.lua +test: $(MINI_TEST) + @MINI_TEST=$(MINI_TEST) $(NVIM) --headless --clean -u tests/minit.lua -c "lua MiniTest.run()" @fail=0; \ for t in $(SUITES); do \ printf '%-22s ' "$$t"; \ @@ -28,15 +32,20 @@ test: done; \ exit $$fail +## 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/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/list_export_spec.lua b/tests/features/list_export_spec.lua new file mode 100644 index 0000000..f796685 --- /dev/null +++ b/tests/features/list_export_spec.lua @@ -0,0 +1,74 @@ +local clipboard = require("scratch_comments.export") +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 json", function() + vim.cmd("CommentExport json") + expect.equality(vim.json.decode(copied).comments[2].comment, "on c") + 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) diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..6222a04 --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,53 @@ +local M = {} + +---@type string[] +M.notifications = {} + +---@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 + +---@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 + +-- Every spec starts from one empty window with no comments. +function M.reset() + for _, win in ipairs(M.floats()) do + vim.api.nvim_win_close(win, true) + end + vim.cmd("silent! only!") + require("scratch_comments").clear() + vim.cmd("silent! %bwipeout!") + M.notifications = {} +end + +return M diff --git a/tests/minit.lua b/tests/minit.lua new file mode 100644 index 0000000..0b7abaa --- /dev/null +++ b/tests/minit.lua @@ -0,0 +1,14 @@ +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("scratch_comments").setup() +require("mini.test").setup({ + collect = { + find_files = function() + return vim.fn.globpath("tests", "**/*_spec.lua", true, true) + end, + }, +}) -- 2.54.0 From ecedd926a7c5e3b193c5e311077b130905e4d896 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 08:50:11 -0700 Subject: [PATCH 05/22] Port the anchoring and span suites to specs reflow.lua and orphans.lua become anchoring_spec.lua, and spans.lua becomes spans_spec.lua. Each case builds its own buffer instead of continuing from the edits before it. In-process specs are enough: helpers fire TextChanged and break undo blocks where headless Neovim doesn't, and answer vim.ui.select. --- Makefile | 2 +- tests/features/anchoring_spec.lua | 136 ++++++++++++++++++++++++++ tests/features/spans_spec.lua | 148 ++++++++++++++++++++++++++++ tests/helpers.lua | 64 +++++++++++- tests/orphans.lua | 109 --------------------- tests/reflow.lua | 79 --------------- tests/spans.lua | 156 ------------------------------ 7 files changed, 348 insertions(+), 346 deletions(-) create mode 100644 tests/features/anchoring_spec.lua create mode 100644 tests/features/spans_spec.lua delete mode 100644 tests/orphans.lua delete mode 100644 tests/reflow.lua delete mode 100644 tests/spans.lua diff --git a/Makefile b/Makefile index 7f1288a..0774051 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ all: check check: lint fmt-check test ## test: run the specs, then the old suites -test: SUITES := tests/smoke.lua tests/reflow.lua tests/orphans.lua tests/spans.lua +test: SUITES := tests/smoke.lua test: $(MINI_TEST) @MINI_TEST=$(MINI_TEST) $(NVIM) --headless --clean -u tests/minit.lua -c "lua MiniTest.run()" @fail=0; \ diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua new file mode 100644 index 0000000..2883356 --- /dev/null +++ b/tests/features/anchoring_spec.lua @@ -0,0 +1,136 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local state = require("scratch_comments.state") +local expect = MiniTest.expect + +local function only() + return state.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(#state.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(#state.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(#state.orphans(), 0) + end) + + it("shrinks when a middle line is deleted", function() + vim.cmd("3d") + expect.equality(#state.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(#state.anchored(), 0) + expect.equality(#state.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(#state.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(#state.orphans(), 0) + end) +end) diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua new file mode 100644 index 0000000..2652e18 --- /dev/null +++ b/tests/features/spans_spec.lua @@ -0,0 +1,148 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local state = require("scratch_comments.state") +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 = state.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(#state.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 = state.anchored()[1] + end) + + local function current() + return vim.tbl_filter(function(view) + return view.id == quick.id + end, state.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(#state.orphans(), 1) + helpers.undo_break() + vim.cmd("silent undo") + expect.equality(current().snippet, "quick") + 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(state.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(state.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(state.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(state.anchored()[1].start_col, nil) + expect.equality(state.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/helpers.lua b/tests/helpers.lua index 6222a04..d68f5b0 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -33,20 +33,82 @@ function M.floats() 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 = {} + +-- Answers each vim.ui.select with the next index given, then with 1. +---@param ... integer +function M.pick(...) + local choices = { ... } + ---@diagnostic disable-next-line: duplicate-set-field -- test fake + vim.ui.select = function(items, _, callback) + M.offered = 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 - vim.api.nvim_win_close(win, true) + -- 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() vim.cmd("silent! %bwipeout!") + vim.ui.select = select + M.offered = {} M.notifications = {} 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/spans.lua b/tests/spans.lua deleted file mode 100644 index cca8995..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.api.nvim_strwidth(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–8] "), - true, - "a long title keeps the range" -) -vim.cmd("q!") - -print("spans: ok") -- 2.54.0 From 353f7dc4d93c03791171d5f92524eefe9fe2ec49 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 08:52:28 -0700 Subject: [PATCH 06/22] Split the smoke suite into feature specs smoke.lua becomes commenting, window, show_delete, navigation and signs specs, plus cases in list_export and anchoring. :e! gets a fixture file instead of the repo's LICENSE. The old runner and minimal_init.lua go, so make test runs mini.test only. --- Makefile | 16 +- tests/features/anchoring_spec.lua | 18 ++ tests/features/commenting_spec.lua | 111 +++++++++ tests/features/list_export_spec.lua | 26 +++ tests/features/navigation_spec.lua | 100 ++++++++ tests/features/show_delete_spec.lua | 55 +++++ tests/features/signs_spec.lua | 63 +++++ tests/features/window_spec.lua | 62 +++++ tests/fixtures/lifecycle.txt | 3 + tests/helpers.lua | 1 + tests/minimal_init.lua | 4 - tests/smoke.lua | 347 ---------------------------- 12 files changed, 440 insertions(+), 366 deletions(-) create mode 100644 tests/features/commenting_spec.lua create mode 100644 tests/features/navigation_spec.lua create mode 100644 tests/features/show_delete_spec.lua create mode 100644 tests/features/signs_spec.lua create mode 100644 tests/features/window_spec.lua create mode 100644 tests/fixtures/lifecycle.txt delete mode 100644 tests/minimal_init.lua delete mode 100644 tests/smoke.lua diff --git a/Makefile b/Makefile index 0774051..e2c4ad7 100644 --- a/Makefile +++ b/Makefile @@ -14,23 +14,9 @@ all: check ## check: everything CI runs check: lint fmt-check test -## test: run the specs, then the old suites -test: SUITES := tests/smoke.lua +## test: run the specs test: $(MINI_TEST) @MINI_TEST=$(MINI_TEST) $(NVIM) --headless --clean -u tests/minit.lua -c "lua MiniTest.run()" - @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 -oE '(FAIL: .*|[a-z]+: ok)' || true); \ - case "$$out" in \ - *FAIL*) echo "$$out"; fail=1 ;; \ - *": ok") echo "ok" ;; \ - *) echo "FAIL: no result"; fail=1 ;; \ - esac; \ - done; \ - exit $$fail ## deps: mini.test, cloned once per version $(MINI_TEST): diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 2883356..6771531 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -134,3 +134,21 @@ describe("an orphan", function() expect.equality(#state.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(#state.anchored(), 1) + end) + + it("go with :bd", function() + vim.cmd("bdelete") + expect.equality(#state.all(), 0) + end) +end) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua new file mode 100644 index 0000000..2c21a39 --- /dev/null +++ b/tests/features/commenting_spec.lua @@ -0,0 +1,111 @@ +local helpers = require("helpers") +local scratch = require("scratch_comments") +local state = require("scratch_comments.state") +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(#state.all(), 1) + expect.equality(state.anchored()[1].start_line, 2) + expect.equality(state.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(#state.all(), 1) + expect.equality(state.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(#state.all(), 2) + expect.equality(state.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(#state.all(), 1) + expect.equality(state.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(#state.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(#state.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, state.anchored()) + expect.equality(table.concat(ranges, ","), "2-2,4-5") + end) +end) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index f796685..087640c 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -42,6 +42,13 @@ describe("json", function() 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") @@ -72,3 +79,22 @@ describe(":CommentExport!", function() 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) diff --git a/tests/features/navigation_spec.lua b/tests/features/navigation_spec.lua new file mode 100644 index 0000000..2ffe016 --- /dev/null +++ b/tests/features/navigation_spec.lua @@ -0,0 +1,100 @@ +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) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua new file mode 100644 index 0000000..4ab0361 --- /dev/null +++ b/tests/features/show_delete_spec.lua @@ -0,0 +1,55 @@ +local helpers = require("helpers") +local state = require("scratch_comments.state") +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("saves edits to the comment it shows", function() + helpers.pick(2) + vim.cmd("CommentShow") + helpers.write("picked the range") + expect.equality(#state.all(), 2) + expect.equality(state.anchored()[2].comment, "picked the range") + 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(#state.all(), 1) + expect.equality(state.anchored()[1].comment, "on one to three") + 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(#state.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..04c361e --- /dev/null +++ b/tests/features/signs_spec.lua @@ -0,0 +1,63 @@ +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("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/window_spec.lua b/tests/features/window_spec.lua new file mode 100644 index 0000000..4e6a134 --- /dev/null +++ b/tests/features/window_spec.lua @@ -0,0 +1,62 @@ +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) 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 index d68f5b0..2da80ff 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -106,6 +106,7 @@ function M.reset() end vim.cmd("silent! only!") require("scratch_comments").clear() + require("scratch_comments").toggle(true) vim.cmd("silent! %bwipeout!") vim.ui.select = select M.offered = {} 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/smoke.lua b/tests/smoke.lua deleted file mode 100644 index 0f11cfb..0000000 --- a/tests/smoke.lua +++ /dev/null @@ -1,347 +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, true, ":CommentShow can edit the comment") -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,LineNr: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("CommentShow") -write("picked the range") -assert_equal(count(), 2, "editing from :CommentShow 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) [NORMAL] ", - "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) [NORMAL] ", "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.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) -vim.cmd("CommentNext") -assert_equal(text_of(vim.api.nvim_get_current_win()), "unsaved", "unsaved changes stop a jump") -vim.cmd("quit!") -vim.api.nvim_set_current_win(file_win) -vim.api.nvim_win_set_cursor(0, { 3, 0 }) -vim.cmd("Comment") -vim.cmd("stopinsert") -vim.cmd("CommentNext") -assert_equal( - text_of(vim.api.nvim_get_current_win()), - "also on four", - "a new comment jumps from its line" -) -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") -- 2.54.0 From 360002021f236105e7049e5ea12ece12eab2674c Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 08:55:08 -0700 Subject: [PATCH 07/22] Cover the behavior that had no specs Specs for the unnamed-buffer warning, the window border, cancelled pickers, deleting with nothing to delete, :CommentToggle usage, navigation with other files or a changed comment, clipboard failure, sorting across files, nested code fences and :checkhealth. helpers.notified() runs scheduled notifications before reading them, and reset flushes them so none reach the next spec. --- tests/features/commenting_spec.lua | 9 ++++++ tests/features/health_spec.lua | 15 ++++++++++ tests/features/list_export_spec.lua | 46 +++++++++++++++++++++++++++++ tests/features/navigation_spec.lua | 34 +++++++++++++++++++++ tests/features/show_delete_spec.lua | 19 ++++++++++++ tests/features/signs_spec.lua | 6 ++++ tests/features/window_spec.lua | 25 ++++++++++++++++ tests/helpers.lua | 11 ++++++- 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/features/health_spec.lua diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 2c21a39..3ab1f54 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -109,3 +109,12 @@ describe("scratch.add()", function() 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 index 087640c..1bbfe81 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -54,6 +54,18 @@ describe(":CommentExport", function() 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) @@ -98,3 +110,37 @@ describe("markdown", function() 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 index 2ffe016..830c2f4 100644 --- a/tests/features/navigation_spec.lua +++ b/tests/features/navigation_spec.lua @@ -98,3 +98,37 @@ describe("from a new comment", function() 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/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 4ab0361..a13f931 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -28,6 +28,12 @@ describe(":CommentShow", function() expect.equality(state.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") @@ -43,6 +49,19 @@ describe(":CommentDelete", function() expect.equality(#state.all(), 1) expect.equality(state.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(#state.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(#state.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "No comment at cursor") + end) end) describe(":CommentClear", function() diff --git a/tests/features/signs_spec.lua b/tests/features/signs_spec.lua index 04c361e..9514543 100644 --- a/tests/features/signs_spec.lua +++ b/tests/features/signs_spec.lua @@ -52,6 +52,12 @@ describe(":CommentToggle", function() 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") diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index 4e6a134..9d110a6 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -60,3 +60,28 @@ describe("the comment window", function() 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/helpers.lua b/tests/helpers.lua index 2da80ff..b31d67d 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -3,6 +3,13 @@ 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) @@ -75,7 +82,8 @@ local select = vim.ui.select ---@type any[] the items the last vim.ui.select offered M.offered = {} --- Answers each vim.ui.select with the next index given, then with 1. +-- 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 = { ... } @@ -110,6 +118,7 @@ function M.reset() vim.cmd("silent! %bwipeout!") vim.ui.select = select M.offered = {} + vim.wait(0) M.notifications = {} end -- 2.54.0 From 6a7f5d3620271befb07bed584d5e52ecc3c81a66 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 08:59:20 -0700 Subject: [PATCH 08/22] Move the comment list into model/store.lua The store holds the comments and their ids and makes no Neovim calls. One remove(predicate) replaces remove_ids and remove_buffer. state.lua keeps only the views: where each comment is now, and its snippet. --- lua/scratch_comments/annotate.lua | 17 +++-- lua/scratch_comments/init.lua | 11 +-- lua/scratch_comments/model/store.lua | 75 +++++++++++++++++++ lua/scratch_comments/state.lua | 104 +-------------------------- tests/features/anchoring_spec.lua | 3 +- tests/features/commenting_spec.lua | 15 ++-- tests/features/show_delete_spec.lua | 11 +-- tests/unit/store_spec.lua | 53 ++++++++++++++ 8 files changed, 164 insertions(+), 125 deletions(-) create mode 100644 lua/scratch_comments/model/store.lua create mode 100644 tests/unit/store_spec.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index 6c72a87..af932a7 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -1,6 +1,7 @@ local context = require("scratch_comments.context") local render = require("scratch_comments.render") local state = require("scratch_comments.state") +local store = require("scratch_comments.model.store") local ui = require("scratch_comments.ui") local M = {} @@ -123,7 +124,7 @@ local function show(view, source_win) comment = view.comment, comment_title = ("comment (%d of %d)"):format(index_of(views, view.id) or 0, #views), on_save = function(text) - state.update(view.id, { comment = text }) + store.update(view.id, { comment = text }) ui.notify("Updated comment", "info") end, }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) @@ -200,13 +201,13 @@ function M.range(start_line, end_line, use_selection) comment = "", on_save = function(text) if added then - state.update(added.id, { comment = text }) + store.update(added.id, { comment = text }) return end if not vim.api.nvim_buf_is_valid(bufnr) then return end - added = state.add({ + added = store.add({ bufnr = bufnr, comment = text, file_path = file_path, @@ -215,7 +216,7 @@ function M.range(start_line, end_line, use_selection) }) window.id = added.id render.anchor(added, start_line, end_line, start_col, end_col) - render.draw_signs(bufnr, state.in_buffer(bufnr)) + render.draw_signs(bufnr, store.in_buffer(bufnr)) ui.notify("Added comment", "info") end, }, window) @@ -245,8 +246,10 @@ 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)) + render.clear_all(store.remove(function(candidate) + return candidate.id == comment.id + end)) + render.draw_signs(comment.bufnr, store.in_buffer(comment.bufnr)) ui.notify("Deleted comment", "info") end @@ -315,7 +318,7 @@ function M.delete_current() end local bufnr = vim.api.nvim_get_current_buf() - local orphans = vim.tbl_filter(render.is_orphaned, state.in_buffer(bufnr)) + local orphans = vim.tbl_filter(render.is_orphaned, store.in_buffer(bufnr)) if #orphans == 0 then ui.notify("No comment at cursor", "info") return diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 211e26a..a83a402 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -4,6 +4,7 @@ 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 store = require("scratch_comments.model.store") local ui = require("scratch_comments.ui") local M = {} @@ -78,7 +79,7 @@ end ---@return integer[] local function commented_buffers() local buffers = {} - for _, comment in ipairs(state.all()) do + for _, comment in ipairs(store.all()) do buffers[comment.bufnr] = true end return vim.tbl_keys(buffers) @@ -86,7 +87,7 @@ end ---@param bufnr integer local function redraw(bufnr) - render.draw_signs(bufnr, state.in_buffer(bufnr)) + render.draw_signs(bufnr, store.in_buffer(bufnr)) end ---@param on? boolean @@ -104,7 +105,7 @@ function M.clear() for _, bufnr in ipairs(commented_buffers()) do render.clear_buffer(bufnr) end - state.clear() + store.clear() ui.notify("Cleared comments", "info") end @@ -171,7 +172,9 @@ function M.setup() group = group, callback = function(args) render.clear_buffer(args.buf) - state.remove_buffer(args.buf) + store.remove(function(comment) + return comment.bufnr == args.buf + end) 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/state.lua b/lua/scratch_comments/state.lua index e400f08..ab1f5a5 100644 --- a/lua/scratch_comments/state.lua +++ b/lua/scratch_comments/state.lua @@ -1,16 +1,8 @@ local render = require("scratch_comments.render") +local store = require("scratch_comments.model.store") 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. @@ -18,10 +10,6 @@ local M = {} ---@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) @@ -37,22 +25,6 @@ local function by_position(a, b) 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 @@ -69,7 +41,7 @@ end ---@return ScratchCommentView[] function M.anchored() local views = {} - for _, comment in ipairs(items) do + for _, comment in ipairs(store.all()) 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]] @@ -86,7 +58,7 @@ end ---@return ScratchComment[] function M.orphans() - return vim.tbl_filter(render.is_orphaned, items) + return vim.tbl_filter(render.is_orphaned, store.all()) end ---@param predicate fun(comment: ScratchCommentView): boolean @@ -101,74 +73,4 @@ 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/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 6771531..7c2cd8a 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -1,6 +1,7 @@ local helpers = require("helpers") local scratch = require("scratch_comments") local state = require("scratch_comments.state") +local store = require("scratch_comments.model.store") local expect = MiniTest.expect local function only() @@ -149,6 +150,6 @@ describe("a file's comments", function() it("go with :bd", function() vim.cmd("bdelete") - expect.equality(#state.all(), 0) + expect.equality(#store.all(), 0) end) end) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 3ab1f54..3d6f058 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -1,6 +1,7 @@ local helpers = require("helpers") local scratch = require("scratch_comments") local state = require("scratch_comments.state") +local store = require("scratch_comments.model.store") local expect = MiniTest.expect before_each(function() @@ -13,7 +14,7 @@ describe(":Comment", function() vim.api.nvim_win_set_cursor(0, { 2, 0 }) vim.cmd("Comment") helpers.write("first comment") - expect.equality(#state.all(), 1) + expect.equality(#store.all(), 1) expect.equality(state.anchored()[1].start_line, 2) expect.equality(state.anchored()[1].comment, "first comment") end) @@ -24,7 +25,7 @@ describe(":Comment", function() vim.cmd("Comment") expect.equality(helpers.text(), "first comment") helpers.write("edited comment") - expect.equality(#state.all(), 1) + expect.equality(#store.all(), 1) expect.equality(state.anchored()[1].comment, "edited comment") end) @@ -33,7 +34,7 @@ describe(":Comment", function() helpers.write("on one") vim.cmd("1,3Comment") helpers.write("on one to three") - expect.equality(#state.all(), 2) + expect.equality(#store.all(), 2) expect.equality(state.anchored()[2].end_line, 3) end) end) @@ -58,15 +59,15 @@ describe("the comment window", function() vim.cmd("w") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first line", "second line, revised" }) vim.cmd("wq") - expect.equality(#state.all(), 1) - expect.equality(state.all()[1].comment, "first line\nsecond line, revised") + 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(#state.all(), 0) + expect.equality(#store.all(), 0) expect.equality(#helpers.floats(), 0) end) @@ -92,7 +93,7 @@ describe("the comment window", function() vim.api.nvim_set_current_win(helpers.floats()[1]) vim.cmd("q!") expect.equality(#helpers.floats(), 0) - expect.equality(#state.all(), 0) + expect.equality(#store.all(), 0) end) end) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index a13f931..220a055 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -1,5 +1,6 @@ local helpers = require("helpers") local state = require("scratch_comments.state") +local store = require("scratch_comments.model.store") local expect = MiniTest.expect before_each(function() @@ -24,7 +25,7 @@ describe(":CommentShow", function() helpers.pick(2) vim.cmd("CommentShow") helpers.write("picked the range") - expect.equality(#state.all(), 2) + expect.equality(#store.all(), 2) expect.equality(state.anchored()[2].comment, "picked the range") end) @@ -46,20 +47,20 @@ describe(":CommentDelete", function() it("deletes the picked comment", function() helpers.pick(1) vim.cmd("CommentDelete") - expect.equality(#state.all(), 1) + expect.equality(#store.all(), 1) expect.equality(state.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(#state.all(), 2) + 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(#state.all(), 2) + expect.equality(#store.all(), 2) expect.equality(helpers.notified()[#helpers.notified()], "No comment at cursor") end) end) @@ -68,7 +69,7 @@ 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(#state.all(), 0) + expect.equality(#store.all(), 0) expect.equality(#vim.api.nvim_buf_get_extmarks(0, namespace, 0, -1, {}), 0) 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) -- 2.54.0 From be805ea1bf8723e87e3edb05766d253f9cd6d252 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:02:30 -0700 Subject: [PATCH 09/22] Order comments on the same lines by their number Ids were compared as text, so comment-10 sorted before comment-2. They now compare by the number in the id, the order they were added. --- lua/scratch_comments/state.lua | 14 +++++++++--- tests/unit/state_spec.lua | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/unit/state_spec.lua diff --git a/lua/scratch_comments/state.lua b/lua/scratch_comments/state.lua index ab1f5a5..b57300a 100644 --- a/lua/scratch_comments/state.lua +++ b/lua/scratch_comments/state.lua @@ -10,9 +10,17 @@ local M = {} ---@field end_col? integer 0-based byte, exclusive. ---@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 -local function by_position(a, b) +---@return boolean +function M.by_position(a, b) if a.relative_path ~= b.relative_path then return a.relative_path < b.relative_path end @@ -22,7 +30,7 @@ local function by_position(a, b) if a.end_line ~= b.end_line then return a.end_line < b.end_line end - return a.id < b.id + return number(a.id) < number(b.id) end ---@param bufnr integer @@ -52,7 +60,7 @@ function M.anchored() table.insert(views, view) end end - table.sort(views, by_position) + table.sort(views, M.by_position) return views end diff --git a/tests/unit/state_spec.lua b/tests/unit/state_spec.lua new file mode 100644 index 0000000..7897a16 --- /dev/null +++ b/tests/unit/state_spec.lua @@ -0,0 +1,39 @@ +local state = require("scratch_comments.state") +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 views table[] +---@return string +local function ids(views) + table.sort(views, state.by_position) + return table.concat( + vim.tbl_map(function(v) + return v.id + end, views), + "," + ) +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) -- 2.54.0 From a5d15bd89a335ad6356fea45b77c78114f58553a Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:05:12 -0700 Subject: [PATCH 10/22] Describe locations in location.lua The window title's location format becomes location.describe, with title and fit beside it. A comment view is now a comment plus a location. --- lua/scratch_comments/annotate.lua | 34 +++++---------------- lua/scratch_comments/location.lua | 48 +++++++++++++++++++++++++++++ lua/scratch_comments/state.lua | 6 +--- lua/scratch_comments/ui.lua | 16 ++-------- tests/unit/location_spec.lua | 51 +++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 44 deletions(-) create mode 100644 lua/scratch_comments/location.lua create mode 100644 tests/unit/location_spec.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index af932a7..4c9f435 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -1,4 +1,5 @@ local context = require("scratch_comments.context") +local location = require("scratch_comments.location") local render = require("scratch_comments.render") local state = require("scratch_comments.state") local store = require("scratch_comments.model.store") @@ -46,30 +47,6 @@ local function cursor_comments() 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 - ---@class ScratchCommentWindow ---@field id? string unset until a new comment is saved ---@field bufnr integer @@ -118,7 +95,7 @@ end local function show(view, source_win) local views = file_views(view.bufnr) open({ - title = title(view.file_path, view.start_line, view.end_line, view.start_col, view.end_col), + title = location.title(view.file_path, view), context = vim.split(view.snippet, "\n"), filetype = vim.bo[view.bufnr].filetype, comment = view.comment, @@ -195,7 +172,12 @@ function M.range(start_line, end_line, use_selection) ---@type ScratchCommentWindow local window = { bufnr = bufnr, line = start_line, source_win = vim.api.nvim_get_current_win() } open({ - title = title(file_path, start_line, end_line, start_col, end_col), + title = location.title(file_path, { + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, + }), context = state.text(bufnr, start_line, end_line, start_col, end_col), filetype = vim.bo[bufnr].filetype, comment = "", 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/state.lua b/lua/scratch_comments/state.lua index b57300a..a581de8 100644 --- a/lua/scratch_comments/state.lua +++ b/lua/scratch_comments/state.lua @@ -3,11 +3,7 @@ local store = require("scratch_comments.model.store") local M = {} ----@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. +---@class ScratchCommentView : ScratchComment, ScratchLocation ---@field snippet string ---@param id string diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui.lua index b64f41c..3c80734 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui.lua @@ -1,3 +1,5 @@ +local location = require("scratch_comments.location") + local M = {} local mode_names = { @@ -32,18 +34,6 @@ 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.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 - ---@param lines string[] ---@param filetype string ---@return integer bufnr @@ -86,7 +76,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", }) 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) -- 2.54.0 From 2605e731e216902f6f958248cffc882caa4e8173 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:10:21 -0700 Subject: [PATCH 11/22] Rename context.lua to paths.lua It finds a file's git root and its path relative to that root, so the name says so. Its own trim gives way to vim.trim. --- lua/scratch_comments/annotate.lua | 4 +- .../{context.lua => paths.lua} | 6 +-- tests/unit/paths_spec.lua | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) rename lua/scratch_comments/{context.lua => paths.lua} (88%) create mode 100644 tests/unit/paths_spec.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index 4c9f435..9e2f0ef 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -1,5 +1,5 @@ -local context = require("scratch_comments.context") local location = require("scratch_comments.location") +local paths = require("scratch_comments.paths") local render = require("scratch_comments.render") local state = require("scratch_comments.state") local store = require("scratch_comments.model.store") @@ -166,7 +166,7 @@ function M.range(start_line, end_line, use_selection) end local file_path = vim.fn.fnamemodify(name, ":p") - local relative_path = context.relative_path(context.git_root(file_path), file_path) + local relative_path = paths.relative_path(paths.git_root(file_path), file_path) ---@type ScratchComment? local added ---@type ScratchCommentWindow 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/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) -- 2.54.0 From aaf7470d4cbc497326206047c76ecbc2d2e3f4db Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:12:15 -0700 Subject: [PATCH 12/22] Group the export modules in export/ markdown.lua, json.lua and the clipboard copy (export.lua) move into export/, unchanged. Unit specs check the whole markdown output and the json fields. --- .../{export.lua => export/clipboard.lua} | 0 lua/scratch_comments/{ => export}/json.lua | 0 .../{ => export}/markdown.lua | 0 lua/scratch_comments/init.lua | 6 +- tests/features/list_export_spec.lua | 2 +- tests/unit/json_spec.lua | 58 ++++++++++++ tests/unit/markdown_spec.lua | 93 +++++++++++++++++++ 7 files changed, 155 insertions(+), 4 deletions(-) rename lua/scratch_comments/{export.lua => export/clipboard.lua} (100%) rename lua/scratch_comments/{ => export}/json.lua (100%) rename lua/scratch_comments/{ => export}/markdown.lua (100%) create mode 100644 tests/unit/json_spec.lua create mode 100644 tests/unit/markdown_spec.lua 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/json.lua b/lua/scratch_comments/export/json.lua similarity index 100% rename from lua/scratch_comments/json.lua rename to lua/scratch_comments/export/json.lua diff --git a/lua/scratch_comments/markdown.lua b/lua/scratch_comments/export/markdown.lua similarity index 100% rename from lua/scratch_comments/markdown.lua rename to lua/scratch_comments/export/markdown.lua diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index a83a402..1380ef5 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,7 +1,7 @@ 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 clipboard = require("scratch_comments.export.clipboard") +local json = require("scratch_comments.export.json") +local markdown = require("scratch_comments.export.markdown") local render = require("scratch_comments.render") local state = require("scratch_comments.state") local store = require("scratch_comments.model.store") diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 1bbfe81..5a05bd7 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -1,4 +1,4 @@ -local clipboard = require("scratch_comments.export") +local clipboard = require("scratch_comments.export.clipboard") local helpers = require("helpers") local scratch = require("scratch_comments") local expect = MiniTest.expect 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/markdown_spec.lua b/tests/unit/markdown_spec.lua new file mode 100644 index 0000000..372eff1 --- /dev/null +++ b/tests/unit/markdown_spec.lua @@ -0,0 +1,93 @@ +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("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) -- 2.54.0 From 8ef9e7e0fbe29f4e602e5dfd4a0ef1216533e2e1 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:14:49 -0700 Subject: [PATCH 13/22] Split render.lua into anchors and signs model/anchors.lua keeps the extmarks that track where comments are. ui/signs.lua draws the signs from them and holds their visibility. --- lua/scratch_comments/annotate.lua | 13 ++-- lua/scratch_comments/init.lua | 17 +++-- .../{render.lua => model/anchors.lua} | 57 --------------- lua/scratch_comments/state.lua | 6 +- lua/scratch_comments/ui/signs.lua | 69 +++++++++++++++++++ 5 files changed, 89 insertions(+), 73 deletions(-) rename lua/scratch_comments/{render.lua => model/anchors.lua} (60%) create mode 100644 lua/scratch_comments/ui/signs.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index 9e2f0ef..b4ec585 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -1,7 +1,8 @@ +local anchors = require("scratch_comments.model.anchors") local location = require("scratch_comments.location") local paths = require("scratch_comments.paths") -local render = require("scratch_comments.render") local state = require("scratch_comments.state") +local signs = require("scratch_comments.ui.signs") local store = require("scratch_comments.model.store") local ui = require("scratch_comments.ui") @@ -197,8 +198,8 @@ function M.range(start_line, end_line, use_selection) charwise = start_col ~= nil, }) window.id = added.id - render.anchor(added, start_line, end_line, start_col, end_col) - render.draw_signs(bufnr, store.in_buffer(bufnr)) + anchors.anchor(added, start_line, end_line, start_col, end_col) + signs.draw(bufnr, store.in_buffer(bufnr)) ui.notify("Added comment", "info") end, }, window) @@ -228,10 +229,10 @@ end ---@param comment ScratchComment local function delete(comment) - render.clear_all(store.remove(function(candidate) + anchors.clear_all(store.remove(function(candidate) return candidate.id == comment.id end)) - render.draw_signs(comment.bufnr, store.in_buffer(comment.bufnr)) + signs.draw(comment.bufnr, store.in_buffer(comment.bufnr)) ui.notify("Deleted comment", "info") end @@ -300,7 +301,7 @@ function M.delete_current() end local bufnr = vim.api.nvim_get_current_buf() - local orphans = vim.tbl_filter(render.is_orphaned, store.in_buffer(bufnr)) + local orphans = vim.tbl_filter(anchors.is_orphaned, store.in_buffer(bufnr)) if #orphans == 0 then ui.notify("No comment at cursor", "info") return diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 1380ef5..b0a8a33 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,8 +1,9 @@ +local anchors = require("scratch_comments.model.anchors") local annotate = require("scratch_comments.annotate") local clipboard = require("scratch_comments.export.clipboard") local json = require("scratch_comments.export.json") local markdown = require("scratch_comments.export.markdown") -local render = require("scratch_comments.render") +local signs = require("scratch_comments.ui.signs") local state = require("scratch_comments.state") local store = require("scratch_comments.model.store") local ui = require("scratch_comments.ui") @@ -87,15 +88,15 @@ end ---@param bufnr integer local function redraw(bufnr) - render.draw_signs(bufnr, store.in_buffer(bufnr)) + signs.draw(bufnr, store.in_buffer(bufnr)) end ---@param on? boolean function M.toggle(on) if on == nil then - on = not render.is_visible() + on = not signs.is_visible() end - render.set_visible(on) + signs.set_visible(on) for _, bufnr in ipairs(commented_buffers()) do redraw(bufnr) end @@ -103,14 +104,15 @@ end function M.clear() for _, bufnr in ipairs(commented_buffers()) do - render.clear_buffer(bufnr) + anchors.clear_buffer(bufnr) + signs.clear_buffer(bufnr) end store.clear() ui.notify("Cleared comments", "info") end function M.setup() - render.setup() + signs.setup() vim.api.nvim_create_user_command("Comment", function(ctx) annotate.range(ctx.line1, ctx.line2, ctx.range == 2) @@ -171,7 +173,8 @@ function M.setup() vim.api.nvim_create_autocmd("BufDelete", { group = group, callback = function(args) - render.clear_buffer(args.buf) + anchors.clear_buffer(args.buf) + signs.clear_buffer(args.buf) store.remove(function(comment) return comment.bufnr == args.buf end) diff --git a/lua/scratch_comments/render.lua b/lua/scratch_comments/model/anchors.lua similarity index 60% rename from lua/scratch_comments/render.lua rename to lua/scratch_comments/model/anchors.lua index 7fa435b..f73a856 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,44 +63,6 @@ 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) if @@ -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/state.lua b/lua/scratch_comments/state.lua index a581de8..ac2d1b7 100644 --- a/lua/scratch_comments/state.lua +++ b/lua/scratch_comments/state.lua @@ -1,4 +1,4 @@ -local render = require("scratch_comments.render") +local anchors = require("scratch_comments.model.anchors") local store = require("scratch_comments.model.store") local M = {} @@ -46,7 +46,7 @@ end function M.anchored() local views = {} for _, comment in ipairs(store.all()) do - local start_line, end_line, start_col, end_col = render.range(comment) + 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 @@ -62,7 +62,7 @@ end ---@return ScratchComment[] function M.orphans() - return vim.tbl_filter(render.is_orphaned, store.all()) + return vim.tbl_filter(anchors.is_orphaned, store.all()) end ---@param predicate fun(comment: ScratchCommentView): boolean diff --git a/lua/scratch_comments/ui/signs.lua b/lua/scratch_comments/ui/signs.lua new file mode 100644 index 0000000..36996c2 --- /dev/null +++ b/lua/scratch_comments/ui/signs.lua @@ -0,0 +1,69 @@ +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 + +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 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 -- 2.54.0 From b60ab661616de0d79ee4fc1f94c014e4ac319ea9 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:22:27 -0700 Subject: [PATCH 14/22] Change comments only through comments.lua Adding, editing, deleting, clearing and forgetting a buffer's comments go through comments.lua, which keeps anchors and signs in sync, so the hand-written sign redraws go. state.lua becomes model/views.lua. --- lua/scratch_comments/annotate.lua | 87 +++++++++---------- lua/scratch_comments/comments.lua | 82 +++++++++++++++++ lua/scratch_comments/init.lua | 44 ++-------- .../{state.lua => model/views.lua} | 0 tests/features/anchoring_spec.lua | 22 ++--- tests/features/commenting_spec.lua | 12 +-- tests/features/show_delete_spec.lua | 6 +- tests/features/spans_spec.lua | 22 ++--- tests/unit/{state_spec.lua => views_spec.lua} | 10 +-- 9 files changed, 170 insertions(+), 115 deletions(-) create mode 100644 lua/scratch_comments/comments.lua rename lua/scratch_comments/{state.lua => model/views.lua} (100%) rename tests/unit/{state_spec.lua => views_spec.lua} (84%) diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index b4ec585..ef9c70a 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -1,9 +1,7 @@ -local anchors = require("scratch_comments.model.anchors") +local comments = require("scratch_comments.comments") local location = require("scratch_comments.location") local paths = require("scratch_comments.paths") -local state = require("scratch_comments.state") -local signs = require("scratch_comments.ui.signs") -local store = require("scratch_comments.model.store") +local views = require("scratch_comments.model.views") local ui = require("scratch_comments.ui") local M = {} @@ -15,7 +13,7 @@ local M = {} ---@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 views.find(function(comment) return comment.bufnr == bufnr and comment.start_line == start_line and comment.end_line == end_line @@ -43,7 +41,7 @@ end 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 views.find_all(function(comment) return comment.bufnr == bufnr and comment.start_line <= line and line <= comment.end_line end) end @@ -77,14 +75,14 @@ end local function file_views(bufnr) return vim.tbl_filter(function(view) return view.bufnr == bufnr - end, state.anchored()) + end, views.anchored()) end ----@param views ScratchCommentView[] +---@param in_file ScratchCommentView[] ---@param id? string ---@return integer? -local function index_of(views, id) - for i, view in ipairs(views) do +local function index_of(in_file, id) + for i, view in ipairs(in_file) do if view.id == id then return i end @@ -94,15 +92,15 @@ end ---@param view ScratchCommentView ---@param source_win integer local function show(view, source_win) - local views = file_views(view.bufnr) + local in_file = file_views(view.bufnr) 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(index_of(views, view.id) or 0, #views), + comment_title = ("comment (%d of %d)"):format(index_of(in_file, view.id) or 0, #in_file), on_save = function(text) - store.update(view.id, { comment = text }) + comments.edit(view.id, text) ui.notify("Updated comment", "info") end, }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) @@ -115,23 +113,23 @@ local function cycle(window, direction) ui.notify("Save or discard the comment first", "warn") return end - local views = file_views(window.bufnr) - if #views == 0 then + local in_file = file_views(window.bufnr) + if #in_file == 0 then return end - local index = index_of(views, window.id) + local index = index_of(in_file, window.id) local target if index then - target = views[(index - 1 + direction) % #views + 1] + target = in_file[(index - 1 + direction) % #in_file + 1] elseif direction == 1 then - target = vim.iter(views):find(function(view) + target = vim.iter(in_file):find(function(view) return view.start_line > window.line - end) or views[1] + end) or in_file[1] else - target = vim.iter(views):rev():find(function(view) + target = vim.iter(in_file):rev():find(function(view) return view.start_line < window.line - end) or views[#views] + end) or in_file[#in_file] end vim.api.nvim_win_close(window.comment_win, true) @@ -179,27 +177,29 @@ function M.range(start_line, end_line, use_selection) start_col = start_col, end_col = end_col, }), - context = state.text(bufnr, start_line, end_line, start_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 - store.update(added.id, { comment = text }) + comments.edit(added.id, text) return end if not vim.api.nvim_buf_is_valid(bufnr) then return end - added = store.add({ + added = comments.add({ bufnr = bufnr, comment = text, file_path = file_path, relative_path = relative_path, - charwise = start_col ~= nil, + }, { + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, }) window.id = added.id - anchors.anchor(added, start_line, end_line, start_col, end_col) - signs.draw(bufnr, store.in_buffer(bufnr)) ui.notify("Added comment", "info") end, }, window) @@ -213,14 +213,14 @@ local function describe(view) return "lines " .. lines .. ": " .. ui.summary(view.comment) end ----@param views ScratchCommentView[] +---@param candidates ScratchCommentView[] ---@param callback fun(view: ScratchCommentView) -local function choose(views, callback) - if #views == 1 then - callback(views[1]) +local function choose(candidates, callback) + if #candidates == 1 then + callback(candidates[1]) return end - vim.ui.select(views, { prompt = "Comment: ", format_item = describe }, function(view) + vim.ui.select(candidates, { prompt = "Comment: ", format_item = describe }, function(view) if view then callback(view) end @@ -229,21 +229,18 @@ end ---@param comment ScratchComment local function delete(comment) - anchors.clear_all(store.remove(function(candidate) - return candidate.id == comment.id - end)) - signs.draw(comment.bufnr, store.in_buffer(comment.bufnr)) + comments.delete(comment) ui.notify("Deleted comment", "info") end function M.show_current() - local views = cursor_comments() - if #views == 0 then + local at_cursor = cursor_comments() + if #at_cursor == 0 then ui.notify("No comment at cursor", "info") return end local source_win = vim.api.nvim_get_current_win() - choose(views, function(view) + choose(at_cursor, function(view) show(view, source_win) end) end @@ -258,7 +255,7 @@ function M.jump(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(state.anchored()) do + for _, view in ipairs(views.anchored()) do if view.bufnr == bufnr then starts[view.start_line] = true end @@ -294,14 +291,16 @@ function M.jump(direction) end function M.delete_current() - local views = cursor_comments() - if #views > 0 then - choose(views, delete) + 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(anchors.is_orphaned, store.in_buffer(bufnr)) + local orphans = vim.tbl_filter(function(comment) + return comment.bufnr == bufnr + end, views.orphans()) if #orphans == 0 then ui.notify("No comment at cursor", "info") return diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua new file mode 100644 index 0000000..13ce524 --- /dev/null +++ b/lua/scratch_comments/comments.lua @@ -0,0 +1,82 @@ +local anchors = require("scratch_comments.model.anchors") +local signs = require("scratch_comments.ui.signs") +local store = require("scratch_comments.model.store") + +local M = {} + +---@param bufnr integer +function M.redraw(bufnr) + signs.draw(bufnr, store.in_buffer(bufnr)) +end + +---@return integer[] +function M.buffers() + local buffers = {} + for _, comment in ipairs(store.all()) do + buffers[comment.bufnr] = true + end + return vim.tbl_keys(buffers) +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(M.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(M.buffers()) do + M.redraw(bufnr) + end +end + +return M diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index b0a8a33..850217c 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,11 +1,10 @@ -local anchors = require("scratch_comments.model.anchors") local annotate = require("scratch_comments.annotate") +local comments = require("scratch_comments.comments") local clipboard = require("scratch_comments.export.clipboard") local json = require("scratch_comments.export.json") local markdown = require("scratch_comments.export.markdown") local signs = require("scratch_comments.ui.signs") -local state = require("scratch_comments.state") -local store = require("scratch_comments.model.store") +local views = require("scratch_comments.model.views") local ui = require("scratch_comments.ui") local M = {} @@ -36,14 +35,14 @@ function M.delete() end function M.list() - ui.list(state.anchored(), state.orphans()) + ui.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()) + return render_format(views.anchored(), views.orphans()) end ---@param format? string @@ -56,7 +55,7 @@ function M.export(format, in_buffer) return end - local items, orphans = state.anchored(), state.orphans() + local items, orphans = views.anchored(), views.orphans() local count = #items + #orphans if count == 0 then ui.notify("No comments to export", "info") @@ -77,37 +76,16 @@ function M.export(format, in_buffer) ui.notify("Copied " .. count .. " comment(s)", "info") end ----@return integer[] -local function commented_buffers() - local buffers = {} - for _, comment in ipairs(store.all()) do - buffers[comment.bufnr] = true - end - return vim.tbl_keys(buffers) -end - ----@param bufnr integer -local function redraw(bufnr) - signs.draw(bufnr, store.in_buffer(bufnr)) -end - ---@param on? boolean function M.toggle(on) if on == nil then on = not signs.is_visible() end - signs.set_visible(on) - for _, bufnr in ipairs(commented_buffers()) do - redraw(bufnr) - end + comments.show_signs(on) end function M.clear() - for _, bufnr in ipairs(commented_buffers()) do - anchors.clear_buffer(bufnr) - signs.clear_buffer(bufnr) - end - store.clear() + comments.clear() ui.notify("Cleared comments", "info") end @@ -166,18 +144,14 @@ function M.setup() vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { group = group, callback = function(args) - redraw(args.buf) + comments.redraw(args.buf) end, }) vim.api.nvim_create_autocmd("BufDelete", { group = group, callback = function(args) - anchors.clear_buffer(args.buf) - signs.clear_buffer(args.buf) - store.remove(function(comment) - return comment.bufnr == args.buf - end) + comments.forget_buffer(args.buf) end, }) end diff --git a/lua/scratch_comments/state.lua b/lua/scratch_comments/model/views.lua similarity index 100% rename from lua/scratch_comments/state.lua rename to lua/scratch_comments/model/views.lua diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 7c2cd8a..c16760d 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -1,11 +1,11 @@ local helpers = require("helpers") local scratch = require("scratch_comments") -local state = require("scratch_comments.state") +local views = require("scratch_comments.model.views") local store = require("scratch_comments.model.store") local expect = MiniTest.expect local function only() - return state.anchored()[1] + return views.anchored()[1] end before_each(function() @@ -39,13 +39,13 @@ describe("a line comment", function() 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(#state.anchored(), 1) + 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(#state.orphans(), 0) + expect.equality(#views.orphans(), 0) end) end) @@ -76,12 +76,12 @@ describe("a range comment", function() 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(#state.orphans(), 0) + expect.equality(#views.orphans(), 0) end) it("shrinks when a middle line is deleted", function() vim.cmd("3d") - expect.equality(#state.orphans(), 0) + expect.equality(#views.orphans(), 0) expect.equality(only().end_line, 3) end) end) @@ -97,8 +97,8 @@ describe("an orphan", function() end) it("is what a comment becomes when its lines are deleted", function() - expect.equality(#state.anchored(), 0) - expect.equality(#state.orphans(), 1) + expect.equality(#views.anchored(), 0) + expect.equality(#views.orphans(), 1) end) it("has no sign", function() @@ -123,7 +123,7 @@ describe("an orphan", function() it("is anchored again by undo", function() helpers.undo_break() vim.cmd("silent undo") - expect.equality(#state.orphans(), 0) + expect.equality(#views.orphans(), 0) expect.equality(only().start_line, 3) end) @@ -132,7 +132,7 @@ describe("an orphan", function() vim.api.nvim_win_set_cursor(0, { 1, 0 }) vim.cmd("CommentDelete") expect.equality(#helpers.offered, 1) - expect.equality(#state.orphans(), 0) + expect.equality(#views.orphans(), 0) end) end) @@ -145,7 +145,7 @@ describe("a file's comments", function() it("survive :e!", function() vim.cmd("edit!") - expect.equality(#state.anchored(), 1) + expect.equality(#views.anchored(), 1) end) it("go with :bd", function() diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 3d6f058..ecedf22 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -1,6 +1,6 @@ local helpers = require("helpers") local scratch = require("scratch_comments") -local state = require("scratch_comments.state") +local views = require("scratch_comments.model.views") local store = require("scratch_comments.model.store") local expect = MiniTest.expect @@ -15,8 +15,8 @@ describe(":Comment", function() vim.cmd("Comment") helpers.write("first comment") expect.equality(#store.all(), 1) - expect.equality(state.anchored()[1].start_line, 2) - expect.equality(state.anchored()[1].comment, "first comment") + 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() @@ -26,7 +26,7 @@ describe(":Comment", function() expect.equality(helpers.text(), "first comment") helpers.write("edited comment") expect.equality(#store.all(), 1) - expect.equality(state.anchored()[1].comment, "edited comment") + expect.equality(views.anchored()[1].comment, "edited comment") end) it("on a range adds a separate comment over those lines", function() @@ -35,7 +35,7 @@ describe(":Comment", function() vim.cmd("1,3Comment") helpers.write("on one to three") expect.equality(#store.all(), 2) - expect.equality(state.anchored()[2].end_line, 3) + expect.equality(views.anchored()[2].end_line, 3) end) end) @@ -106,7 +106,7 @@ describe("scratch.add()", function() helpers.write("on lines 4-5") local ranges = vim.tbl_map(function(view) return view.start_line .. "-" .. view.end_line - end, state.anchored()) + end, views.anchored()) expect.equality(table.concat(ranges, ","), "2-2,4-5") end) end) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 220a055..928d3e9 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -1,5 +1,5 @@ local helpers = require("helpers") -local state = require("scratch_comments.state") +local views = require("scratch_comments.model.views") local store = require("scratch_comments.model.store") local expect = MiniTest.expect @@ -26,7 +26,7 @@ describe(":CommentShow", function() vim.cmd("CommentShow") helpers.write("picked the range") expect.equality(#store.all(), 2) - expect.equality(state.anchored()[2].comment, "picked the range") + expect.equality(views.anchored()[2].comment, "picked the range") end) it("opens nothing when the picker is cancelled", function() @@ -48,7 +48,7 @@ describe(":CommentDelete", function() helpers.pick(1) vim.cmd("CommentDelete") expect.equality(#store.all(), 1) - expect.equality(state.anchored()[1].comment, "on one to three") + expect.equality(views.anchored()[1].comment, "on one to three") end) it("deletes nothing when the picker is cancelled", function() diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua index 2652e18..3e2395c 100644 --- a/tests/features/spans_spec.lua +++ b/tests/features/spans_spec.lua @@ -1,6 +1,6 @@ local helpers = require("helpers") local scratch = require("scratch_comments") -local state = require("scratch_comments.state") +local views = require("scratch_comments.model.views") local expect = MiniTest.expect ---@param keys string a visual selection, such as "viw" @@ -30,7 +30,7 @@ describe("a charwise selection", function() it("makes a span over exactly the selected text", function() helpers.write("on quick") - local quick = state.anchored()[1] + local quick = views.anchored()[1] expect.equality(quick.snippet, "quick") expect.equality(quick.start_col, 4) expect.equality(quick.end_col, 9) @@ -43,7 +43,7 @@ describe("a charwise selection", function() vim.cmd("1Comment") expect.equality(helpers.text(), "on the line") vim.cmd("quit") - expect.equality(#state.anchored(), 2) + expect.equality(#views.anchored(), 2) end) end) @@ -55,13 +55,13 @@ describe("a span", function() select("viw") vim.cmd("'<,'>Comment") helpers.write("on quick") - quick = state.anchored()[1] + quick = views.anchored()[1] end) local function current() return vim.tbl_filter(function(view) return view.id == quick.id - end, state.anchored())[1] + end, views.anchored())[1] end it("follows its text", function() @@ -73,7 +73,7 @@ describe("a span", function() helpers.undo_break() vim.api.nvim_buf_set_text(0, 0, 4, 0, 9, { "slow" }) expect.equality(current(), nil) - expect.equality(#state.orphans(), 1) + expect.equality(#views.orphans(), 1) helpers.undo_break() vim.cmd("silent undo") expect.equality(current().snippet, "quick") @@ -92,7 +92,7 @@ describe("a selection", function() select("v3l") vim.cmd("'<,'>Comment") helpers.write("on café") - expect.equality(state.anchored()[1].snippet, "café") + expect.equality(views.anchored()[1].snippet, "café") end) it("to $ ends at the end of the line", function() @@ -100,7 +100,7 @@ describe("a selection", function() select("v$") vim.cmd("'<,'>Comment") helpers.write("to the end") - expect.equality(state.anchored()[1].snippet, "two") + expect.equality(views.anchored()[1].snippet, "two") end) it("can cross lines, titled with line:col", function() @@ -109,7 +109,7 @@ describe("a selection", function() vim.cmd("'<,'>Comment") expect.equality(lines_title(), " spans.md [lines 3:5–4:5] ") helpers.write("across lines") - expect.equality(state.anchored()[1].snippet, "two\nthree") + expect.equality(views.anchored()[1].snippet, "two\nthree") end) it("that is linewise makes a line comment", function() @@ -118,8 +118,8 @@ describe("a selection", function() vim.cmd("'<,'>Comment") expect.equality(lines_title(), " spans.md [line 3] ") helpers.write("whole line") - expect.equality(state.anchored()[1].start_col, nil) - expect.equality(state.anchored()[1].snippet, "one two") + expect.equality(views.anchored()[1].start_col, nil) + expect.equality(views.anchored()[1].snippet, "one two") end) end) diff --git a/tests/unit/state_spec.lua b/tests/unit/views_spec.lua similarity index 84% rename from tests/unit/state_spec.lua rename to tests/unit/views_spec.lua index 7897a16..20072f6 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/views_spec.lua @@ -1,4 +1,4 @@ -local state = require("scratch_comments.state") +local views = require("scratch_comments.model.views") local expect = MiniTest.expect ---@param id string @@ -13,14 +13,14 @@ local function view(id, relative_path, start_line) } end ----@param views table[] +---@param list table[] ---@return string -local function ids(views) - table.sort(views, state.by_position) +local function ids(list) + table.sort(list, views.by_position) return table.concat( vim.tbl_map(function(v) return v.id - end, views), + end, list), "," ) end -- 2.54.0 From 966409c75933b603622d1aad76352dec893a7481 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:25:32 -0700 Subject: [PATCH 15/22] Move the comment window and notifications into ui/ ui/window.lua has the two-pane frame and tracks the open comment window, so the frame no longer needs an on_close hook. ui/notify.lua replaces notify(message, kind) with info, warn and error. --- lua/scratch_comments/annotate.lua | 79 ++++++--------- lua/scratch_comments/init.lua | 13 +-- lua/scratch_comments/ui.lua | 148 +-------------------------- lua/scratch_comments/ui/notify.lua | 27 +++++ lua/scratch_comments/ui/window.lua | 156 +++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 202 deletions(-) create mode 100644 lua/scratch_comments/ui/notify.lua create mode 100644 lua/scratch_comments/ui/window.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index ef9c70a..ceb36ea 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -2,7 +2,9 @@ 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 ui = require("scratch_comments.ui") +local window = require("scratch_comments.ui.window") local M = {} @@ -46,30 +48,6 @@ local function cursor_comments() end) 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 - ----@type ScratchCommentWindow? -local open_window - ----@param frame ScratchFrame ----@param window ScratchCommentWindow -local function open(frame, window) - local comment_win - frame.on_close = function() - if open_window and open_window.comment_win == comment_win then - open_window = nil - end - end - comment_win = ui.open_frame(frame) - window.comment_win = comment_win - open_window = window -end - ---@param bufnr integer ---@return ScratchCommentView[] local function file_views(bufnr) @@ -93,7 +71,7 @@ end ---@param source_win integer local function show(view, source_win) local in_file = file_views(view.bufnr) - open({ + window.open({ title = location.title(view.file_path, view), context = vim.split(view.snippet, "\n"), filetype = vim.bo[view.bufnr].filetype, @@ -101,45 +79,45 @@ local function show(view, source_win) comment_title = ("comment (%d of %d)"):format(index_of(in_file, view.id) or 0, #in_file), on_save = function(text) comments.edit(view.id, text) - ui.notify("Updated comment", "info") + notify.info("Updated comment") end, }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) end ----@param window ScratchCommentWindow +---@param current ScratchCommentWindow ---@param direction 1|-1 -local function cycle(window, direction) - if vim.bo[vim.api.nvim_win_get_buf(window.comment_win)].modified then - ui.notify("Save or discard the comment first", "warn") +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 = file_views(window.bufnr) + local in_file = file_views(current.bufnr) if #in_file == 0 then return end - local index = index_of(in_file, window.id) + local index = 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 > window.line + 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 < window.line + return view.start_line < current.line end) or in_file[#in_file] end - vim.api.nvim_win_close(window.comment_win, true) - if vim.api.nvim_win_is_valid(window.source_win) then - vim.api.nvim_win_call(window.source_win, function() + 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, window.source_win) + show(target, current.source_win) end ---@param start_line integer @@ -149,7 +127,7 @@ 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") + 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) @@ -169,8 +147,8 @@ function M.range(start_line, end_line, use_selection) ---@type ScratchComment? local added ---@type ScratchCommentWindow - local window = { bufnr = bufnr, line = start_line, source_win = vim.api.nvim_get_current_win() } - open({ + 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, @@ -199,10 +177,10 @@ function M.range(start_line, end_line, use_selection) start_col = start_col, end_col = end_col, }) - window.id = added.id - ui.notify("Added comment", "info") + record.id = added.id + notify.info("Added comment") end, - }, window) + }, record) end ---@param view ScratchCommentView @@ -230,13 +208,13 @@ end ---@param comment ScratchComment local function delete(comment) comments.delete(comment) - ui.notify("Deleted comment", "info") + notify.info("Deleted comment") end function M.show_current() local at_cursor = cursor_comments() if #at_cursor == 0 then - ui.notify("No comment at cursor", "info") + notify.info("No comment at cursor") return end local source_win = vim.api.nvim_get_current_win() @@ -247,8 +225,9 @@ end ---@param direction 1|-1 function M.jump(direction) - if open_window and vim.api.nvim_win_is_valid(open_window.comment_win) then - cycle(open_window, direction) + local current = window.current() + if current then + cycle(current, direction) return end @@ -262,7 +241,7 @@ function M.jump(direction) end local lines = vim.tbl_keys(starts) if #lines == 0 then - ui.notify("No comments in this buffer", "info") + notify.info("No comments in this buffer") return end table.sort(lines) @@ -302,7 +281,7 @@ function M.delete_current() return comment.bufnr == bufnr end, views.orphans()) if #orphans == 0 then - ui.notify("No comment at cursor", "info") + notify.info("No comment at cursor") return end diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 850217c..d937e22 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -5,6 +5,7 @@ local json = require("scratch_comments.export.json") local markdown = require("scratch_comments.export.markdown") local signs = require("scratch_comments.ui.signs") local views = require("scratch_comments.model.views") +local notify = require("scratch_comments.ui.notify") local ui = require("scratch_comments.ui") local M = {} @@ -51,14 +52,14 @@ 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") + notify.error("Unknown format: " .. format) return end local items, orphans = views.anchored(), views.orphans() local count = #items + #orphans if count == 0 then - ui.notify("No comments to export", "info") + notify.info("No comments to export") return end @@ -69,11 +70,11 @@ function M.export(format, in_buffer) end if not clipboard.copy(text) then - ui.notify("Could not copy to the clipboard; no provider configured?", "error") + notify.error("Could not copy to the clipboard; no provider configured?") return end - ui.notify("Copied " .. count .. " comment(s)", "info") + notify.info("Copied " .. count .. " comment(s)") end ---@param on? boolean @@ -86,7 +87,7 @@ end function M.clear() comments.clear() - ui.notify("Cleared comments", "info") + notify.info("Cleared comments") end function M.setup() @@ -122,7 +123,7 @@ function M.setup() 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") + notify.error("Usage: :CommentToggle [on|off]") return end if choice == nil then diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui.lua index 3c80734..1b9fafd 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui.lua @@ -1,157 +1,13 @@ -local location = require("scratch_comments.location") +local notify = require("scratch_comments.ui.notify") local M = {} -local mode_names = { - n = "NORMAL", - i = "INSERT", - v = "VISUAL", - V = "VISUAL", - ["\22"] = "VISUAL", - c = "COMMAND", - R = "REPLACE", -} - -local function level(name) - return vim.log.levels[(name or "info"):upper()] or vim.log.levels.INFO -end - ----@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 ----@param lines string[] ----@param filetype string ----@return integer bufnr -local function frame_buffer(lines, filetype) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.bo[bufnr].modified = false - vim.bo[bufnr].bufhidden = "wipe" - vim.bo[bufnr].filetype = filetype - return bufnr -end - ----@class ScratchFrame ----@field title string ----@field context string[] ----@field filetype string ----@field comment string ----@field on_save fun(text: string) ----@field on_close? fun() ----@field comment_title? string - ----@param frame ScratchFrame ----@return integer comment_win -function M.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)) - local comment_height = math.min(math.max(#comment_lines, 5), math.floor(vim.o.lines * 0.3)) - -- Each pane has a top and a bottom border line. - local row = math.floor((vim.o.lines - context_height - comment_height - 4) / 2) - local col = math.floor((vim.o.columns - width) / 2) - local border = (vim.o.winborder == "" or vim.o.winborder == "none") and "rounded" or nil - - local context_buf = frame_buffer(frame.context, frame.filetype) - vim.bo[context_buf].modifiable = false - local context_win = vim.api.nvim_open_win(context_buf, false, { - relative = "editor", - row = row, - col = col, - width = width, - height = context_height, - border = border, - title = " " .. location.fit(frame.title, width - 2) .. " ", - title_pos = "center", - style = "minimal", - }) - - local comment_buf = frame_buffer(comment_lines, "markdown") - local comment_win = vim.api.nvim_open_win(comment_buf, true, { - relative = "editor", - row = row + context_height + 2, - col = col, - width = width, - height = comment_height, - border = border, - 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,LineNr:Normal" - vim.wo[win].statuscolumn = " " - end - - vim.api.nvim_create_autocmd("WinClosed", { - pattern = tostring(comment_win), - once = true, - callback = function() - pcall(vim.api.nvim_win_close, context_win, true) - if frame.on_close then - frame.on_close() - end - end, - }) - vim.api.nvim_create_autocmd("WinClosed", { - pattern = tostring(context_win), - once = true, - callback = function() - -- Not forced, so an unsaved comment stays open instead of being lost. - pcall(vim.api.nvim_win_close, comment_win, false) - 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 = " " .. (frame.comment_title or "comment") .. " [" .. name .. "] ", - title_pos = "center", - }) - vim.cmd.redraw() - end - end - show_mode() - vim.api.nvim_create_autocmd("ModeChanged", { buffer = comment_buf, callback = show_mode }) - - vim.bo[comment_buf].buftype = "acwrite" - vim.api.nvim_buf_set_name(comment_buf, "scratch-comments://" .. comment_buf) - if frame.comment == "" then - vim.cmd.startinsert() - end - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = comment_buf, - callback = function() - local lines = vim.api.nvim_buf_get_lines(comment_buf, 0, -1, false) - local text = vim.trim(table.concat(lines, "\n")) - if text ~= "" then - frame.on_save(text) - end - vim.bo[comment_buf].modified = false - end, - }) - return comment_win -end - ---@param text string ---@param filetype string function M.open_scratch(text, filetype) @@ -169,7 +25,7 @@ end ---@param orphans ScratchComment[] function M.list(items, orphans) if #items + #orphans == 0 then - M.notify("No comments", "info") + notify.info("No comments") return end 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/window.lua b/lua/scratch_comments/ui/window.lua new file mode 100644 index 0000000..4f37ef9 --- /dev/null +++ b/lua/scratch_comments/ui/window.lua @@ -0,0 +1,156 @@ +local location = require("scratch_comments.location") + +local M = {} + +local mode_names = { + n = "NORMAL", + i = "INSERT", + v = "VISUAL", + V = "VISUAL", + ["\22"] = "VISUAL", + c = "COMMAND", + R = "REPLACE", +} + +---@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 + +---@type ScratchCommentWindow? +local current + +---@param lines string[] +---@param filetype string +---@return integer bufnr +local function frame_buffer(lines, filetype) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].filetype = filetype + return bufnr +end + +---@class ScratchFrame +---@field title string +---@field context string[] +---@field filetype string +---@field comment string +---@field on_save fun(text: string) +---@field comment_title? string + +---@param frame ScratchFrame +---@return integer comment_win +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)) + local comment_height = math.min(math.max(#comment_lines, 5), math.floor(vim.o.lines * 0.3)) + -- Each pane has a top and a bottom border line. + local row = math.floor((vim.o.lines - context_height - comment_height - 4) / 2) + local col = math.floor((vim.o.columns - width) / 2) + local border = (vim.o.winborder == "" or vim.o.winborder == "none") and "rounded" or nil + + local context_buf = frame_buffer(frame.context, frame.filetype) + vim.bo[context_buf].modifiable = false + local context_win = vim.api.nvim_open_win(context_buf, false, { + relative = "editor", + row = row, + col = col, + width = width, + height = context_height, + border = border, + title = " " .. location.fit(frame.title, width - 2) .. " ", + title_pos = "center", + style = "minimal", + }) + + local comment_buf = frame_buffer(comment_lines, "markdown") + local comment_win = vim.api.nvim_open_win(comment_buf, true, { + relative = "editor", + row = row + context_height + 2, + col = col, + width = width, + height = comment_height, + border = border, + 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,LineNr:Normal" + vim.wo[win].statuscolumn = " " + end + + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(comment_win), + once = true, + callback = function() + pcall(vim.api.nvim_win_close, context_win, true) + if current and current.comment_win == comment_win then + current = nil + end + end, + }) + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(context_win), + once = true, + callback = function() + -- Not forced, so an unsaved comment stays open instead of being lost. + pcall(vim.api.nvim_win_close, comment_win, false) + 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 = " " .. (frame.comment_title or "comment") .. " [" .. name .. "] ", + title_pos = "center", + }) + vim.cmd.redraw() + end + end + show_mode() + vim.api.nvim_create_autocmd("ModeChanged", { buffer = comment_buf, callback = show_mode }) + + vim.bo[comment_buf].buftype = "acwrite" + vim.api.nvim_buf_set_name(comment_buf, "scratch-comments://" .. comment_buf) + if frame.comment == "" then + vim.cmd.startinsert() + end + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = comment_buf, + callback = function() + local lines = vim.api.nvim_buf_get_lines(comment_buf, 0, -1, false) + local text = vim.trim(table.concat(lines, "\n")) + if text ~= "" then + frame.on_save(text) + end + vim.bo[comment_buf].modified = false + end, + }) + return comment_win +end + +---@param frame ScratchFrame +---@param window ScratchCommentWindow +function M.open(frame, window) + window.comment_win = open_frame(frame) + current = window +end + +---@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 +end + +return M -- 2.54.0 From eb4e08c186526e68327b18eb3ce559ef414f2f50 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:36:50 -0700 Subject: [PATCH 16/22] Move next and prev into navigate.lua navigate.jump either cycles the open comment window or moves the cursor to the next comment's line. Cycling shows a comment, so navigate calls annotate.show. The buffer's views and index_of move into model/views. --- lua/scratch_comments/annotate.lua | 111 ++------------------------- lua/scratch_comments/init.lua | 5 +- lua/scratch_comments/model/views.lua | 19 +++++ lua/scratch_comments/navigate.lua | 92 ++++++++++++++++++++++ 4 files changed, 119 insertions(+), 108 deletions(-) create mode 100644 lua/scratch_comments/navigate.lua diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/annotate.lua index ceb36ea..97a7634 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/annotate.lua @@ -48,35 +48,16 @@ local function cursor_comments() end) end ----@param bufnr integer ----@return ScratchCommentView[] -local function file_views(bufnr) - return vim.tbl_filter(function(view) - return view.bufnr == bufnr - end, views.anchored()) -end - ----@param in_file ScratchCommentView[] ----@param id? string ----@return integer? -local function index_of(in_file, id) - for i, view in ipairs(in_file) 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 in_file = file_views(view.bufnr) +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(index_of(in_file, view.id) or 0, #in_file), + 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") @@ -84,42 +65,6 @@ local function show(view, source_win) }, { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win }) end ----@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 = file_views(current.bufnr) - if #in_file == 0 then - return - end - - local index = 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 - show(target, current.source_win) -end - ---@param start_line integer ---@param end_line integer ---@param use_selection? boolean @@ -138,7 +83,7 @@ function M.range(start_line, end_line, use_selection) local existing = find_anchor(bufnr, start_line, end_line, start_col, end_col) if existing then - show(existing, vim.api.nvim_get_current_win()) + M.show(existing, vim.api.nvim_get_current_win()) return end @@ -219,56 +164,10 @@ function M.show_current() end local source_win = vim.api.nvim_get_current_win() choose(at_cursor, function(view) - show(view, source_win) + M.show(view, source_win) end) end ----@param direction 1|-1 -function M.jump(direction) - local current = window.current() - if current then - cycle(current, 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(views.anchored()) do - if view.bufnr == bufnr then - starts[view.start_line] = true - end - 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 - function M.delete_current() local at_cursor = cursor_comments() if #at_cursor > 0 then diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index d937e22..653079e 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -3,6 +3,7 @@ local comments = require("scratch_comments.comments") local clipboard = require("scratch_comments.export.clipboard") local json = require("scratch_comments.export.json") local markdown = require("scratch_comments.export.markdown") +local navigate = require("scratch_comments.navigate") local signs = require("scratch_comments.ui.signs") local views = require("scratch_comments.model.views") local notify = require("scratch_comments.ui.notify") @@ -24,11 +25,11 @@ function M.show() end function M.next() - annotate.jump(1) + navigate.jump(1) end function M.prev() - annotate.jump(-1) + navigate.jump(-1) end function M.delete() diff --git a/lua/scratch_comments/model/views.lua b/lua/scratch_comments/model/views.lua index ac2d1b7..d52c628 100644 --- a/lua/scratch_comments/model/views.lua +++ b/lua/scratch_comments/model/views.lua @@ -60,6 +60,25 @@ function M.anchored() 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()) diff --git a/lua/scratch_comments/navigate.lua b/lua/scratch_comments/navigate.lua new file mode 100644 index 0000000..c0f54bf --- /dev/null +++ b/lua/scratch_comments/navigate.lua @@ -0,0 +1,92 @@ +local annotate = require("scratch_comments.annotate") +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 + annotate.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 -- 2.54.0 From 991230b4fa112ca25b982688ed197f0df3f858b2 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:42:09 -0700 Subject: [PATCH 17/22] Split the commands into actions, export and quickfix annotate.lua becomes actions.lua, with toggle and clear from init.lua. Rendering and :CommentExport move into export/init.lua, and ui.lua becomes ui/quickfix.lua for :CommentList. init.lua is the Lua API and setup. --- .../{annotate.lua => actions.lua} | 27 +++++- lua/scratch_comments/export/init.lua | 65 ++++++++++++++ lua/scratch_comments/init.lua | 84 ++++--------------- lua/scratch_comments/navigate.lua | 4 +- .../{ui.lua => ui/quickfix.lua} | 17 +--- 5 files changed, 107 insertions(+), 90 deletions(-) rename lua/scratch_comments/{annotate.lua => actions.lua} (91%) create mode 100644 lua/scratch_comments/export/init.lua rename lua/scratch_comments/{ui.lua => ui/quickfix.lua} (64%) diff --git a/lua/scratch_comments/annotate.lua b/lua/scratch_comments/actions.lua similarity index 91% rename from lua/scratch_comments/annotate.lua rename to lua/scratch_comments/actions.lua index 97a7634..e075a09 100644 --- a/lua/scratch_comments/annotate.lua +++ b/lua/scratch_comments/actions.lua @@ -3,11 +3,17 @@ 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 ui = require("scratch_comments.ui") +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 @@ -68,7 +74,7 @@ end ---@param start_line integer ---@param end_line integer ---@param use_selection? boolean -function M.range(start_line, end_line, use_selection) +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 @@ -133,7 +139,7 @@ end 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) + return "lines " .. lines .. ": " .. summary(view.comment) end ---@param candidates ScratchCommentView[] @@ -187,7 +193,7 @@ function M.delete_current() vim.ui.select(orphans, { prompt = "Delete orphaned comment: ", format_item = function(comment) - return ui.summary(comment.comment) + return summary(comment.comment) end, }, function(comment) if comment then @@ -196,4 +202,17 @@ function M.delete_current() 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/export/init.lua b/lua/scratch_comments/export/init.lua new file mode 100644 index 0000000..a05c794 --- /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 items, orphans = views.anchored(), views.orphans() + local count = #items + #orphans + if count == 0 then + notify.info("No comments to export") + return + end + + local text = render_format(items, 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/init.lua b/lua/scratch_comments/init.lua index 653079e..a1c4508 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,28 +1,27 @@ -local annotate = require("scratch_comments.annotate") +local actions = require("scratch_comments.actions") local comments = require("scratch_comments.comments") -local clipboard = require("scratch_comments.export.clipboard") -local json = require("scratch_comments.export.json") -local markdown = require("scratch_comments.export.markdown") +local export = require("scratch_comments.export") local navigate = require("scratch_comments.navigate") +local notify = require("scratch_comments.ui.notify") +local quickfix = require("scratch_comments.ui.quickfix") local signs = require("scratch_comments.ui.signs") local views = require("scratch_comments.model.views") -local notify = require("scratch_comments.ui.notify") -local ui = require("scratch_comments.ui") 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() navigate.jump(1) @@ -32,70 +31,15 @@ function M.prev() navigate.jump(-1) end -function M.delete() - annotate.delete_current() -end - function M.list() - ui.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(views.anchored(), views.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 - notify.error("Unknown format: " .. format) - return - end - - local items, orphans = views.anchored(), views.orphans() - local count = #items + #orphans - if count == 0 then - notify.info("No comments to export") - 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 - notify.error("Could not copy to the clipboard; no provider configured?") - return - end - - notify.info("Copied " .. count .. " comment(s)") -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") + quickfix.list(views.anchored(), views.orphans()) end function M.setup() signs.setup() vim.api.nvim_create_user_command("Comment", function(ctx) - annotate.range(ctx.line1, ctx.line2, ctx.range == 2) + actions.comment(ctx.line1, ctx.line2, ctx.range == 2) end, { range = true, desc = "Comment on the current line or range" }) vim.api.nvim_create_user_command( @@ -117,7 +61,7 @@ function M.setup() nargs = "?", bang = true, complete = function() - return vim.tbl_keys(formats) + return export.names() end, desc = "Copy comments to the clipboard; with ! open them in a scratch buffer", }) diff --git a/lua/scratch_comments/navigate.lua b/lua/scratch_comments/navigate.lua index c0f54bf..feabb6c 100644 --- a/lua/scratch_comments/navigate.lua +++ b/lua/scratch_comments/navigate.lua @@ -1,4 +1,4 @@ -local annotate = require("scratch_comments.annotate") +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") @@ -38,7 +38,7 @@ local function cycle(current, direction) vim.api.nvim_win_set_cursor(0, { target.start_line, target.start_col or 0 }) end) end - annotate.show(target, current.source_win) + actions.show(target, current.source_win) end ---@param direction 1|-1 diff --git a/lua/scratch_comments/ui.lua b/lua/scratch_comments/ui/quickfix.lua similarity index 64% rename from lua/scratch_comments/ui.lua rename to lua/scratch_comments/ui/quickfix.lua index 1b9fafd..498b259 100644 --- a/lua/scratch_comments/ui.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -4,21 +4,10 @@ local M = {} ---@param text string ---@return string -function M.summary(text) +local function summary(text) return vim.split(text, "\n")[1] 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")) -end - -- Quickfix rather than a picker: any quickfix front-end (Trouble, Snacks, -- Telescope, nvim-bqf) can display it. ---@param items ScratchCommentView[] @@ -34,13 +23,13 @@ function M.list(items, orphans) filename = comment.file_path, lnum = comment.start_line, end_lnum = comment.end_line, - text = M.summary(comment.comment), + text = summary(comment.comment), } end, items) for _, comment in ipairs(orphans) do table.insert( entries, - { filename = comment.file_path, text = "[orphaned] " .. M.summary(comment.comment) } + { filename = comment.file_path, text = "[orphaned] " .. summary(comment.comment) } ) end -- 2.54.0 From 3cb710ee687668f69931c6a1d9cd3a507d75c041 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:44:29 -0700 Subject: [PATCH 18/22] Name lists of comments views, and find them one way Lists that were called items are views, anchored or comments. actions finds a buffer's comments through views.in_buffer, so views.find and find_all go. Helpers used only inside their module become local. --- lua/scratch_comments/actions.lua | 18 ++++++++---------- lua/scratch_comments/comments.lua | 12 ++++++------ lua/scratch_comments/export/init.lua | 6 +++--- lua/scratch_comments/export/json.lua | 6 +++--- lua/scratch_comments/export/markdown.lua | 6 +++--- lua/scratch_comments/model/anchors.lua | 10 +++++----- lua/scratch_comments/model/views.lua | 12 ------------ lua/scratch_comments/ui/quickfix.lua | 8 ++++---- 8 files changed, 32 insertions(+), 46 deletions(-) diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index e075a09..08ffea8 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -21,12 +21,11 @@ end ---@param end_col? integer ---@return ScratchCommentView? local function find_anchor(bufnr, start_line, end_line, start_col, end_col) - return views.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 + 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 @@ -47,11 +46,10 @@ 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 views.find_all(function(comment) - return comment.bufnr == bufnr and comment.start_line <= line and line <= comment.end_line - end) + 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 diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 13ce524..7f4751e 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -10,12 +10,12 @@ function M.redraw(bufnr) end ---@return integer[] -function M.buffers() - local buffers = {} +local function commented_buffers() + local seen = {} for _, comment in ipairs(store.all()) do - buffers[comment.bufnr] = true + seen[comment.bufnr] = true end - return vim.tbl_keys(buffers) + return vim.tbl_keys(seen) end ---@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string } @@ -55,7 +55,7 @@ function M.delete(comment) end function M.clear() - for _, bufnr in ipairs(M.buffers()) do + for _, bufnr in ipairs(commented_buffers()) do anchors.clear_buffer(bufnr) signs.clear_buffer(bufnr) end @@ -74,7 +74,7 @@ end ---@param on boolean function M.show_signs(on) signs.set_visible(on) - for _, bufnr in ipairs(M.buffers()) do + for _, bufnr in ipairs(commented_buffers()) do M.redraw(bufnr) end end diff --git a/lua/scratch_comments/export/init.lua b/lua/scratch_comments/export/init.lua index a05c794..e163ff8 100644 --- a/lua/scratch_comments/export/init.lua +++ b/lua/scratch_comments/export/init.lua @@ -41,14 +41,14 @@ function M.export(format, in_buffer) return end - local items, orphans = views.anchored(), views.orphans() - local count = #items + #orphans + 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(items, orphans) + local text = render_format(anchored, orphans) if in_buffer then open_scratch(text, format) return diff --git a/lua/scratch_comments/export/json.lua b/lua/scratch_comments/export/json.lua index 29b5efb..20a3212 100644 --- a/lua/scratch_comments/export/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/export/markdown.lua b/lua/scratch_comments/export/markdown.lua index 6fe4cdc..c954d04 100644 --- a/lua/scratch_comments/export/markdown.lua +++ b/lua/scratch_comments/export/markdown.lua @@ -18,14 +18,14 @@ local function line_range(comment) 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 diff --git a/lua/scratch_comments/model/anchors.lua b/lua/scratch_comments/model/anchors.lua index f73a856..b1298d7 100644 --- a/lua/scratch_comments/model/anchors.lua +++ b/lua/scratch_comments/model/anchors.lua @@ -64,7 +64,7 @@ function M.is_orphaned(comment) end ---@param comment ScratchComment -function M.clear(comment) +local function clear(comment) if comment and comment.extmark_id @@ -75,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 diff --git a/lua/scratch_comments/model/views.lua b/lua/scratch_comments/model/views.lua index d52c628..ce2731e 100644 --- a/lua/scratch_comments/model/views.lua +++ b/lua/scratch_comments/model/views.lua @@ -84,16 +84,4 @@ function M.orphans() return vim.tbl_filter(anchors.is_orphaned, store.all()) 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 - return M diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index 498b259..e33897d 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -10,10 +10,10 @@ end -- Quickfix rather than a picker: any quickfix front-end (Trouble, Snacks, -- Telescope, nvim-bqf) can display it. ----@param items ScratchCommentView[] +---@param views ScratchCommentView[] ---@param orphans ScratchComment[] -function M.list(items, orphans) - if #items + #orphans == 0 then +function M.list(views, orphans) + if #views + #orphans == 0 then notify.info("No comments") return end @@ -25,7 +25,7 @@ function M.list(items, orphans) end_lnum = comment.end_line, text = summary(comment.comment), } - end, items) + end, views) for _, comment in ipairs(orphans) do table.insert( entries, -- 2.54.0 From 4e6987f08e56683fa4fe95721302e4b0da5ff0e9 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:48:03 -0700 Subject: [PATCH 19/22] Define the commands in plugin/, without setup() plugin/scratch-comments.lua defines the commands and loads the plugin when one runs. The autocmds and the sign highlight are set when their modules first load, so nothing runs on text changes before a comment command has. setup() is kept as a no-op for configs that call it. --- README.md | 7 +-- doc/scratch-comments.txt | 5 +-- lua/scratch_comments/comments.lua | 16 +++++++ lua/scratch_comments/init.lua | 71 +------------------------------ lua/scratch_comments/ui/signs.lua | 4 +- plugin/scratch-comments.lua | 68 +++++++++++++++++++++++++++++ tests/features/plugin_spec.lua | 36 ++++++++++++++++ tests/minit.lua | 1 - 8 files changed, 125 insertions(+), 83 deletions(-) create mode 100644 plugin/scratch-comments.lua create mode 100644 tests/features/plugin_spec.lua diff --git a/README.md b/README.md index f1e9ba3..c6f88cd 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 @@ -136,7 +132,6 @@ 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() diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 5e480f7..d3e4c98 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -20,9 +20,7 @@ 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 are there once the plugin is installed; no setup is needed. Add your own mappings. See |scratch-comments-api| for examples. ============================================================================== @@ -141,7 +139,6 @@ 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() diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 7f4751e..8dae360 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -4,6 +4,8 @@ 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)) @@ -79,4 +81,18 @@ function M.show_signs(on) 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/init.lua b/lua/scratch_comments/init.lua index a1c4508..beb280d 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,10 +1,7 @@ local actions = require("scratch_comments.actions") -local comments = require("scratch_comments.comments") local export = require("scratch_comments.export") local navigate = require("scratch_comments.navigate") -local notify = require("scratch_comments.ui.notify") local quickfix = require("scratch_comments.ui.quickfix") -local signs = require("scratch_comments.ui.signs") local views = require("scratch_comments.model.views") local M = {} @@ -35,71 +32,7 @@ function M.list() quickfix.list(views.anchored(), views.orphans()) end -function M.setup() - signs.setup() - - vim.api.nvim_create_user_command("Comment", function(ctx) - actions.comment(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( - "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 export.names() - 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 - notify.error("Usage: :CommentToggle [on|off]") - 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) - comments.redraw(args.buf) - end, - }) - - vim.api.nvim_create_autocmd("BufDelete", { - group = group, - callback = function(args) - comments.forget_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/ui/signs.lua b/lua/scratch_comments/ui/signs.lua index 36996c2..f79fc1b 100644 --- a/lua/scratch_comments/ui/signs.lua +++ b/lua/scratch_comments/ui/signs.lua @@ -7,9 +7,7 @@ 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 +vim.api.nvim_set_hl(0, group, { link = "Todo", default = true }) ---@return boolean function M.is_visible() 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/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/minit.lua b/tests/minit.lua index 0b7abaa..e112b7a 100644 --- a/tests/minit.lua +++ b/tests/minit.lua @@ -4,7 +4,6 @@ vim.opt.swapfile = false vim.opt.showmode = false package.path = vim.fn.getcwd() .. "/tests/?.lua;" .. package.path -require("scratch_comments").setup() require("mini.test").setup({ collect = { find_files = function() -- 2.54.0 From f5da74b7568431e1199c97ed08cc889e20312009 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 09:51:06 -0700 Subject: [PATCH 20/22] Describe locations one way, and put columns in quickfix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker and the markdown export use location.describe, the window title's format: "line 3", "lines 3–5", "line 3, col 5–12". This fixes the picker's "lines 3" for one line. Quickfix gets a span's columns, so ]q lands on the text. --- doc/scratch-comments.txt | 2 +- lua/scratch_comments/actions.lua | 4 +--- lua/scratch_comments/export/markdown.lua | 11 +++-------- lua/scratch_comments/ui/quickfix.lua | 2 ++ tests/features/list_export_spec.lua | 2 +- tests/features/show_delete_spec.lua | 6 ++++++ tests/features/spans_spec.lua | 7 +++++++ tests/helpers.lua | 7 ++++++- tests/unit/markdown_spec.lua | 7 ++++++- 9 files changed, 33 insertions(+), 15 deletions(-) diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index d3e4c98..788967a 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -100,7 +100,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* diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 08ffea8..66ed421 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -135,9 +135,7 @@ 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 .. ": " .. summary(view.comment) + return location.describe(view) .. ": " .. summary(view.comment) end ---@param candidates ScratchCommentView[] diff --git a/lua/scratch_comments/export/markdown.lua b/lua/scratch_comments/export/markdown.lua index c954d04..3dcf441 100644 --- a/lua/scratch_comments/export/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,13 +13,6 @@ 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 views ScratchCommentView[] ---@param orphans ScratchComment[] ---@return string @@ -34,7 +29,7 @@ function M.render(views, 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/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index e33897d..cd1b104 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -23,6 +23,8 @@ function M.list(views, orphans) 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) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 5a05bd7..3a1af6c 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -107,7 +107,7 @@ 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) + expect.no_equality(scratch.render():find("lines 1–2", 1, true), nil) end) end) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 928d3e9..a44fdf1 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -21,6 +21,12 @@ describe(":CommentShow", function() 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") diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua index 3e2395c..2eb3ca8 100644 --- a/tests/features/spans_spec.lua +++ b/tests/features/spans_spec.lua @@ -79,6 +79,13 @@ describe("a span", function() 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) diff --git a/tests/helpers.lua b/tests/helpers.lua index b31d67d..9ffbe3b 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -82,14 +82,18 @@ 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, _, callback) + 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 @@ -118,6 +122,7 @@ function M.reset() vim.cmd("silent! %bwipeout!") vim.ui.select = select M.offered = {} + M.labels = {} vim.wait(0) M.notifications = {} end diff --git a/tests/unit/markdown_spec.lua b/tests/unit/markdown_spec.lua index 372eff1..fe6d368 100644 --- a/tests/unit/markdown_spec.lua +++ b/tests/unit/markdown_spec.lua @@ -44,7 +44,7 @@ describe("markdown", function() "local x = 1", "```", "", - "- lines 5-6 (comment-2)", + "- lines 5–6 (comment-2)", "", "two", "", @@ -67,6 +67,11 @@ describe("markdown", function() ) 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) -- 2.54.0 From 537221dd0c9b68821fcc09693c8324d4e5164266 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 10:00:58 -0700 Subject: [PATCH 21/22] Require Neovim 0.12 The comment window reads 'winborder', which needs 0.11, and CI tests on 0.12. :checkhealth, the README and the help file now say 0.12. --- README.md | 2 +- doc/scratch-comments.txt | 2 ++ lua/scratch_comments/health.lua | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6f88cd..705840f 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ 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"`. diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 788967a..8b69933 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -23,6 +23,8 @@ them. The commands are there once the plugin is installed; no setup is needed. Add your own mappings. See |scratch-comments-api| for examples. +Requires Neovim 0.12 or newer. + ============================================================================== 2. COMMANDS *scratch-comments-commands* 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 -- 2.54.0 From 03f44cac0b66fcb5ac5a4ed41d1bb8b6c56e57e8 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 22 Sep 2026 10:23:15 -0700 Subject: [PATCH 22/22] Check the docs against the code, and add a development section Only a new comment opens in insert mode, and :CommentList lands on a span's text. The wording is plainer throughout. The README now maps the code layout and says how to run the checks. --- README.md | 46 ++++++++++++++++++++++++++++++++-------- doc/scratch-comments.txt | 41 ++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 705840f..c1f737d 100644 --- a/README.md +++ b/README.md @@ -34,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 @@ -93,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 comment open, they show the next or previous -comment in its place. 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: +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 @@ -156,6 +158,32 @@ vim.fn.writefile(vim.split(scratch.render(), "\n"), "review.md") - 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 8b69933..d68d0af 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -20,7 +20,7 @@ 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. -The commands are there once the plugin is installed; no setup is needed. +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. @@ -32,38 +32,40 @@ Requires Neovim 0.12 or newer. :[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, - with the lines it is on. 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. +: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 comment open, show the next comment in its - place instead, one comment at a time. The cursor in - the file follows. Save or discard your changes first. + 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 comment open, show the - previous comment in its place. + previous comment in that window instead. *:CommentDelete* :CommentDelete Delete the comment at the cursor. If more than one @@ -74,6 +76,7 @@ Requires Neovim 0.12 or newer. *: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. @@ -125,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 -- 2.54.0