Rearchitect the plugin, with mini.test specs #1
No files matched your search
@@ -1,3 +1,4 @@
|
||||
/.DS_Store
|
||||
.build/
|
||||
.nono.json
|
||||
deps/
|
||||
+2
-2
@@ -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"]
|
||||
}
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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, "")
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" })
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
first line
|
||||
second line
|
||||
third line
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
vim.opt.runtimepath:prepend(vim.fn.getcwd())
|
||||
vim.opt.swapfile = false
|
||||
|
||||
require("scratch_comments").setup()
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user