Rearchitect the plugin, with mini.test specs #1

Merged
eleith merged 22 commits from rearchitecture into main 2026-09-22 17:52:49 +00:00
51 changed files with 2517 additions and 1678 deletions

No files matched your search

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