local M = {} local defaults = { auto_enable = true, data_path = nil, flush_interval_ms = 10000, statusline_prefix = "LOC", } local state = { config = nil, enabled = false, augroup = nil, snapshots = {}, base_days = {}, pending_days = {}, flush_timer = nil, metrics_render = 0, } local metrics_color_stops = { { at = 0.00, color = "#f0b6c1" }, { at = 0.35, color = "#eba1ae" }, { at = 0.65, color = "#df617a" }, { at = 1.00, color = "#b90f36" }, } local metrics_zero_style = { bg = "#252628", value_fg = "#e5e39a", date_fg = "#8f9096", } local metrics_low_text_style = { value_fg = "#2b2529", date_fg = "#58565b", } local metrics_high_text_style = { value_fg = "#ffd6df", date_fg = "#f0aebb", } local weekday_names = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } local chars_per_loc = 35 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) or table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/") end local json_encode = (vim.json and vim.json.encode) or vim.fn.json_encode local json_decode = (vim.json and vim.json.decode) or vim.fn.json_decode local function today_key() return vim.fn.strftime("%Y-%m-%d") end 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) if value == "" then return 0 end local ok, count = pcall(vim.fn.strchars, value) return ok and count or #value end local function is_continuation_byte(byte) return byte and byte >= 0x80 and byte <= 0xbf end local function has_char_boundary_at(value, byte_index) return byte_index <= 1 or byte_index > #value or not is_continuation_byte(value:byte(byte_index)) end local function has_char_boundary_after(value, byte_index) return has_char_boundary_at(value, byte_index + 1) 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 local day = ensure_day(state.pending_days, today_key()) day.added = day.added + added day.deleted = day.deleted + deleted redraw_statusline() 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) end state.snapshots[bufnr] = current end local function clamp(value, min, max) return math.max(min, math.min(max, value)) end local function chars_to_loc(chars) return (chars < 0 and -1 or 1) * math.floor((math.abs(chars) / chars_per_loc) + 0.5) end local function hex_to_rgb(color) return { r = tonumber(color:sub(2, 3), 16), g = tonumber(color:sub(4, 5), 16), b = tonumber(color:sub(6, 7), 16), } end local function rgb_to_hex(rgb) return string.format("#%02x%02x%02x", rgb.r, rgb.g, rgb.b) end local function lerp_number(start, finish, t) return math.floor(start + ((finish - start) * t) + 0.5) end local function lerp_color(start, finish, t) local from = hex_to_rgb(start) local to = hex_to_rgb(finish) return rgb_to_hex({ r = lerp_number(from.r, to.r, t), g = lerp_number(from.g, to.g, t), b = lerp_number(from.b, to.b, t), }) end local function metrics_color(t) t = clamp(t or 0, 0, 1) for index = 1, #metrics_color_stops - 1 do local left = metrics_color_stops[index] local right = metrics_color_stops[index + 1] if t <= right.at then local span = right.at - left.at local local_t = span == 0 and 0 or (t - left.at) / span return lerp_color(left.color, right.color, local_t) end end return metrics_color_stops[#metrics_color_stops].color end local function metrics_tile_style(net, max_abs_net) local abs_net = math.abs(net or 0) if abs_net == 0 or max_abs_net == 0 then return vim.tbl_extend("force", {}, metrics_zero_style) end local t = clamp(abs_net / max_abs_net, 0, 1) local text_style = t >= 0.75 and metrics_high_text_style or metrics_low_text_style return { bg = metrics_color(t), value_fg = text_style.value_fg, date_fg = text_style.date_fg, } end local function highlight_color(name, key) local ok, highlight = pcall(vim.api.nvim_get_hl, 0, { name = name, link = false }) if not ok or type(highlight) ~= "table" or type(highlight[key]) ~= "number" then return nil end return highlight[key] end local function resolve_metrics_background() return { bg = highlight_color("StatusLine", "bg") or highlight_color("Normal", "bg"), fg = highlight_color("StatusLine", "fg") or highlight_color("Normal", "fg"), } end local function metrics_dates(now) now = now or vim.fn.localtime() local dates = {} local today_weekday = tonumber(vim.fn.strftime("%w", now)) local current_week_start = now - (today_weekday * 86400) local range_start = current_week_start - (21 * 86400) for offset = 0, 27 do local epoch = range_start + (offset * 86400) dates[#dates + 1] = { date = vim.fn.strftime("%Y-%m-%d", epoch), epoch = epoch, weekday = tonumber(vim.fn.strftime("%w", epoch)), } end return dates end local function metrics_display_date(epoch) return vim.fn.strftime("%m/%d", epoch) end local function stored_day_stats(date) return merged_day_stats(date) end local function display_day_stats(stats) local loc_added = chars_to_loc(stats.added) local loc_deleted = chars_to_loc(stats.deleted) local loc_net = chars_to_loc(stats.net) return loc_added, loc_deleted, loc_net end local function collect_metrics_entries(now) local dates = metrics_dates(now) local entries = {} local max_abs_net = 0 local total_net = 0 for index, item in ipairs(dates) do local stats = stored_day_stats(item.date) local loc_added, loc_deleted, loc_net = display_day_stats(stats) local loc_abs_net = math.abs(loc_net) local entry = { index = index, date = item.date, display_date = metrics_display_date(item.epoch), weekday = item.weekday, column = math.floor((index - 1) / 7) + 1, added = stats.added, deleted = stats.deleted, net = stats.net, abs_net = math.abs(stats.net), loc_added = loc_added, loc_deleted = loc_deleted, loc_net = loc_net, loc_abs_net = loc_abs_net, } max_abs_net = math.max(max_abs_net, loc_abs_net) total_net = total_net + loc_net entries[#entries + 1] = entry end return entries, entries[#entries].column, max_abs_net, total_net end local function max_display_width(lines) local width = 0 for _, line in ipairs(lines) do width = math.max(width, vim.fn.strdisplaywidth(line)) end return width end local function centered_text(value, width) local display_width = vim.fn.strdisplaywidth(value) if display_width >= width then return value end local left = math.floor((width - display_width) / 2) return string.rep(" ", left) .. value .. string.rep(" ", width - display_width - left) end local function clamped_metric_value(value) value = clamp(value, -9999, 9999) return (value > 0 and "+" or "") .. value end local function build_metrics_lines(column_count, total_net) local horizontal_padding = 4 local vertical_padding = 1 local lines = { "LOC metrics", "", } local cells = {} for week = 1, column_count do local row_lines = {} local row_cells = {} for _ = 1, metrics_cell_height do row_lines[#row_lines + 1] = "" end for weekday = 0, 6 do local start_col = #row_lines[1] for row = 1, metrics_cell_height do row_lines[row] = row_lines[row] .. string.rep(" ", metrics_cell_width) end row_cells[weekday] = { line = #lines, value_line = #lines + 1, date_line = #lines + 2, start_col = start_col, end_col = #row_lines[1], } if weekday < 6 then local gap = string.rep(" ", metrics_column_gap) for row = 1, metrics_cell_height do row_lines[row] = row_lines[row] .. gap end end end cells[week] = row_cells for _, line in ipairs(row_lines) do lines[#lines + 1] = line end if week < column_count then for _ = 1, metrics_row_gap do lines[#lines + 1] = "" end end end lines[#lines + 1] = "" lines[#lines + 1] = string.format("4-week net: %+d", total_net) local padded_lines = {} for _ = 1, vertical_padding do padded_lines[#padded_lines + 1] = "" end for _, line in ipairs(lines) do padded_lines[#padded_lines + 1] = string.rep(" ", horizontal_padding) .. line .. string.rep(" ", horizontal_padding) end for _ = 1, vertical_padding do padded_lines[#padded_lines + 1] = "" end for _, row_cells in pairs(cells) do for _, cell in pairs(row_cells) do cell.line = cell.line + vertical_padding cell.value_line = cell.value_line + vertical_padding cell.date_line = cell.date_line + vertical_padding cell.start_col = cell.start_col + horizontal_padding cell.end_col = cell.end_col + horizontal_padding end end return padded_lines, cells end local function set_metrics_keymaps(bufnr, winid) local function close() if vim.api.nvim_win_is_valid(winid) then vim.api.nvim_win_close(winid, true) end end local opts = { buffer = bufnr, nowait = true, silent = true } vim.keymap.set("n", "q", close, opts) vim.keymap.set("n", "", close, opts) -- Disable scrolling and movement local nop = function() end local keys = { "", "", "", "", "", "", "", "", "k", "j", "h", "l", "", "", "", "", "gg", "G" } for _, key in ipairs(keys) do vim.keymap.set("n", key, nop, opts) end end local function open_metrics_window(lines) local width = max_display_width(lines) local height = #lines local columns = vim.o.columns local rows = vim.o.lines width = math.min(width, math.max(1, columns - 4)) height = math.min(height, math.max(1, rows - 4)) local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].buftype = "nofile" vim.bo[bufnr].bufhidden = "wipe" vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = "locmetrics" vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modifiable = false local winid = vim.api.nvim_open_win(bufnr, true, { relative = "editor", width = width, height = height, row = math.max(0, math.floor((rows - height) / 2) - 1), col = math.max(0, math.floor((columns - width) / 2)), style = "minimal", }) vim.wo[winid].wrap = false vim.wo[winid].cursorline = false vim.wo[winid].number = false vim.wo[winid].relativenumber = false vim.wo[winid].scrolloff = 0 vim.wo[winid].sidescrolloff = 0 set_metrics_keymaps(bufnr, winid) return bufnr, winid end local function set_metrics_window_highlights(winid) local normal_group = "LocMetricsNormal" .. state.metrics_render vim.api.nvim_set_hl(0, normal_group, resolve_metrics_background()) vim.wo[winid].winhl = "Normal:" .. normal_group .. ",NormalFloat:" .. normal_group end local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net) local namespace = vim.api.nvim_create_namespace("loc_metrics") local title_group = "LocMetricsTitle" .. state.metrics_render vim.api.nvim_set_hl(0, title_group, { italic = true }) vim.api.nvim_buf_add_highlight(bufnr, namespace, title_group, 1, 4, 15) for _, entry in ipairs(entries) do local style = metrics_tile_style(entry.loc_net, max_abs_net) local group = string.format("LocMetricsCell%d_%02d", state.metrics_render, entry.index) local date_group = string.format("LocMetricsDate%d_%02d", state.metrics_render, entry.index) local cell = cells[entry.column][entry.weekday] local date = centered_text(entry.display_date, metrics_cell_width) vim.api.nvim_set_hl(0, group, { bg = style.bg, fg = style.value_fg }) vim.api.nvim_set_hl(0, date_group, { bg = style.bg, fg = style.date_fg, italic = true }) if entry.loc_net == 0 then vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { date }) else local value = centered_text(clamped_metric_value(entry.loc_net), metrics_cell_width) vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { value }) vim.api.nvim_buf_set_text(bufnr, cell.date_line, cell.start_col, cell.date_line, cell.end_col, { date }) end for row = 0, metrics_cell_height - 1 do vim.api.nvim_buf_add_highlight(bufnr, namespace, group, cell.line + row, cell.start_col, cell.end_col) end local date_line = entry.loc_net == 0 and cell.value_line or cell.date_line vim.api.nvim_buf_add_highlight(bufnr, namespace, date_group, date_line, cell.start_col, cell.end_col) end end local function flush_pending_now() if not state.config or not has_pending_days() then return true 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) write_days_to_disk(path, merged) return merged end) if not ok then return false, result end 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() if state.config then return end M.setup({ auto_enable = false }) 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 state.base_days = {} state.pending_days = {} reload_base_days() if was_enabled then start_flush_timer() elseif 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 end, }) vim.api.nvim_create_autocmd("VimLeavePre", { group = state.augroup, callback = function() flush_pending_now() end, }) start_flush_timer() end function M.disable() if not state.enabled then return end stop_flush_timer() if state.augroup then pcall(vim.api.nvim_del_augroup_by_id, state.augroup) end state.enabled = false state.augroup = nil state.snapshots = {} flush_pending_now() end function M.reset() ensure_setup() 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 vim.cmd.redrawstatus() end function M.stats() ensure_setup() return merged_day_stats(today_key()) end function M.display_stats() local stats = M.stats() local added, deleted, net = display_day_stats(stats) return { added = added, deleted = deleted, net = net, } end function M.statusline() local stats = M.display_stats() local prefix = state.config.statusline_prefix return string.format("%s %+d", prefix, stats.net) end function M.metrics() ensure_setup() local entries, column_count, max_abs_net, total_net = collect_metrics_entries() local lines, cells = build_metrics_lines(column_count, total_net) local bufnr, winid = open_metrics_window(lines) state.metrics_render = state.metrics_render + 1 set_metrics_window_highlights(winid) vim.bo[bufnr].modifiable = true apply_metrics_highlights(bufnr, cells, entries, max_abs_net) vim.bo[bufnr].modifiable = false return { bufnr = bufnr, winid = winid, entries = entries, total_net = total_net, max_abs_net = max_abs_net, } end function M.save() ensure_setup() flush_pending_now() end M._measure_change = measure_change M._metrics_color = metrics_color M._metrics_dates = metrics_dates M._metrics_entries = collect_metrics_entries M._metrics_background = resolve_metrics_background M._metrics_centered_text = centered_text M._metrics_value = clamped_metric_value M._metrics_tile_style = metrics_tile_style M._metrics_display_date = metrics_display_date M._chars_to_loc = chars_to_loc return M