From bbc81afdb9fc51b67fecfdeac9594cdb624edd96 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Thu, 30 Apr 2026 22:32:47 +0200 Subject: [PATCH] feat: init version --- .gitignore | 6 + README.md | 134 +++++++++++++++++ lua/loc/init.lua | 355 ++++++++++++++++++++++++++++++++++++++++++++++ plugin/loc.lua | 30 ++++ test/loc_spec.lua | 34 +++++ 5 files changed, 559 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lua/loc/init.lua create mode 100644 plugin/loc.lua create mode 100644 test/loc_spec.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e6444e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +nvim.log +.DS_Store +*.swp +*.swo +*.tmp +luacov.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..58286c7 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/lua/loc/init.lua b/lua/loc/init.lua new file mode 100644 index 0000000..9121756 --- /dev/null +++ b/lua/loc/init.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 diff --git a/plugin/loc.lua b/plugin/loc.lua new file mode 100644 index 0000000..aabfc25 --- /dev/null +++ b/plugin/loc.lua @@ -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" }) diff --git a/test/loc_spec.lua b/test/loc_spec.lua new file mode 100644 index 0000000..023f960 --- /dev/null +++ b/test/loc_spec.lua @@ -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")