feat: init version

This commit is contained in:
2026-04-30 22:32:47 +02:00
commit bbc81afdb9
5 changed files with 559 additions and 0 deletions
+355
View File
@@ -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