fix: handle concurrent updates from multiple sessions

This commit is contained in:
2026-06-03 19:56:58 +00:00
parent 106641748c
commit 9a6b0e1dd8
3 changed files with 322 additions and 125 deletions
+4 -6
View File
@@ -1,7 +1,5 @@
# loc.nvim
> Entirely GPT 5.5 generated
A tiny Neovim plugin that tracks characters added and deleted while you are in
insert mode, then displays estimated lines of code by dividing characters by 35.
@@ -82,9 +80,8 @@ Defaults:
```lua
require("loc").setup({
auto_enable = true,
persist = true,
data_path = nil,
save_delay_ms = 1000,
flush_interval_ms = 10000,
statusline_prefix = "LOC",
})
```
@@ -106,8 +103,7 @@ Stats are stored by local date:
}
```
With `persist = false`, daily counters stay in memory only for the current
Neovim process.
Local edits are applied immediately in the UI, then flushed into `stats.json` every `flush_interval_ms`.
## API
@@ -121,6 +117,8 @@ require("loc").stats()
require("loc").metrics()
```
`save()` flushes this process's pending deltas immediately.
`stats()` returns today's counters:
```lua
+302 -113
View File
@@ -2,22 +2,19 @@ local M = {}
local defaults = {
auto_enable = true,
persist = true,
data_path = nil,
save_delay_ms = 1000,
flush_interval_ms = 10000,
statusline_prefix = "LOC",
}
local state = {
config = nil,
loaded = false,
loaded_path = nil,
enabled = false,
augroup = nil,
snapshots = {},
save_pending = false,
dirty = false,
days = {},
base_days = {},
pending_days = {},
flush_timer = nil,
metrics_render = 0,
}
@@ -50,6 +47,9 @@ local metrics_cell_width = 7
local metrics_cell_height = 3
local metrics_column_gap = 2
local metrics_row_gap = 1
local uv = vim.uv or vim.loop
local lock_retry_count = 40
local lock_retry_delay_ms = 50
local function data_path()
return (state.config and state.config.data_path)
@@ -63,9 +63,194 @@ local function today_key()
return vim.fn.strftime("%Y-%m-%d")
end
local function day_stats(date)
state.days[date] = state.days[date] or { added = 0, deleted = 0 }
return state.days[date]
local function redraw_statusline()
vim.schedule(function()
vim.cmd.redrawstatus()
end)
end
local function copy_days(days)
local copy = {}
for date, stats in pairs(days or {}) do
if type(date) == "string" and type(stats) == "table" then
copy[date] = {
added = tonumber(stats.added) or 0,
deleted = tonumber(stats.deleted) or 0,
}
end
end
return copy
end
local function ensure_day(days, date)
days[date] = days[date] or { added = 0, deleted = 0 }
return days[date]
end
local function merged_day_stats(date)
local base = state.base_days[date]
local pending = state.pending_days[date]
local added = (tonumber(base and base.added) or 0) + (tonumber(pending and pending.added) or 0)
local deleted = (tonumber(base and base.deleted) or 0) + (tonumber(pending and pending.deleted) or 0)
return {
added = added,
deleted = deleted,
net = added - deleted,
}
end
local function has_pending_days()
return next(state.pending_days) ~= nil
end
local function merge_days(base_days, pending_days)
local merged = copy_days(base_days)
for date, stats in pairs(pending_days or {}) do
local day = ensure_day(merged, date)
day.added = day.added + (tonumber(stats.added) or 0)
day.deleted = day.deleted + (tonumber(stats.deleted) or 0)
end
return merged
end
local function stats_lock_path(path)
return path .. ".lock"
end
local function lock_info_path(lock_path)
return lock_path .. "/info.json"
end
local function stale_lock_age_seconds()
local interval = (state.config and state.config.flush_interval_ms) or defaults.flush_interval_ms
return math.max(math.ceil(interval / 1000) * 6, 30)
end
local function normalize_days(days)
return copy_days(days)
end
local function read_days_from_disk(path)
if vim.fn.filereadable(path) ~= 1 then
return {}
end
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
end)
if not ok or type(parsed) ~= "table" then
return {}
end
return normalize_days(parsed)
end
local function reload_base_days()
state.base_days = read_days_from_disk(data_path())
end
local function write_days_to_disk(path, days)
local dir = vim.fn.fnamemodify(path, ":h")
local temp_path = string.format("%s.tmp.%d.%d", path, vim.fn.getpid(), os.time())
local payload = json_encode(days)
vim.fn.mkdir(dir, "p")
vim.fn.writefile({ payload }, temp_path)
local ok, err = uv.fs_rename(temp_path, path)
if not ok then
vim.fn.delete(temp_path)
error(err or "failed to rename stats file")
end
end
local function read_lock_timestamp(lock_path)
local info_path = lock_info_path(lock_path)
if vim.fn.filereadable(info_path) == 1 then
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(info_path), "\n"))
end)
if ok and type(parsed) == "table" and type(parsed.timestamp) == "number" then
return parsed.timestamp
end
end
local stat = uv.fs_stat(lock_path)
if stat and stat.mtime and type(stat.mtime.sec) == "number" then
return stat.mtime.sec
end
return nil
end
local function lock_is_stale(lock_path)
local timestamp = read_lock_timestamp(lock_path)
if not timestamp then
return false
end
return os.difftime(os.time(), timestamp) >= stale_lock_age_seconds()
end
local function release_lock(lock_path)
pcall(vim.fn.delete, lock_path, "rf")
end
local function write_lock_metadata(lock_path)
local metadata = {
pid = vim.fn.getpid(),
timestamp = os.time(),
}
pcall(vim.fn.writefile, { json_encode(metadata) }, lock_info_path(lock_path))
end
local function with_stats_lock(fn)
local path = data_path()
local dir = vim.fn.fnamemodify(path, ":h")
local lock_path = stats_lock_path(path)
vim.fn.mkdir(dir, "p")
for _ = 1, lock_retry_count do
local ok, created = pcall(vim.fn.mkdir, lock_path)
if ok and created == 1 then
write_lock_metadata(lock_path)
local success, result, extra = xpcall(function()
return fn(path)
end, debug.traceback)
release_lock(lock_path)
if not success then
return false, result
end
return true, result, extra
end
if lock_is_stale(lock_path) then
release_lock(lock_path)
else
vim.wait(lock_retry_delay_ms, function() return false end)
end
end
return false, string.format("failed to acquire stats lock: %s", lock_path)
end
local function char_count(value)
@@ -158,46 +343,11 @@ local function apply_change(added, deleted)
return
end
local day = day_stats(today_key())
local day = ensure_day(state.pending_days, today_key())
day.added = day.added + added
day.deleted = day.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(state.days)
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)
redraw_statusline()
end
local function update_snapshot(bufnr)
@@ -212,7 +362,6 @@ local function update_snapshot(bufnr)
if previous then
local added, deleted = measure_change(previous, current)
apply_change(added, deleted)
schedule_save()
end
state.snapshots[bufnr] = current
@@ -331,20 +480,7 @@ local function metrics_display_date(epoch)
end
local function stored_day_stats(date)
local day = state.days[date]
if type(day) ~= "table" then
return { added = 0, deleted = 0, net = 0 }
end
local added = tonumber(day.added) or 0
local deleted = tonumber(day.deleted) or 0
return {
added = added,
deleted = deleted,
net = added - deleted,
}
return merged_day_stats(date)
end
local function display_day_stats(stats)
@@ -600,47 +736,77 @@ local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net)
end
end
local function load_stats()
local path = data_path()
if state.loaded and state.loaded_path == path then
return
local function flush_pending_now()
if not state.config or not has_pending_days() then
return true
end
if not state.config.persist then
state.days = {}
state.loaded = true
state.loaded_path = path
return
end
local flushed = copy_days(state.pending_days)
local ok, result = with_stats_lock(function(path)
local merged = merge_days(read_days_from_disk(path), flushed)
if vim.fn.filereadable(path) ~= 1 then
state.days = {}
state.loaded = true
state.loaded_path = path
return
end
local ok, parsed = pcall(function()
return json_decode(table.concat(vim.fn.readfile(path), "\n"))
write_days_to_disk(path, merged)
return merged
end)
state.days = {}
state.dirty = false
if ok and type(parsed) == "table" then
for date, stats in pairs(parsed) do
if type(date) == "string" and type(stats) == "table" then
state.days[date] = {
added = tonumber(stats.added) or 0,
deleted = tonumber(stats.deleted) or 0,
}
end
end
if not ok then
return false, result
end
state.loaded = true
state.loaded_path = path
reload_base_days()
state.pending_days = {}
redraw_statusline()
return true, result
end
local function periodic_sync()
if not state.config then
return
end
if has_pending_days() then
local ok = flush_pending_now()
if not ok then
return
end
return
end
reload_base_days()
redraw_statusline()
end
local function stop_flush_timer()
if not state.flush_timer then
return
end
pcall(function()
state.flush_timer:stop()
end)
if not state.flush_timer:is_closing() then
state.flush_timer:close()
end
state.flush_timer = nil
end
local function start_flush_timer()
stop_flush_timer()
if not state.enabled or not state.config then
return
end
state.flush_timer = assert(uv.new_timer())
state.flush_timer:start(
state.config.flush_interval_ms,
state.config.flush_interval_ms,
vim.schedule_wrap(periodic_sync)
)
end
local function ensure_setup()
@@ -653,15 +819,27 @@ end
function M.setup(opts)
opts = opts or {}
local was_enabled = state.enabled
if state.config then
flush_pending_now()
stop_flush_timer()
end
state.config = vim.tbl_deep_extend("force", defaults, opts)
state.config.flush_interval_ms = math.max(1, tonumber(state.config.flush_interval_ms) or defaults.flush_interval_ms)
if opts.data_path == nil then
state.config.data_path = nil
end
load_stats()
state.base_days = {}
state.pending_days = {}
reload_base_days()
if state.config.auto_enable then
if was_enabled then
start_flush_timer()
elseif state.config.auto_enable then
M.enable()
end
@@ -697,9 +875,17 @@ function M.enable()
callback = function(args)
update_snapshot(args.buf)
state.snapshots[args.buf] = nil
save_now()
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
group = state.augroup,
callback = function()
flush_pending_now()
end,
})
start_flush_timer()
end
function M.disable()
@@ -707,6 +893,8 @@ function M.disable()
return
end
stop_flush_timer()
if state.augroup then
pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
end
@@ -714,31 +902,32 @@ function M.disable()
state.enabled = false
state.augroup = nil
state.snapshots = {}
save_now()
flush_pending_now()
end
function M.reset()
ensure_setup()
local day = day_stats(today_key())
local today = today_key()
local ok = with_stats_lock(function(path)
local days = read_days_from_disk(path)
days[today] = { added = 0, deleted = 0 }
write_days_to_disk(path, days)
end)
if ok then
reload_base_days()
state.pending_days = {}
end
day.added = 0
day.deleted = 0
state.dirty = true
save_now()
vim.cmd.redrawstatus()
end
function M.stats()
ensure_setup()
local day = day_stats(today_key())
return {
added = day.added,
deleted = day.deleted,
net = day.added - day.deleted,
}
return merged_day_stats(today_key())
end
function M.display_stats()
@@ -784,7 +973,7 @@ end
function M.save()
ensure_setup()
save_now()
flush_pending_now()
end
M._measure_change = measure_change
+16 -6
View File
@@ -63,7 +63,7 @@ write_json(daily_path, {
[old_day] = { added = 4, deleted = 1 },
})
loc.setup({ auto_enable = false, data_path = daily_path, save_delay_ms = 1 })
loc.setup({ auto_enable = false, data_path = daily_path, flush_interval_ms = 10000 })
local loaded_stats = loc.stats()
assert_equal(loaded_stats.added, 7, "loaded daily added")
@@ -85,7 +85,7 @@ assert_equal(daily[old_day].added, 4, "reset preserves old day added")
assert_equal(daily[old_day].deleted, 1, "reset preserves old day deleted")
local change_path = vim.fn.tempname()
loc.setup({ auto_enable = false, data_path = change_path, save_delay_ms = 1 })
loc.setup({ auto_enable = false, data_path = change_path, flush_interval_ms = 10000 })
loc.enable()
local bufnr = vim.api.nvim_create_buf(false, true)
@@ -161,7 +161,7 @@ write_json(metrics_path, {
["2026-05-10"] = { added = 350, deleted = 0 },
})
loc.setup({ auto_enable = false, data_path = metrics_path, save_delay_ms = 1 })
loc.setup({ auto_enable = false, data_path = metrics_path, flush_interval_ms = 10000 })
local entries, column_count, max_abs_net, total_net = loc._metrics_entries(metrics_epoch)
@@ -181,6 +181,16 @@ assert_equal(entries[28].loc_net, 0, "metrics future date loc net is zero")
assert_equal(max_abs_net, 10, "metrics max abs net")
assert_equal(total_net, 14, "metrics total net")
local render_path = vim.fn.tempname()
local render_dates = loc._metrics_dates()
write_json(render_path, {
[render_dates[1].date] = { added = 245, deleted = 0 },
[render_dates[10].date] = { added = 0, deleted = 105 },
[today] = { added = 350, deleted = 0 },
})
loc.setup({ auto_enable = false, data_path = render_path, flush_interval_ms = 10000 })
local render = loc.metrics()
assert_equal(vim.api.nvim_win_is_valid(render.winid), true, "metrics window is valid")
assert_equal(vim.api.nvim_buf_is_valid(render.bufnr), true, "metrics buffer is valid")
@@ -192,9 +202,9 @@ assert_equal(metrics_lines[2]:match("LOC metrics") ~= nil, true, "metrics title"
assert_equal(metrics_lines[#metrics_lines - 1]:match("4%-week net: %+14") ~= nil, true, "metrics summary")
assert_equal(#metrics_lines, 21, "metrics renders transposed three-line week rows with row gaps and outer padding")
assert_equal(rendered:match("%+7") ~= nil, true, "metrics renders net value")
assert_equal(rendered:match("04/19") ~= nil, true, "metrics renders date label")
assert_equal(rendered:match("04/20") ~= nil, true, "metrics renders zero tile date label")
assert_equal(rendered:match("05/16") ~= nil, true, "metrics renders future date label")
assert_equal(rendered:match(loc._metrics_display_date(render_dates[1].epoch)) ~= nil, true, "metrics renders date label")
assert_equal(rendered:match(loc._metrics_display_date(render_dates[2].epoch)) ~= nil, true, "metrics renders zero tile date label")
assert_equal(rendered:match(loc._metrics_display_date(render_dates[28].epoch)) ~= nil, true, "metrics renders future date label")
assert_equal(rendered:match("Sun") == nil, true, "metrics omits weekday labels")
assert_equal(vim.wo[render.winid].winhl:match("LocMetricsNormal") ~= nil, true, "metrics window uses custom normal highlight")
assert_equal(vim.wo[render.winid].winhl:match("FloatBorder") == nil, true, "metrics window does not set a border highlight")