feat: init version
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
nvim.log
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.tmp
|
||||||
|
luacov.*
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# loc.nvim
|
||||||
|
|
||||||
|
> Entirely GPT 5.5 generated
|
||||||
|
|
||||||
|
A tiny Neovim plugin that tracks characters added and deleted while you are in
|
||||||
|
insert mode.
|
||||||
|
|
||||||
|
It keeps global lifetime totals:
|
||||||
|
|
||||||
|
- `added`: characters inserted
|
||||||
|
- `deleted`: characters removed
|
||||||
|
- `net`: `added - deleted`
|
||||||
|
- `abs`: `added + deleted`
|
||||||
|
|
||||||
|
The statusline text shows `net/abs`, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LOC +123/456
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
With `lazy.nvim`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
"your-name/loc.nvim",
|
||||||
|
config = function()
|
||||||
|
require("loc").setup()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a local checkout:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
dir = "/Users/zero/main/loc.nvim",
|
||||||
|
config = function()
|
||||||
|
require("loc").setup()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Statusline
|
||||||
|
|
||||||
|
`loc.nvim` does not overwrite your statusline. Add the exposed function wherever
|
||||||
|
you want it.
|
||||||
|
|
||||||
|
Built-in statusline:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.o.statusline = vim.o.statusline .. " %{v:lua.require'loc'.statusline()}"
|
||||||
|
```
|
||||||
|
|
||||||
|
With `lualine.nvim`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("lualine").setup({
|
||||||
|
sections = {
|
||||||
|
lualine_x = {
|
||||||
|
function()
|
||||||
|
return require("loc").statusline()
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
The plugin defines these commands when it is on your runtimepath:
|
||||||
|
|
||||||
|
- `:LocEnable` starts tracking.
|
||||||
|
- `:LocDisable` stops tracking.
|
||||||
|
- `:LocReset` clears the counters.
|
||||||
|
- `:LocStats` prints the current counters.
|
||||||
|
|
||||||
|
Calling `require("loc").setup()` enables tracking by default. Use this if you
|
||||||
|
want to configure the plugin but start it manually:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("loc").setup({ auto_enable = false })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("loc").setup({
|
||||||
|
auto_enable = true,
|
||||||
|
persist = true,
|
||||||
|
data_path = nil,
|
||||||
|
save_delay_ms = 1000,
|
||||||
|
statusline_prefix = "LOC",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
When `data_path` is `nil`, stats are stored at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stdpath("data")/loc.nvim/stats.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("loc").enable()
|
||||||
|
require("loc").disable()
|
||||||
|
require("loc").reset()
|
||||||
|
require("loc").save()
|
||||||
|
require("loc").statusline()
|
||||||
|
require("loc").stats()
|
||||||
|
```
|
||||||
|
|
||||||
|
`stats()` returns:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
added = 123,
|
||||||
|
deleted = 45,
|
||||||
|
net = 78,
|
||||||
|
abs = 168,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvim --headless -u NONE -i NONE -l test/loc_spec.lua
|
||||||
|
```
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local defaults = {
|
||||||
|
auto_enable = true,
|
||||||
|
persist = true,
|
||||||
|
data_path = nil,
|
||||||
|
save_delay_ms = 1000,
|
||||||
|
statusline_prefix = "LOC",
|
||||||
|
}
|
||||||
|
|
||||||
|
local state = {
|
||||||
|
config = nil,
|
||||||
|
loaded = false,
|
||||||
|
loaded_path = nil,
|
||||||
|
enabled = false,
|
||||||
|
augroup = nil,
|
||||||
|
snapshots = {},
|
||||||
|
save_pending = false,
|
||||||
|
dirty = false,
|
||||||
|
added = 0,
|
||||||
|
deleted = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function data_path()
|
||||||
|
if state.config and state.config.data_path then
|
||||||
|
return state.config.data_path
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function json_encode(value)
|
||||||
|
if vim.json and vim.json.encode then
|
||||||
|
return vim.json.encode(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
return vim.fn.json_encode(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function json_decode(value)
|
||||||
|
if vim.json and vim.json.decode then
|
||||||
|
return vim.json.decode(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
return vim.fn.json_decode(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function char_count(value)
|
||||||
|
if value == "" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, count = pcall(vim.fn.strchars, value)
|
||||||
|
if ok then
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
return #value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_continuation_byte(byte)
|
||||||
|
return byte and byte >= 0x80 and byte <= 0xbf
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_char_boundary_after(value, byte_index)
|
||||||
|
if byte_index <= 0 or byte_index >= #value then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return not is_continuation_byte(value:byte(byte_index + 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_char_boundary_at(value, byte_index)
|
||||||
|
if byte_index <= 1 or byte_index > #value then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return not is_continuation_byte(value:byte(byte_index))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function common_prefix_bytes(old, new)
|
||||||
|
local max_len = math.min(#old, #new)
|
||||||
|
local prefix = 0
|
||||||
|
|
||||||
|
while prefix < max_len and old:byte(prefix + 1) == new:byte(prefix + 1) do
|
||||||
|
prefix = prefix + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
while prefix > 0 and (not has_char_boundary_after(old, prefix) or not has_char_boundary_after(new, prefix)) do
|
||||||
|
prefix = prefix - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
local function common_suffix_bytes(old, new, prefix)
|
||||||
|
local old_len = #old
|
||||||
|
local new_len = #new
|
||||||
|
local suffix = 0
|
||||||
|
|
||||||
|
while old_len - suffix > prefix
|
||||||
|
and new_len - suffix > prefix
|
||||||
|
and old:byte(old_len - suffix) == new:byte(new_len - suffix)
|
||||||
|
do
|
||||||
|
suffix = suffix + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
while suffix > 0 do
|
||||||
|
local old_start = old_len - suffix + 1
|
||||||
|
local new_start = new_len - suffix + 1
|
||||||
|
|
||||||
|
if has_char_boundary_at(old, old_start) and has_char_boundary_at(new, new_start) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
suffix = suffix - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return suffix
|
||||||
|
end
|
||||||
|
|
||||||
|
local function measure_change(old, new)
|
||||||
|
if old == new then
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local prefix = common_prefix_bytes(old, new)
|
||||||
|
local suffix = common_suffix_bytes(old, new, prefix)
|
||||||
|
local old_changed = old:sub(prefix + 1, #old - suffix)
|
||||||
|
local new_changed = new:sub(prefix + 1, #new - suffix)
|
||||||
|
|
||||||
|
return char_count(new_changed), char_count(old_changed)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function snapshot(bufnr)
|
||||||
|
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, true)
|
||||||
|
if not ok then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_change(added, deleted)
|
||||||
|
if added == 0 and deleted == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.added = state.added + added
|
||||||
|
state.deleted = state.deleted + deleted
|
||||||
|
state.dirty = true
|
||||||
|
|
||||||
|
vim.schedule(function()
|
||||||
|
vim.cmd.redrawstatus()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function save_now()
|
||||||
|
if not state.config or not state.config.persist or not state.dirty then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local path = data_path()
|
||||||
|
local payload = json_encode({
|
||||||
|
added = state.added,
|
||||||
|
deleted = state.deleted,
|
||||||
|
})
|
||||||
|
|
||||||
|
local ok = pcall(function()
|
||||||
|
vim.fn.mkdir(vim.fn.fnamemodify(path, ":h"), "p")
|
||||||
|
vim.fn.writefile({ payload }, path)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok then
|
||||||
|
state.dirty = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_save()
|
||||||
|
if not state.config or not state.config.persist or state.save_pending then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.save_pending = true
|
||||||
|
|
||||||
|
vim.defer_fn(function()
|
||||||
|
state.save_pending = false
|
||||||
|
save_now()
|
||||||
|
end, state.config.save_delay_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update_snapshot(bufnr)
|
||||||
|
local previous = state.snapshots[bufnr]
|
||||||
|
local current = snapshot(bufnr)
|
||||||
|
|
||||||
|
if not current then
|
||||||
|
state.snapshots[bufnr] = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if previous then
|
||||||
|
local added, deleted = measure_change(previous, current)
|
||||||
|
apply_change(added, deleted)
|
||||||
|
schedule_save()
|
||||||
|
end
|
||||||
|
|
||||||
|
state.snapshots[bufnr] = current
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_stats()
|
||||||
|
local path = data_path()
|
||||||
|
|
||||||
|
if state.loaded and state.loaded_path == path then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not state.config.persist then
|
||||||
|
state.loaded = true
|
||||||
|
state.loaded_path = path
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if vim.fn.filereadable(path) ~= 1 then
|
||||||
|
state.loaded = true
|
||||||
|
state.loaded_path = path
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, parsed = pcall(function()
|
||||||
|
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok and type(parsed) == "table" then
|
||||||
|
state.added = tonumber(parsed.added) or 0
|
||||||
|
state.deleted = tonumber(parsed.deleted) or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
state.loaded = true
|
||||||
|
state.loaded_path = path
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_setup()
|
||||||
|
if state.config then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
M.setup({ auto_enable = false })
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.setup(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
state.config = vim.tbl_deep_extend("force", defaults, opts)
|
||||||
|
|
||||||
|
if opts.data_path == nil then
|
||||||
|
state.config.data_path = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
load_stats()
|
||||||
|
|
||||||
|
if state.config.auto_enable then
|
||||||
|
M.enable()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.enable()
|
||||||
|
ensure_setup()
|
||||||
|
|
||||||
|
if state.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.enabled = true
|
||||||
|
state.augroup = vim.api.nvim_create_augroup("loc_nvim_tracker", { clear = true })
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("InsertEnter", {
|
||||||
|
group = state.augroup,
|
||||||
|
callback = function(args)
|
||||||
|
state.snapshots[args.buf] = snapshot(args.buf)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("TextChangedI", {
|
||||||
|
group = state.augroup,
|
||||||
|
callback = function(args)
|
||||||
|
update_snapshot(args.buf)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("InsertLeave", {
|
||||||
|
group = state.augroup,
|
||||||
|
callback = function(args)
|
||||||
|
update_snapshot(args.buf)
|
||||||
|
state.snapshots[args.buf] = nil
|
||||||
|
save_now()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.disable()
|
||||||
|
if not state.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.augroup then
|
||||||
|
pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
|
||||||
|
end
|
||||||
|
|
||||||
|
state.enabled = false
|
||||||
|
state.augroup = nil
|
||||||
|
state.snapshots = {}
|
||||||
|
save_now()
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.reset()
|
||||||
|
ensure_setup()
|
||||||
|
|
||||||
|
state.added = 0
|
||||||
|
state.deleted = 0
|
||||||
|
state.dirty = true
|
||||||
|
save_now()
|
||||||
|
vim.cmd.redrawstatus()
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.stats()
|
||||||
|
ensure_setup()
|
||||||
|
|
||||||
|
return {
|
||||||
|
added = state.added,
|
||||||
|
deleted = state.deleted,
|
||||||
|
net = state.added - state.deleted,
|
||||||
|
abs = state.added + state.deleted,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.statusline()
|
||||||
|
local stats = M.stats()
|
||||||
|
local prefix = state.config.statusline_prefix
|
||||||
|
|
||||||
|
return string.format("%s %+d/%d", prefix, stats.net, stats.abs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.save()
|
||||||
|
ensure_setup()
|
||||||
|
save_now()
|
||||||
|
end
|
||||||
|
|
||||||
|
M._measure_change = measure_change
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
if vim.g.loaded_loc_nvim == 1 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.g.loaded_loc_nvim = 1
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("LocEnable", function()
|
||||||
|
require("loc").enable()
|
||||||
|
end, { desc = "Enable loc.nvim insert-mode character tracking" })
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("LocDisable", function()
|
||||||
|
require("loc").disable()
|
||||||
|
end, { desc = "Disable loc.nvim insert-mode character tracking" })
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("LocReset", function()
|
||||||
|
require("loc").reset()
|
||||||
|
end, { desc = "Reset loc.nvim character counters" })
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("LocStats", function()
|
||||||
|
local stats = require("loc").stats()
|
||||||
|
local message = string.format(
|
||||||
|
"LOC added=%d deleted=%d net=%+d abs=%d",
|
||||||
|
stats.added,
|
||||||
|
stats.deleted,
|
||||||
|
stats.net,
|
||||||
|
stats.abs
|
||||||
|
)
|
||||||
|
|
||||||
|
vim.notify(message, vim.log.levels.INFO)
|
||||||
|
end, { desc = "Show loc.nvim character counters" })
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
vim.opt.runtimepath:prepend(vim.fn.getcwd())
|
||||||
|
|
||||||
|
local loc = require("loc")
|
||||||
|
|
||||||
|
local function assert_equal(actual, expected, message)
|
||||||
|
if actual ~= expected then
|
||||||
|
error(string.format("%s: expected %s, got %s", message, vim.inspect(expected), vim.inspect(actual)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function assert_change(old, new, expected_added, expected_deleted)
|
||||||
|
local added, deleted = loc._measure_change(old, new)
|
||||||
|
|
||||||
|
assert_equal(added, expected_added, string.format("added for %q -> %q", old, new))
|
||||||
|
assert_equal(deleted, expected_deleted, string.format("deleted for %q -> %q", old, new))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_change("", "abc", 3, 0)
|
||||||
|
assert_change("abc", "ab", 0, 1)
|
||||||
|
assert_change("abc", "axc", 1, 1)
|
||||||
|
assert_change("hello", "hello world", 6, 0)
|
||||||
|
assert_change("héllo", "hallo", 1, 1)
|
||||||
|
assert_change("one\ntwo", "one\nthree", 4, 2)
|
||||||
|
|
||||||
|
local tmp = vim.fn.tempname()
|
||||||
|
loc.setup({ auto_enable = false, data_path = tmp, save_delay_ms = 1 })
|
||||||
|
loc.reset()
|
||||||
|
|
||||||
|
local stats = loc.stats()
|
||||||
|
assert_equal(stats.added, 0, "reset added")
|
||||||
|
assert_equal(stats.deleted, 0, "reset deleted")
|
||||||
|
assert_equal(loc.statusline(), "LOC +0/0", "statusline")
|
||||||
|
|
||||||
|
print("loc.nvim tests passed")
|
||||||
Reference in New Issue
Block a user