diff --git a/CLAUDE.md b/CLAUDE.md index 82db360..132c281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,14 +24,17 @@ Neovim plugin for coding activity tracking, written in Lua. Communicates with Sh ``` lua/shelltime/ init.lua -- Entry point: setup(), enable/disable, status, flush - config.lua -- YAML config loading from ~/.shelltime/config.yaml + config.lua -- Config: the CLI's files (config.yaml > .yml > .toml, plus config.local.*) heartbeat.lua -- Autocmd event handling, heartbeat creation, debouncing sender.lua -- Timer-based batching, socket communication socket.lua -- Unix socket client (vim.loop/libuv) + offline.lua -- Leaves undelivered heartbeats in the daemon's resend file on exit utils/ git.lua -- Git branch detection system.lua -- OS/hostname info yaml.lua -- Minimal YAML parser + toml.lua -- Minimal TOML parser + language.lua -- Filetype -> VS Code language id plugin/shelltime.lua -- Auto-load, user commands (:ShellTimeStatus, etc.) ``` @@ -55,7 +58,7 @@ plugin/shelltime.lua -- Auto-load, user commands (:ShellTimeStatus, etc.) ## Key Constants -- Config path: `~/.shelltime/config.yaml` +- Config files: `~/.shelltime/config.{yaml,yml,toml}` + `config.local.*` (or `setup({ config = path })`) - Socket path: `/tmp/shelltime.sock` - Heartbeat interval: 120000ms (2 min) - Debounce interval: 30000ms (30 sec) diff --git a/README.md b/README.md index 0a2ee2d..7f9d8da 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ A Neovim plugin for automatic coding activity tracking. Works with the ShellTime ## Features - **Automatic Time Tracking** - Passively monitors coding activity -- **Language Detection** - Uses the buffer's filetype, falling back to the file extension +- **Language Detection** - Uses the buffer's filetype, falling back to the file extension, and reports it as the VS Code language id (`sh` → `shellscript`, `text` → `plaintext`, …) so a language adds up across editors - **Project Analytics** - Tracks time per project/workspace - **Git Integration** - Records activity by git branch - **Debug Detection** - Marks activity as `debugging` while an nvim-dap session is active -- **Debouncing & Batching** - At most one heartbeat per file every 30s (saves always count), sent in batches every 2 minutes -- **Offline Support** - Keeps heartbeats in memory and retries them when the daemon is unavailable -- **Flush on Exit** - Sends pending heartbeats when Neovim quits, so short sessions aren't lost +- **Debouncing & Batching** - At most one heartbeat per file every 30s, sent in batches every 2 minutes +- **Offline Support** - Keeps heartbeats in memory and retries them when the daemon is unavailable or has code tracking turned off +- **Flush on Exit** - Sends pending heartbeats when Neovim quits, and leaves what the daemon can't take for it to send later, so short sessions aren't lost - **CLI Update Check** - Warns once per session when your ShellTime CLI is out of date ## Prerequisites @@ -61,13 +61,12 @@ Or in TOML format (`~/.shelltime/config.toml`): enabled = true ``` -Configs created by `shelltime init` already include this; older configs may not. The daemon ignores editor heartbeats unless it is `true`, and it reads the setting at startup, so restart the daemon after changing it (for example with `shelltime daemon reinstall`). +Configs created by `shelltime init` already include this; older configs may not. The daemon ignores editor heartbeats unless it is `true`. Recent daemons pick the change up right away; older ones read it only at startup, so restart them after changing it (for example with `shelltime daemon reinstall`). ## Requirements - Neovim >= 0.10.0 (Neovim only; classic Vim isn't supported) - ShellTime daemon running (see [Prerequisites](#prerequisites)) -- Git (optional, for branch tracking) - `curl` (optional, for the CLI update check) ## Installation @@ -117,19 +116,19 @@ Calling `setup()` is required: loading the plugin only registers its commands. W ## Configuration -The plugin reads settings from `~/.shelltime/config.yaml` by default: +The plugin reads the same files as the ShellTime CLI: the first of `~/.shelltime/config.yaml`, `config.yml` and `config.toml`, with the first `config.local.yaml`, `config.local.yml` or `config.local.toml` merged over it. ```lua --- Default setup (uses ~/.shelltime/config.yaml) +-- Default setup (reads the CLI's config files) require("shelltime").setup() --- Custom config path +-- Read one file instead (YAML or TOML) require("shelltime").setup({ config = "/path/to/your/config.yaml", }) ``` -`config` is the only `setup()` option. The plugin reads just this one YAML file: not `config.toml`, `config.yml` or `config.local.*`. If the file is missing, it uses the defaults below. The file is re-read whenever it changes, so most settings apply without restarting Neovim (`heartbeatInterval` needs a restart). +`config` is the only `setup()` option. If there is no config file, the plugin uses the defaults below. The files are checked for changes every few seconds, so most settings apply without restarting Neovim, `codeTracking.enabled` included (`heartbeatInterval` needs a restart). ### Config File Options @@ -150,7 +149,7 @@ require("shelltime").setup({ | `:ShellTimeStatus` | Show daemon connection status (version, uptime, platform) and pending heartbeats | | `:ShellTimeFlush` | Manually flush pending heartbeats to daemon | | `:ShellTimeEnable` | Enable tracking for this session (needs `setup()`; doesn't override `codeTracking.enabled: false`) | -| `:ShellTimeDisable` | Flush pending heartbeats and disable tracking for this session | +| `:ShellTimeDisable` | Send pending heartbeats (what the daemon doesn't take goes to its resend file, as on exit) and disable tracking for this session | ## How It Works @@ -163,14 +162,16 @@ The plugin monitors these Neovim events: | `BufWritePost` | Saving a file | | `CursorMoved` / `CursorMovedI` | Moving cursor | +Commands that pass through many buffers (`:bufdo`, `:argdo`, `:cfdo`, session restore) count only the buffer that is current when they finish, and the text change of a reload from disk (the file changed outside after a `git checkout`, or a formatter or agent rewrote it) isn't counted as editing, while an undo back to the saved state is. Saves with `:wa` are attributed to each buffer written, at its own cursor. + Only regular files are tracked. Special buffers (help, terminal, quickfix, etc.), unnamed buffers, files inside `.git/`, and non-`file://` URLs such as `oil://` or `fugitive://` are skipped. Heartbeats are: - **Deduplicated**: Buffer and cursor events at the same file and cursor position as the last event are skipped (edits always count) -- **Debounced**: Max 1 heartbeat per file per 30 seconds (except saves) -- **Batched**: Sent to the daemon over its Unix socket every 2 minutes -- **Flushed on exit**: Pending heartbeats are sent on `VimLeavePre` (Neovim waits up to 1.5s) -- **Queued**: Kept in memory and retried on the next flush if the daemon is unavailable (up to 5,000; oldest dropped first). They are not written to disk, so they are lost if Neovim exits while the daemon is down +- **Debounced**: Max 1 heartbeat per file per 30 seconds. Saves count once per window too, since auto-save plugins write after every change: the last save inside the window is kept, at its own time and cursor, and sent with the file's next heartbeat or the next flush, so the save before `:wq` always counts +- **Batched**: Sent to the daemon over its Unix socket every 2 minutes, at most 500 per message +- **Flushed on exit**: Pending heartbeats are sent on `VimLeavePre`, and Neovim waits up to 1.5s for them and for any send already in progress. What the daemon doesn't take, or doesn't acknowledge in time, goes to `~/.shelltime/coding-heartbeat.data.log`, which the daemon resends once it runs +- **Queued**: Kept in memory and retried on the next flush if the daemon is unavailable or has code tracking turned off (up to 5,000; oldest dropped first) ### Data Tracked @@ -182,11 +183,11 @@ Each heartbeat includes: - **System info**: Hostname, OS, OS version - **Activity**: Timestamp, whether it was a save event, category (`coding` or `debugging`) -The project root is the nearest parent directory containing `.git`, `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `setup.py`, `Makefile`, `CMakeLists.txt`, `.project` or `.root` (otherwise the file's directory). The project name is its last two path segments, e.g. `code/my-app`. +The project root is the git repository the file is in (as in VS Code and JetBrains, so a monorepo's packages count toward one project). Outside a repository it is the nearest parent directory containing `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `setup.py`, `Makefile`, `CMakeLists.txt`, `.project` or `.root`, otherwise the file's directory. The project name is its last two path segments, e.g. `code/my-app`. The branch is read from the repository's `HEAD`, without running `git`, except in a reftable repository, whose `HEAD` is a stub: there it comes from `git rev-parse --abbrev-ref HEAD`, asked at most every 30 seconds per repository. ### CLI Update Check -On startup, if the daemon is reachable, the plugin asks `/api/v1/cli/version-check` (via `curl`) whether the daemon's CLI version is the latest. If not, it shows a warning once per session with the update command (`curl -sSL /i | bash`) and copies that command to the `+` register when a clipboard is available. The check is skipped when `apiEndpoint` or `webEndpoint` is not set. +On startup, if the daemon is reachable, the plugin asks `/api/v1/cli/version-check` (via `curl`) whether the daemon's CLI version is the latest. If not, it shows a warning once per session with the update command (`curl -sSL /i | bash`). The check is skipped when `apiEndpoint` or `webEndpoint` is not set. ## Troubleshooting @@ -218,7 +219,7 @@ On startup, if the daemon is reachable, the plugin asks `/api/v1/cl Run `:ShellTimeStatus` to check: - If "Disconnected", ensure daemon is running and `socketPath` matches the daemon's socket - If pending heartbeats > 0, try `:ShellTimeFlush` -- If "Connected" and flushes succeed but no activity shows up, check that `codeTracking.enabled` is `true` for the daemon and restart it. The daemon drops heartbeats otherwise, and the plugin can't tell +- If it warns that code tracking is off in the daemon config, set `codeTracking.enabled` to `true` (and restart an older daemon). The daemon drops heartbeats until then; the plugin keeps them queued ## License diff --git a/lua/shelltime/config.lua b/lua/shelltime/config.lua index 7a2e2e3..7bc8323 100644 --- a/lua/shelltime/config.lua +++ b/lua/shelltime/config.lua @@ -1,6 +1,7 @@ -- Configuration loading for shelltime local yaml = require('shelltime.utils.yaml') +local toml = require('shelltime.utils.toml') local M = {} @@ -18,9 +19,20 @@ local defaults = { -- Default config file path local default_config_path = '~/.shelltime/config.yaml' +-- Without a `config` option the plugin reads what the ShellTime CLI reads +-- (cli/model/config.go): the first of these base files that exists, with the +-- first local file merged over it +local config_dir = '~/.shelltime' +local base_files = { 'config.yaml', 'config.yml', 'config.toml' } +local local_files = { 'config.local.yaml', 'config.local.yml', 'config.local.toml' } + +-- How often get_config() looks at the files again; it runs on every cursor move +local RECHECK_INTERVAL_MS = 5000 + -- Cached config local cached_config = nil -local cached_mtime = nil +local cached_signature = nil +local last_check = nil local config_path = nil local test_mode = false @@ -41,17 +53,56 @@ end local function get_mtime(path) local stat = vim.loop.fs_stat(path) if stat then - return stat.mtime.sec + return stat.mtime.sec * 1e9 + stat.mtime.nsec + end + return nil +end + +--- First of the given names in the config directory that exists +---@param names string[] File names +---@return string|nil Expanded path +local function first_existing(names) + local dir = expand_path(config_dir) + for _, name in ipairs(names) do + local path = dir .. '/' .. name + if vim.loop.fs_stat(path) then + return path + end end return nil end ---- Load configuration from YAML file ----@param path string Config file path +--- The files to read, base first +---@return string[] Expanded paths +local function config_files() + if config_path then + return { expand_path(config_path) } + end + local base = first_existing(base_files) + if not base then + return {} + end + local local_file = first_existing(local_files) + return local_file and { base, local_file } or { base } +end + +--- Paths and modification times of the files, to tell when they change +---@param files string[] Expanded paths +---@return string +local function signature_of(files) + local parts = {} + for _, path in ipairs(files) do + table.insert(parts, path .. ':' .. tostring(get_mtime(path))) + end + return table.concat(parts, '|') +end + +--- Load configuration from a YAML or TOML file +---@param path string Expanded config file path ---@return table Configuration table local function load_config_file(path) - local expanded = expand_path(path) - local file_config, err = yaml.load_file(expanded) + local parser = path:match('%.toml$') and toml or yaml + local file_config, err = parser.load_file(path) if err then if defaults.debug then @@ -63,6 +114,24 @@ local function load_config_file(path) return file_config or {} end +--- Merge a local override file over the base file, as the CLI's mergeConfig: +--- set values replace the base's, empty strings don't, and codeTracking merges +--- field by field (a local file that only sets its token keeps tracking on) +---@param base table Base file config +---@param override table Local file config +---@return table Merged file config +local function merge_file_configs(base, override) + local merged = vim.tbl_extend('force', {}, base) + for key, value in pairs(override) do + if key == 'codeTracking' and type(value) == 'table' and type(merged[key]) == 'table' then + merged[key] = merge_file_configs(merged[key], value) + elseif value ~= '' then + merged[key] = value + end + end + return merged +end + --- Merge file config with defaults ---@param file_config table Config from file ---@return table Merged config @@ -92,13 +161,15 @@ local function merge_config(file_config) config.debounce_interval = file_config.debounceInterval end - -- API and web endpoints for version check - if file_config.apiEndpoint then - config.api_endpoint = file_config.apiEndpoint + -- API and web endpoints for version check (TOML spells them APIEndpoint and WebEndpoint) + local api_endpoint = file_config.apiEndpoint or file_config.APIEndpoint + if api_endpoint then + config.api_endpoint = api_endpoint end - if file_config.webEndpoint then - config.web_endpoint = file_config.webEndpoint + local web_endpoint = file_config.webEndpoint or file_config.WebEndpoint + if web_endpoint then + config.web_endpoint = web_endpoint end return config @@ -108,11 +179,12 @@ end ---@param opts table|nil Setup options function M.setup(opts) opts = opts or {} - config_path = opts.config or default_config_path + config_path = opts.config -- Force reload cached_config = nil - cached_mtime = nil + cached_signature = nil + last_check = nil test_mode = false end @@ -124,18 +196,26 @@ function M.get_config() return cached_config end - local path = expand_path(config_path or default_config_path) - local mtime = get_mtime(path) + -- Look at the files at most every few seconds + local now = vim.loop.hrtime() / 1e6 + if cached_config and last_check and now - last_check < RECHECK_INTERVAL_MS then + return cached_config + end + last_check = now - -- Use cache if valid - if cached_config and mtime and mtime == cached_mtime then + local files = config_files() + local signature = signature_of(files) + if cached_config and signature == cached_signature then return cached_config end -- Load and cache - local file_config = load_config_file(config_path or default_config_path) + local file_config = {} + for _, path in ipairs(files) do + file_config = merge_file_configs(file_config, load_config_file(path)) + end cached_config = merge_config(file_config) - cached_mtime = mtime + cached_signature = signature return cached_config end @@ -154,10 +234,19 @@ function M.is_enabled() return M.get('enabled') == true end ---- Get config file path +--- Get config file path: the one passed to setup(), else the base file the +--- CLI's discovery picks (config.yaml when there is none yet) ---@return string Config path function M.get_config_path() - return config_path or default_config_path + if config_path then + return config_path + end + for _, name in ipairs(base_files) do + if vim.loop.fs_stat(expand_path(config_dir .. '/' .. name)) then + return config_dir .. '/' .. name + end + end + return default_config_path end --- Set config values directly (for testing only) diff --git a/lua/shelltime/heartbeat.lua b/lua/shelltime/heartbeat.lua index 7efb89c..fdebf6d 100644 --- a/lua/shelltime/heartbeat.lua +++ b/lua/shelltime/heartbeat.lua @@ -19,6 +19,14 @@ local MAX_PENDING = 5000 -- Last heartbeat time per file (for debouncing) local last_heartbeat_time = {} +-- The write heartbeat of the latest save inside its file's debounce window, +-- per file: sent ahead of the file's next heartbeat, or with the next flush +local pending_writes = {} + +-- Each buffer's changedtick right after it was read from disk: a text change +-- at that tick is the file loaded or reloaded, not typing +local read_ticks = {} + -- Last activity state (for duplicate detection) local last_activity = { file_path = nil, @@ -32,8 +40,9 @@ local augroup = nil --- Check if DAP debugger is active ---@return boolean local function is_debugging() - local ok, dap = pcall(require, 'dap') - if ok and dap.session then + -- Only when nvim-dap is already loaded: requiring it would load a lazy-loaded plugin + local dap = package.loaded['dap'] + if type(dap) == 'table' and type(dap.session) == 'function' then return dap.session() ~= nil end return false @@ -124,18 +133,39 @@ local function update_last_activity(file_path, line_number, cursor_position) last_activity.cursor_position = cursor_position end ---- Create heartbeat data for current buffer +--- Cursor of a buffer: from a window showing it, else where it was last left. +--- The current window isn't always it: :wa and :bufdo write other buffers, which +--- Neovim shows in a temporary autocmd window with the cursor on line 1. +---@param bufnr number Buffer number +---@return table|nil { line (1-indexed), column (0-indexed) } +local function get_cursor(bufnr) + if vim.api.nvim_get_current_buf() == bufnr and vim.fn.win_gettype() ~= 'autocmd' then + return vim.api.nvim_win_get_cursor(0) + end + for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do + if vim.fn.win_gettype(win) ~= 'autocmd' then + return vim.api.nvim_win_get_cursor(win) + end + end + local ok, mark = pcall(vim.api.nvim_buf_get_mark, bufnr, '"') + if ok and mark[1] > 0 then + return mark + end + return nil +end + +--- Create heartbeat data for a buffer ---@param bufnr number Buffer number ---@param is_write boolean Whether this is a write event +---@param cursor table|nil Cursor from get_cursor ---@return table|nil Heartbeat data or nil -local function create_heartbeat(bufnr, is_write) +local function create_heartbeat(bufnr, is_write, cursor) local file_path = vim.api.nvim_buf_get_name(bufnr) if file_path == '' then return nil end local project_root = system.get_project_root(file_path) - local cursor = vim.api.nvim_win_get_cursor(0) return { heartbeatId = system.uuid(), @@ -148,8 +178,8 @@ local function create_heartbeat(bufnr, is_write) branch = git.get_branch(file_path), language = lang.get_language(vim.bo[bufnr].filetype, file_path), lines = vim.api.nvim_buf_line_count(bufnr), - lineNumber = cursor[1], -- Already 1-indexed - cursorPosition = cursor[2], -- 0-indexed column + lineNumber = cursor and cursor[1], -- Already 1-indexed + cursorPosition = cursor and cursor[2], -- 0-indexed column editor = 'neovim', editorVersion = system.get_editor_version(), plugin = 'shelltime', @@ -184,25 +214,32 @@ local function add_heartbeat(heartbeat) end --- Handle editor event +---@param bufnr number Buffer the event is for ---@param is_write boolean Whether this is a write event ---@param is_navigation boolean Whether this is a navigation event (BufEnter, cursor moves) -local function on_event(is_write, is_navigation) +local function on_event(bufnr, is_write, is_navigation) if not config.is_enabled() then return end - local bufnr = vim.api.nvim_get_current_buf() - if not is_valid_buffer(bufnr) then return end + -- A reload of the file from disk (:checktime after a git checkout, a + -- formatter or an AI agent rewrote it) changes the text but isn't typing. + -- 'modified' can't tell it from an undo back to the saved state. + if not is_write and not is_navigation + and read_ticks[bufnr] == vim.api.nvim_buf_get_changedtick(bufnr) then + return + end + local file_path = vim.api.nvim_buf_get_name(bufnr) -- Get cursor position for duplicate detection - local cursor = vim.api.nvim_win_get_cursor(0) - local line_number = cursor[1] - local cursor_position = cursor[2] + local cursor = get_cursor(bufnr) + local line_number = cursor and cursor[1] + local cursor_position = cursor and cursor[2] -- Skip repeated navigation events (same file and cursor position). -- Edits always count, even when the cursor stays in place (x, dd). @@ -214,12 +251,23 @@ local function on_event(is_write, is_navigation) -- This ensures we track the latest position even if debounce blocks sending update_last_activity(file_path, line_number, cursor_position) - if not should_send_heartbeat(file_path, is_write) then + -- Saves count once per debounce window too: an auto-save plugin writes after + -- every change. A save inside the window is held as the file's pending write + -- (at its own time and cursor, replacing an earlier one), so the last save + -- before :wq is still sent. + if not should_send_heartbeat(file_path, false) then + if is_write then + pending_writes[file_path] = create_heartbeat(bufnr, true, cursor) + end return end - local heartbeat = create_heartbeat(bufnr, is_write) + local heartbeat = create_heartbeat(bufnr, is_write, cursor) if heartbeat then + if pending_writes[file_path] then + add_heartbeat(pending_writes[file_path]) + pending_writes[file_path] = nil + end add_heartbeat(heartbeat) end end @@ -232,35 +280,49 @@ function M.start() augroup = vim.api.nvim_create_augroup('ShellTimeHeartbeat', { clear = true }) - -- File opened + -- File opened. :bufdo, :argdo, :cfdo and session restore enter every buffer + -- in turn; only the one still current once the command is done is the user's. vim.api.nvim_create_autocmd('BufEnter', { group = augroup, - callback = function() - on_event(false, true) + callback = function(args) + vim.schedule(function() + if vim.api.nvim_get_current_buf() == args.buf then + on_event(args.buf, false, true) + end + end) + end, + }) + + -- File read from disk, when opened or reloaded after it changed outside + vim.api.nvim_create_autocmd({ 'BufReadPost', 'FileChangedShellPost' }, { + group = augroup, + callback = function(args) + read_ticks[args.buf] = vim.api.nvim_buf_get_changedtick(args.buf) end, }) -- Text changed vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { group = augroup, - callback = function() - on_event(false, false) + callback = function(args) + on_event(args.buf, false, false) end, }) - -- File saved + -- File saved: args.buf is the buffer written, which :wa and :bufdo w make + -- different from the one being edited vim.api.nvim_create_autocmd('BufWritePost', { group = augroup, - callback = function() - on_event(true, false) + callback = function(args) + on_event(args.buf, true, false) end, }) -- Cursor moved vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { group = augroup, - callback = function() - on_event(false, true) + callback = function(args) + on_event(args.buf, false, true) end, }) end @@ -273,11 +335,15 @@ function M.stop() end end ---- Get and clear pending heartbeats +--- Get and clear pending heartbeats, the saves held back by the debounce included ---@return table[] Pending heartbeats function M.flush() local heartbeats = pending_heartbeats pending_heartbeats = {} + for _, write in pairs(pending_writes) do + table.insert(heartbeats, write) + end + pending_writes = {} return heartbeats end @@ -294,12 +360,13 @@ end --- Get pending heartbeat count ---@return number Count function M.get_pending_count() - return #pending_heartbeats + return #pending_heartbeats + vim.tbl_count(pending_writes) end --- Clear debounce cache function M.clear_cache() last_heartbeat_time = {} + pending_writes = {} last_activity = { file_path = nil, line_number = nil, diff --git a/lua/shelltime/init.lua b/lua/shelltime/init.lua index 6e72f3a..00f4a37 100644 --- a/lua/shelltime/init.lua +++ b/lua/shelltime/init.lua @@ -32,10 +32,11 @@ local function stop_tracking() return end - -- Flush remaining heartbeats before stopping - sender.flush() - sender.stop() + -- Stopping the sender removes its exit flush, so send what is pending now + -- and leave what the daemon doesn't take for it to send later heartbeat.stop() + sender.exit_flush() + sender.stop() enabled = false if config.get('debug') then @@ -53,22 +54,23 @@ function M.setup(opts) -- Initialize config config.setup(opts) - -- Start tracking if enabled - if config.is_enabled() then - start_tracking() - end + -- Listen even while codeTracking is off: every event checks the config, so + -- turning it on in the file takes effect without restarting Neovim + start_tracking() initialized = true end --- Enable tracking +---@return boolean Whether tracking runs function M.enable() if not initialized then vim.notify('[shelltime] Call setup() first', vim.log.levels.ERROR) - return + return false end start_tracking() + return true end --- Disable tracking @@ -76,10 +78,10 @@ function M.disable() stop_tracking() end ---- Check if tracking is enabled +--- Check if tracking is enabled: started, and not turned off in the config ---@return boolean function M.is_enabled() - return enabled + return enabled and config.is_enabled() end --- Force flush pending heartbeats @@ -115,7 +117,10 @@ function M.status() status.platform or 'unknown', pending ) - vim.notify(msg, vim.log.levels.INFO) + if sender.is_tracking_disabled() then + msg = msg .. '\n Code tracking is off in the daemon config: set codeTracking.enabled to true (an older daemon also needs a restart)' + end + vim.notify(msg, sender.is_tracking_disabled() and vim.log.levels.WARN or vim.log.levels.INFO) else vim.notify( string.format( diff --git a/lua/shelltime/offline.lua b/lua/shelltime/offline.lua new file mode 100644 index 0000000..636a376 --- /dev/null +++ b/lua/shelltime/offline.lua @@ -0,0 +1,57 @@ +-- Heartbeats left for the daemon to send + +local M = {} + +-- Heartbeats per line, as the sender batches them +local MAX_PER_LINE = 500 + +-- Don't grow the file without bound when no daemon ever reads it +local MAX_FILE_BYTES = 50 * 1024 * 1024 + +--- The daemon's file for heartbeats it could not send (cli/model/db.go +--- HEARTBEAT_LOG_FILE): one {"heartbeats": [...]} payload per line, resent when +--- the daemon starts and every 30 minutes. Heartbeat ids make a resend of one +--- that did arrive harmless. +---@return string Path +function M.default_path() + local home = os.getenv('HOME') or os.getenv('USERPROFILE') or '' + return home .. '/.shelltime/coding-heartbeat.data.log' +end + +--- Leave heartbeats that could not be delivered before Neovim exits for the +--- daemon to send once it runs again +---@param heartbeats table[] Heartbeats +---@param path string|nil File, the daemon's by default +---@return boolean Whether they were written +function M.save(heartbeats, path) + if #heartbeats == 0 then + return true + end + path = path or M.default_path() + + local uv = vim.uv or vim.loop + local stat = uv.fs_stat(path) + if stat and stat.size > MAX_FILE_BYTES then + return false + end + vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p') + + local lines = {} + for i = 1, #heartbeats, MAX_PER_LINE do + local ok, line = pcall(vim.json.encode, { heartbeats = vim.list_slice(heartbeats, i, i + MAX_PER_LINE - 1) }) + if not ok then + return false + end + table.insert(lines, line) + end + + local file = io.open(path, 'a') + if not file then + return false + end + file:write(table.concat(lines, '\n') .. '\n') + file:close() + return true +end + +return M diff --git a/lua/shelltime/sender.lua b/lua/shelltime/sender.lua index 080bb7c..9f6872c 100644 --- a/lua/shelltime/sender.lua +++ b/lua/shelltime/sender.lua @@ -4,6 +4,7 @@ local config = require('shelltime.config') local socket = require('shelltime.socket') local heartbeat = require('shelltime.heartbeat') local version = require('shelltime.version') +local offline = require('shelltime.offline') local M = {} @@ -16,10 +17,35 @@ local augroup = nil -- How long Neovim may wait on exit for pending heartbeats to be sent local EXIT_FLUSH_TIMEOUT = 1500 +-- Heartbeats per socket message, so a backlog never goes out as one huge request +local MAX_PER_MESSAGE = 500 + -- Connection status local is_connected = false ---- Send pending heartbeats to daemon +-- Sends in progress by id: their heartbeats and how many the daemon took, +-- until it is known what became of the rest +local outstanding = {} +local next_send_id = 0 + +-- Whether the daemon last dropped heartbeats because code tracking is off +local tracking_disabled = false +local disabled_notice_shown = false + +local function notify_tracking_disabled() + if disabled_notice_shown then + return + end + disabled_notice_shown = true + vim.notify( + '[shelltime] The daemon is not recording editor activity because code tracking is off. ' + .. 'Set codeTracking.enabled to true in ~/.shelltime/config.yaml (an older daemon also needs ' + .. '`shelltime daemon reinstall`). Your activity is kept until then.', + vim.log.levels.WARN + ) +end + +--- Send pending heartbeats to daemon, at most MAX_PER_MESSAGE per message ---@param callback function|nil Optional callback(success, error) local function send_heartbeats(callback) local heartbeats = heartbeat.flush() @@ -31,16 +57,28 @@ local function send_heartbeats(callback) return end - socket.send_heartbeats(heartbeats, function(success, err) - is_connected = success + next_send_id = next_send_id + 1 + local id = next_send_id + local send = { heartbeats = heartbeats, delivered = 0 } + outstanding[id] = send - if not success then + local function finish(sent, status, err) + -- exit_flush gives up on a send that takes too long and saves the rest itself + local abandoned = outstanding[id] == nil + outstanding[id] = nil + is_connected = status ~= 'failed' + tracking_disabled = status == 'disabled' + + if sent < #heartbeats and not abandoned then -- Keep them for the next flush instead of dropping them - heartbeat.requeue(heartbeats) + heartbeat.requeue(vim.list_slice(heartbeats, sent + 1)) + end + if tracking_disabled then + notify_tracking_disabled() end if config.get('debug') then - if success then + if status == 'ok' then vim.notify( string.format('[shelltime] Sent %d heartbeats', #heartbeats), vim.log.levels.DEBUG @@ -54,9 +92,32 @@ local function send_heartbeats(callback) end if callback then - callback(success, err) + callback(status == 'ok', err) end - end) + end + + local function send_from(sent) + if sent >= #heartbeats then + finish(sent, 'ok', nil) + return + end + local chunk = vim.list_slice(heartbeats, sent + 1, sent + MAX_PER_MESSAGE) + socket.send_heartbeats(chunk, function(status, err) + if status == 'ok' then + send.delivered = sent + #chunk + if outstanding[id] == nil then + -- exit_flush stopped waiting and saved the rest to the offline file + finish(sent + #chunk, status, err) + return + end + send_from(sent + #chunk) + else + finish(sent, status, err) + end + end) + end + + send_from(0) end --- Start periodic flush timer @@ -81,7 +142,7 @@ function M.start() vim.api.nvim_create_autocmd('VimLeavePre', { group = augroup, callback = function() - M.flush_sync(EXIT_FLUSH_TIMEOUT) + M.exit_flush() end, }) @@ -120,25 +181,48 @@ function M.flush(callback) send_heartbeats(callback) end ---- Flush pending heartbeats and block until sent or timed out +--- Flush pending heartbeats and block until every send in progress, this one +--- and any started earlier, has finished or the timeout passed ---@param timeout number Maximum wait in milliseconds ----@return boolean True if the send finished within the timeout +---@return boolean True if they all finished within the timeout function M.flush_sync(timeout) - local done = false - send_heartbeats(function() - done = true - end) + send_heartbeats() return vim.wait(timeout, function() - return done + return next(outstanding) == nil end, 10) end +--- Send what is pending before Neovim exits, and leave what doesn't go out +--- (no daemon, or no answer in time) for the daemon to send later: what the +--- sends still in progress have not delivered, and what is still queued +---@param timeout number|nil Maximum wait in milliseconds (1.5 s by default) +function M.exit_flush(timeout) + M.flush_sync(timeout or EXIT_FLUSH_TIMEOUT) + + local ids = vim.tbl_keys(outstanding) + table.sort(ids) + local unsent = {} + for _, id in ipairs(ids) do + local send = outstanding[id] + outstanding[id] = nil + vim.list_extend(unsent, send.heartbeats, send.delivered + 1) + end + vim.list_extend(unsent, heartbeat.flush()) + offline.save(unsent) +end + --- Get connection status ---@return boolean function M.is_connected() return is_connected end +--- Whether the daemon dropped the last heartbeats because code tracking is off +---@return boolean +function M.is_tracking_disabled() + return tracking_disabled +end + --- Check and update connection status ---@param callback function Callback(connected) function M.check_status(callback) diff --git a/lua/shelltime/socket.lua b/lua/shelltime/socket.lua index 56c7fef..48c5cbd 100644 --- a/lua/shelltime/socket.lua +++ b/lua/shelltime/socket.lua @@ -15,6 +15,17 @@ function M.send(message, callback) local socket_path = config.get('socket_path') local pipe = uv.new_pipe(false) + -- A timeout and a late reply can both be scheduled; only the first one counts + local done = false + local user_callback = callback + callback = function(...) + if done then + return + end + done = true + user_callback(...) + end + if not pipe then callback(nil, 'Failed to create pipe') return @@ -98,7 +109,7 @@ function M.send(message, callback) callback(nil, 'Failed to parse response') end else - callback(nil, nil) -- Empty response is ok for heartbeats + callback(nil, nil) -- Closed without a reply end end) end @@ -110,7 +121,8 @@ end --- Send heartbeats to daemon ---@param heartbeats table[] Array of heartbeat data ----@param callback function Callback(success, error) +---@param callback function Callback(status, error): status is 'ok', 'disabled' when +--- the daemon dropped them because code tracking is off in its config, or 'failed' function M.send_heartbeats(heartbeats, callback) local message = { type = 'heartbeat', @@ -121,13 +133,66 @@ function M.send_heartbeats(heartbeats, callback) M.send(message, function(response, err) if err then - callback(false, err) + callback('failed', err) + elseif response == nil then + -- Daemons since 0.1.48 acknowledge every heartbeat message and close + -- without a reply when they could not take it (shutting down, or dying + -- before they answer), so the heartbeats must be kept. 0.1.42-0.1.47 + -- handled heartbeats but never replied (0.1.42 answers no status + -- either): an empty reply counts as delivered only when the daemon + -- still takes connections, which a stopping one no longer does. + M.can_connect(function(connected) + if connected then + callback('ok', nil) + else + callback('failed', 'no acknowledgment from the daemon') + end + end) + elseif type(response) == 'table' and response.status == 'disabled' then + callback('disabled', 'code tracking is disabled in the daemon') else - callback(true, nil) + callback('ok', nil) end end) end +--- Check that the daemon takes connections, without sending it anything +---@param callback function Callback(connected) +function M.can_connect(callback) + local uv = vim.loop or vim.uv + local pipe = uv.new_pipe(false) + if not pipe then + callback(false) + return + end + + local done = false + local timer = uv.new_timer() + local function finish(connected) + if done then + return + end + done = true + timer:stop() + if not timer:is_closing() then + timer:close() + end + if not pipe:is_closing() then + pipe:close() + end + vim.schedule(function() + callback(connected) + end) + end + + timer:start(SOCKET_TIMEOUT, 0, function() + finish(false) + end) + pipe:connect(config.get('socket_path'), function(err) + finish(err == nil) + end) +end + --- Get daemon status ---@param callback function Callback(status, error) function M.get_status(callback) diff --git a/lua/shelltime/utils/git.lua b/lua/shelltime/utils/git.lua index 37aa30e..83f1b68 100644 --- a/lua/shelltime/utils/git.lua +++ b/lua/shelltime/utils/git.lua @@ -2,7 +2,76 @@ local M = {} ---- Get current git branch for a file +-- How long a branch git printed is used before git is asked again +local BRANCH_CACHE_MS = 30000 + +-- Branches git printed, per git directory, for repositories whose HEAD file +-- doesn't name the branch +local branch_cache = {} + +--- Find the git directory for a file's directory: the nearest .git directory, +--- or where a worktree's or submodule's .git file points +---@param dir string Directory to start from +---@return string|nil Git directory +local function find_git_dir(dir) + local path = dir + while path and path ~= '' do + local dot_git = path .. '/.git' + if vim.fn.isdirectory(dot_git) == 1 then + return dot_git + end + if vim.fn.filereadable(dot_git) == 1 then + local ok, lines = pcall(vim.fn.readfile, dot_git, '', 1) + local gitdir = ok and lines[1] and lines[1]:match('^gitdir:%s*(.-)%s*$') + if gitdir then + if gitdir:sub(1, 1) ~= '/' then + gitdir = path .. '/' .. gitdir + end + return vim.fn.fnamemodify(gitdir, ':p'):gsub('/$', '') + end + end + local parent = vim.fn.fnamemodify(path, ':h') + if parent == path then + break + end + path = parent + end + return nil +end + +--- The branch git itself reports for a directory, at most once per git +--- directory every BRANCH_CACHE_MS, or '' when git fails +---@param dir string Directory in the repository +---@param git_dir string Its git directory +---@return string Branch name or empty string +-- How long the reftable fallback waits for git +local GIT_TIMEOUT_MS = 1000 + +local function branch_from_git(dir, git_dir) + local now = (vim.uv or vim.loop).now() + local cached = branch_cache[git_dir] + if cached and now - cached.at < BRANCH_CACHE_MS then + return cached.branch + end + + local branch = '' + -- vim.system raises an error when git isn't installed, and git fails outside a + -- repository. Only stdout holds the branch; warnings go to stderr. + local ok, result = pcall(function() + return vim.system({ 'git', '-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD' }, { text = true }):wait(GIT_TIMEOUT_MS) + end) + if ok and result.code == 0 then + branch = vim.trim(result.stdout or '') + end + branch_cache[git_dir] = { branch = branch, at = now } + return branch +end + +--- Get current git branch for a file, as `git rev-parse --abbrev-ref HEAD` +--- prints it ('HEAD' when detached). It reads HEAD instead of running git, +--- which blocked the editor on every heartbeat. A reftable repository keeps +--- its real HEAD in reftable/ and leaves a `ref: refs/heads/.invalid` stub, +--- so only there git is asked, and its answer cached. ---@param file_path string File path ---@return string Branch name or empty string function M.get_branch(file_path) @@ -13,25 +82,21 @@ function M.get_branch(file_path) return '' end - local result = vim.fn.systemlist({ - 'git', - '-C', - dir, - 'rev-parse', - '--abbrev-ref', - 'HEAD', - }) - - -- Check for errors - if vim.v.shell_error ~= 0 then + local git_dir = find_git_dir(dir) + if not git_dir then return '' end - if result and #result > 0 then - return vim.trim(result[1]) + local ok, lines = pcall(vim.fn.readfile, git_dir .. '/HEAD', '', 1) + if not ok or not lines[1] then + return '' end - return '' + local branch = lines[1]:match('^ref:%s*refs/heads/(.-)%s*$') + if branch == '.invalid' or vim.fn.isdirectory(git_dir .. '/reftable') == 1 then + return branch_from_git(dir, git_dir) + end + return branch or 'HEAD' end return M diff --git a/lua/shelltime/utils/language.lua b/lua/shelltime/utils/language.lua index 3e5dea0..0579b81 100644 --- a/lua/shelltime/utils/language.lua +++ b/lua/shelltime/utils/language.lua @@ -25,9 +25,10 @@ local extension_map = { h = 'c', hpp = 'cpp', cs = 'csharp', - sh = 'sh', - bash = 'bash', - zsh = 'zsh', + txt = 'plaintext', + sh = 'shellscript', + bash = 'shellscript', + zsh = 'shellscript', fish = 'fish', html = 'html', css = 'css', @@ -46,6 +47,23 @@ local extension_map = { conf = 'conf', } +-- Neovim filetypes whose VS Code language id differs. The ShellTime plugins report +-- VS Code ids, which the web merges on, so a language isn't split across editors. +local filetype_ids = { + sh = 'shellscript', + bash = 'shellscript', + zsh = 'shellscript', + text = 'plaintext', + make = 'makefile', + cs = 'csharp', + objc = 'objective-c', + objcpp = 'objective-cpp', + tex = 'latex', + ps1 = 'powershell', + ['javascript.jsx'] = 'javascriptreact', + ['typescript.tsx'] = 'typescriptreact', +} + --- Get language for a file ---@param filetype string|nil Buffer filetype ---@param file_path string File path @@ -53,7 +71,7 @@ local extension_map = { function M.get_language(filetype, file_path) -- Use filetype if available if filetype and filetype ~= '' then - return filetype + return filetype_ids[filetype] or filetype end -- Fallback: detect from file extension @@ -65,7 +83,8 @@ function M.get_language(filetype, file_path) return '' end - local ext = file_path:match('%.([^%.]+)$') + -- From the file name only: a dot in a directory (john.doe, .config) is no extension + local ext = filename:match('%.([^%.]+)$') if ext then ext = ext:lower() return extension_map[ext] or ext diff --git a/lua/shelltime/utils/system.lua b/lua/shelltime/utils/system.lua index 9d98d7c..984669c 100644 --- a/lua/shelltime/utils/system.lua +++ b/lua/shelltime/utils/system.lua @@ -56,14 +56,13 @@ local root_markers = { '.root', } ---- Find project root by searching for marker files ----@param file_path string Starting file path ----@return string Project root path -function M.get_project_root(file_path) - local path = vim.fn.fnamemodify(file_path, ':p:h') - +--- Find the nearest directory above a file that holds one of the markers +---@param path string Directory to start from +---@param markers string[] Marker file or directory names +---@return string|nil Directory +local function find_up(path, markers) while path and path ~= '/' and path ~= '' do - for _, marker in ipairs(root_markers) do + for _, marker in ipairs(markers) do local marker_path = path .. '/' .. marker if vim.fn.isdirectory(marker_path) == 1 or vim.fn.filereadable(marker_path) == 1 then return path @@ -75,9 +74,22 @@ function M.get_project_root(file_path) end path = parent end + return nil +end + +--- Find project root: the repository the file is in, else the nearest +--- directory with a project marker, else the file's directory. The repository +--- comes first, so a package.json or Makefile in a monorepo's subdirectory +--- doesn't split it into projects, and the project matches VS Code's workspace +--- folder and the JetBrains project. +---@param file_path string Starting file path +---@return string Project root path +function M.get_project_root(file_path) + local dir = vim.fn.fnamemodify(file_path, ':p:h') - -- Fallback to file's directory - return vim.fn.fnamemodify(file_path, ':p:h') + return find_up(dir, { '.git' }) + or find_up(dir, root_markers) + or dir end --- Get project name from root path (last 2 folder layers) diff --git a/lua/shelltime/utils/toml.lua b/lua/shelltime/utils/toml.lua new file mode 100644 index 0000000..bcc266e --- /dev/null +++ b/lua/shelltime/utils/toml.lua @@ -0,0 +1,101 @@ +-- Minimal TOML parser for shelltime config +-- Supports: key = value pairs, [table] and [[array of tables]] headers, with +-- strings, numbers and booleans + +local M = {} + +local function trim(s) + return s:match('^%s*(.-)%s*$') +end + +local function parse_value(value) + value = trim(value) + + -- Quoted string; a # inside it is not a comment + local quoted = value:match('^"(.-)"') or value:match("^'(.-)'") + if quoted then + return quoted + end + + -- Drop a trailing comment + value = trim(value:gsub('%s+#.*$', '')) + + if value == 'true' then + return true + elseif value == 'false' then + return false + end + + local num = tonumber(value) + if num then + return num + end + + return value +end + +--- Parse TOML string into Lua table +---@param content string TOML content +---@return table Parsed table +function M.parse(content) + local result = {} + local current = result + + for line in content:gmatch('[^\r\n]+') do + local trimmed = trim(line) + if trimmed ~= '' and not trimmed:match('^#') then + local array_name = trimmed:match('^%[%[%s*([%w_%.%-]+)%s*%]%]') + local table_name = trimmed:match('^%[%s*([%w_%.%-]+)%s*%]') + if array_name then + -- [[a.b]] starts a new table in the array a.b: the keys after it go + -- there, never to the table before it (old CLIs wrote [[ENDPOINTS]]) + local parent = result + for part in array_name:gmatch('([^%.]+)%.') do + parent[part] = parent[part] or {} + parent = parent[part] + end + local name = array_name:match('([^%.]+)$') + parent[name] = parent[name] or {} + current = {} + table.insert(parent[name], current) + elseif table_name then + -- [a.b] nests a table in a + current = result + for part in table_name:gmatch('[^%.]+') do + current[part] = current[part] or {} + current = current[part] + end + else + local key, value = trimmed:match('^([%w_%-]+)%s*=%s*(.-)$') + if key then + current[key] = parse_value(value) + end + end + end + end + + return result +end + +--- Load and parse TOML file +---@param path string File path +---@return table|nil Parsed table or nil on error +---@return string|nil Error message +function M.load_file(path) + local file = io.open(path, 'r') + if not file then + return nil, 'Cannot open file: ' .. path + end + + local content = file:read('*a') + file:close() + + local ok, result = pcall(M.parse, content) + if not ok then + return nil, 'Parse error: ' .. tostring(result) + end + + return result, nil +end + +return M diff --git a/lua/shelltime/utils/yaml.lua b/lua/shelltime/utils/yaml.lua index ad281f4..5fa41b8 100644 --- a/lua/shelltime/utils/yaml.lua +++ b/lua/shelltime/utils/yaml.lua @@ -10,15 +10,23 @@ end local function parse_value(value) value = trim(value) - -- Remove quotes if present + -- Remove quotes if present; a # inside them is not a comment + local quoted = value:match('^"(.-)"%s*#') or value:match("^'(.-)'%s*#") + if quoted then + return quoted + end if value:match('^".*"$') or value:match("^'.*'$") then return value:sub(2, -2) end - -- Boolean - if value == 'true' then + -- Drop a trailing comment (enabled: true # code tracking) + value = trim(value:gsub('%s+#.*$', '')) + + -- Boolean, in the spellings the CLI's YAML library reads as one + local lower = value:lower() + if lower == 'true' or lower == 'yes' or lower == 'on' then return true - elseif value == 'false' then + elseif lower == 'false' or lower == 'no' or lower == 'off' then return false end diff --git a/lua/shelltime/version.lua b/lua/shelltime/version.lua index 5851f61..5ad1ab5 100644 --- a/lua/shelltime/version.lua +++ b/lua/shelltime/version.lua @@ -71,7 +71,8 @@ function M.check_version(daemon_version, callback) -- Use curl asynchronously via vim.fn.jobstart local stdout_data = {} - vim.fn.jobstart({ 'curl', '-sSL', '-m', '5', '-H', 'Accept: application/json', url }, { + -- -f: an error page is not an answer + vim.fn.jobstart({ 'curl', '-fsSL', '-m', '5', '-H', 'Accept: application/json', url }, { stdout_buffered = true, on_stdout = function(_, data) if data then @@ -97,8 +98,8 @@ function M.check_version(daemon_version, callback) local response = table.concat(stdout_data, '') local result = parse_json(response) - if result then - if not result.isLatest then + if result and type(result.isLatest) == 'boolean' then + if not result.isLatest and result.latestVersion then has_shown_warning = true M.show_update_warning(daemon_version, result.latestVersion, web_endpoint) elseif config.get('debug') then @@ -134,13 +135,8 @@ function M.show_update_warning(current_version, latest_version, web_endpoint) update_command ) + -- The command is in the message; the clipboard is the user's vim.notify(message, vim.log.levels.WARN) - - -- Also copy to clipboard if available - if vim.fn.has('clipboard') == 1 then - vim.fn.setreg('+', update_command) - vim.notify('[shelltime] Update command copied to clipboard', vim.log.levels.INFO) - end end return M diff --git a/plugin/shelltime.lua b/plugin/shelltime.lua index 4fa5a01..1da49fb 100644 --- a/plugin/shelltime.lua +++ b/plugin/shelltime.lua @@ -27,8 +27,9 @@ end, { }) vim.api.nvim_create_user_command('ShellTimeEnable', function() - require('shelltime').enable() - vim.notify('[shelltime] Tracking enabled', vim.log.levels.INFO) + if require('shelltime').enable() then + vim.notify('[shelltime] Tracking enabled', vim.log.levels.INFO) + end end, { desc = 'Enable ShellTime tracking', }) diff --git a/tests/discovery_spec.lua b/tests/discovery_spec.lua new file mode 100644 index 0000000..01969bb --- /dev/null +++ b/tests/discovery_spec.lua @@ -0,0 +1,150 @@ +-- Config discovery, project roots and branches against real files in a temp HOME +describe('shelltime file discovery', function() + local stub = require('luassert.stub') + local tmp + local original_home + + local function write(rel, content) + local path = tmp .. '/' .. rel + vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p') + vim.fn.writefile(vim.split(content, '\n'), path) + return path + end + + before_each(function() + tmp = vim.fn.resolve(vim.fn.tempname()) + vim.fn.mkdir(tmp, 'p') + original_home = vim.env.HOME + vim.env.HOME = tmp + package.loaded['shelltime.config'] = nil + package.loaded['shelltime.utils.system'] = nil + package.loaded['shelltime.utils.git'] = nil + end) + + after_each(function() + vim.env.HOME = original_home + vim.fn.delete(tmp, 'rf') + end) + + describe('config without a config option', function() + it('should read config.yaml, which shelltime init writes', function() + write('.shelltime/config.yaml', 'socketPath: /tmp/yaml.sock\ncodeTracking:\n enabled: false # off for now') + local config = require('shelltime.config') + config.setup() + + assert.equals('/tmp/yaml.sock', config.get('socket_path')) + assert.is_false(config.is_enabled()) + end) + + it('should prefer config.yaml over config.yml and config.toml, like the CLI', function() + write('.shelltime/config.toml', 'socketPath = "/tmp/toml.sock"') + write('.shelltime/config.yml', 'socketPath: /tmp/yml.sock') + local config = require('shelltime.config') + + config.setup() + assert.equals('/tmp/yml.sock', config.get('socket_path')) + assert.equals('~/.shelltime/config.yml', config.get_config_path()) + + vim.fn.delete(tmp .. '/.shelltime/config.yml') + config.setup() + assert.equals('/tmp/toml.sock', config.get('socket_path')) + assert.equals('~/.shelltime/config.toml', config.get_config_path()) + end) + + it('should read the CLI TOML keys', function() + write('.shelltime/config.toml', 'APIEndpoint = "http://localhost:27312"\n[codeTracking]\nenabled = true') + local config = require('shelltime.config') + config.setup() + + assert.equals('http://localhost:27312', config.get('api_endpoint')) + assert.is_true(config.is_enabled()) + end) + + it('should merge config.local.* over the base file', function() + write('.shelltime/config.yaml', 'socketPath: /tmp/base.sock\nwebEndpoint: https://shelltime.xyz\ncodeTracking:\n enabled: false') + write('.shelltime/config.local.yaml', 'socketPath: /tmp/local.sock\ncodeTracking:\n enabled: true') + local config = require('shelltime.config') + config.setup() + + assert.equals('/tmp/local.sock', config.get('socket_path')) + assert.equals('https://shelltime.xyz', config.get('web_endpoint')) + assert.is_true(config.is_enabled()) + end) + + it('should keep tracking enabled when the local file only sets the token', function() + write('.shelltime/config.yaml', 'codeTracking:\n enabled: true') + write('.shelltime/config.local.yaml', 'codeTracking:\n token: secret') + local config = require('shelltime.config') + config.setup() + + assert.is_true(config.is_enabled()) + end) + + it('should use the defaults without any config file', function() + local config = require('shelltime.config') + config.setup() + + assert.equals('/tmp/shelltime.sock', config.get('socket_path')) + assert.is_true(config.is_enabled()) + end) + + it('should not look at the files on every call', function() + write('.shelltime/config.yaml', 'socketPath: /tmp/first.sock') + local config = require('shelltime.config') + config.setup() + assert.equals('/tmp/first.sock', config.get('socket_path')) + + local stat_stub = stub(vim.loop, 'fs_stat') + for _ = 1, 100 do + config.get('socket_path') + end + assert.stub(stat_stub).was_called(0) + stat_stub:revert() + end) + end) + + describe('project root', function() + it('should be the repository, not a nested package', function() + write('mono/.git/HEAD', 'ref: refs/heads/main') + write('mono/packages/ui/package.json', '{}') + local file = write('mono/packages/ui/src/Button.tsx', '') + local system = require('shelltime.utils.system') + + local root = system.get_project_root(file) + assert.equals(tmp .. '/mono', root) + assert.equals(vim.fn.fnamemodify(tmp, ':t') .. '/mono', system.get_project_name(root)) + end) + + it('should fall back to the nearest marker outside a repository', function() + write('plain/app/package.json', '{}') + local file = write('plain/app/src/index.js', '') + + assert.equals(tmp .. '/plain/app', require('shelltime.utils.system').get_project_root(file)) + end) + end) + + describe('branch', function() + it('should read the branch from HEAD', function() + write('repo/.git/HEAD', 'ref: refs/heads/feature/x') + local file = write('repo/src/main.go', '') + + assert.equals('feature/x', require('shelltime.utils.git').get_branch(file)) + end) + + it('should report HEAD when detached', function() + write('repo/.git/HEAD', '3f2c1b0e9d8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e') + local file = write('repo/main.go', '') + + assert.equals('HEAD', require('shelltime.utils.git').get_branch(file)) + end) + + it('should follow a relative gitdir, as submodules use', function() + write('super/.git/HEAD', 'ref: refs/heads/main') + write('super/.git/modules/lib/HEAD', 'ref: refs/heads/lib-branch') + write('super/lib/.git', 'gitdir: ../.git/modules/lib') + local file = write('super/lib/init.lua', '') + + assert.equals('lib-branch', require('shelltime.utils.git').get_branch(file)) + end) + end) +end) diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 23da413..5961aeb 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -67,6 +67,71 @@ describe('shelltime.utils.git', function() end end) + describe('reftable repositories', function() + -- git's reftable backend keeps the real HEAD in reftable/ and writes a stub to HEAD + local spy = require('luassert.spy') + local stub = require('luassert.stub') + local dir + local system + + local function git_in(repo, ...) + vim.fn.system(vim.list_extend({ 'git', '-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t' }, { ... })) + assert.equals(0, vim.v.shell_error) + end + + before_each(function() + dir = vim.fn.tempname() + vim.fn.mkdir(dir .. '/src', 'p') + end) + + after_each(function() + if system then + system:revert() + system = nil + end + vim.fn.delete(dir, 'rf') + end) + + it('should ask git, once per repository, when the git directory has reftable/', function() + git_in(dir, 'init', '-q') + git_in(dir, 'commit', '-q', '--allow-empty', '-m', 'init') + git_in(dir, 'checkout', '-q', '-b', 'feature/reftable') + vim.fn.mkdir(dir .. '/.git/reftable', 'p') + system = spy.on(vim, 'system') + + assert.equals('feature/reftable', git.get_branch(dir .. '/a.lua')) + assert.equals('feature/reftable', git.get_branch(dir .. '/src/b.lua')) + assert.spy(system).was_called(1) + end) + + it('should report no branch when git fails for a HEAD stub', function() + vim.fn.mkdir(dir .. '/.git/reftable', 'p') + vim.fn.writefile({ 'ref: refs/heads/.invalid' }, dir .. '/.git/HEAD') + + assert.equals('', git.get_branch(dir .. '/a.lua')) + end) + + it('should report no branch when git is not installed', function() + vim.fn.mkdir(dir .. '/.git', 'p') + vim.fn.writefile({ 'ref: refs/heads/.invalid' }, dir .. '/.git/HEAD') + system = stub(vim, 'system') + system.invokes(function() + error('ENOENT: no such file or directory (cmd): git') + end) + + assert.equals('', git.get_branch(dir .. '/a.lua')) + end) + + it('should not run git for a repository whose HEAD names the branch', function() + vim.fn.mkdir(dir .. '/.git', 'p') + vim.fn.writefile({ 'ref: refs/heads/main' }, dir .. '/.git/HEAD') + system = spy.on(vim, 'system') + + assert.equals('main', git.get_branch(dir .. '/a.lua')) + assert.spy(system).was_not_called() + end) + end) + it('should not contain newlines', function() local project_dir = helpers.get_project_dir() local test_file = project_dir .. '/lua/shelltime/init.lua' diff --git a/tests/heartbeat_spec.lua b/tests/heartbeat_spec.lua index ed3a428..7950d54 100644 --- a/tests/heartbeat_spec.lua +++ b/tests/heartbeat_spec.lua @@ -214,12 +214,20 @@ describe('shelltime.heartbeat', function() describe('autocmd events (integration)', function() local buffers = {} + -- Let scheduled callbacks (the deferred BufEnter) run + local function settle() + vim.wait(20, function() + return false + end) + end + -- Open a named file buffer in the current window, dropping its BufEnter heartbeat local function open_buffer(path) local bufnr = vim.api.nvim_create_buf(true, false) vim.api.nvim_buf_set_name(bufnr, path) table.insert(buffers, bufnr) vim.api.nvim_set_current_buf(bufnr) + settle() heartbeat.flush() return bufnr end @@ -255,6 +263,7 @@ describe('shelltime.heartbeat', function() it('should count edits that leave the cursor in place', function() local bufnr = open_buffer('/tmp/shelltime-test/edit.lua') + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'edited' }) vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) @@ -262,6 +271,120 @@ describe('shelltime.heartbeat', function() assert.equals(2, heartbeat.get_pending_count()) end) + it('should count only the buffer a buffer loop ends in', function() + -- :bufdo enters each buffer in one go; the user only sees the last + local paths = { '/tmp/shelltime-test/loop1.lua', '/tmp/shelltime-test/loop2.lua', '/tmp/shelltime-test/loop3.lua' } + for _, path in ipairs(paths) do + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(bufnr, path) + table.insert(buffers, bufnr) + vim.api.nvim_set_current_buf(bufnr) + end + settle() + + local pending = heartbeat.flush() + assert.equals(1, #pending) + assert.equals(paths[3], pending[1].entity) + end) + + it('should count saves once per debounce window and keep the last one', function() + config._set_for_testing({ debounce_interval = 60000 }) + local bufnr = open_buffer('/tmp/shelltime-test/autosave.lua') + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'a', 'b', 'c' }) + heartbeat.clear_cache() + + for line = 1, 3 do + vim.api.nvim_win_set_cursor(0, { line, 0 }) + vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr }) + end + + -- The window's write, then the last save inside it, where it happened + assert.equals(2, heartbeat.get_pending_count()) + local pending = heartbeat.flush() + assert.equals(2, #pending) + assert.is_true(pending[1].isWrite) + assert.equals(1, pending[1].lineNumber) + assert.is_true(pending[2].isWrite) + assert.equals(3, pending[2].lineNumber) + assert.equals(0, #heartbeat.flush()) + end) + + it('should send a save inside the window ahead of the file\'s next heartbeat', function() + config._set_for_testing({ debounce_interval = 60000 }) + local bufnr = open_buffer('/tmp/shelltime-test/save-then-edit.lua') + heartbeat.clear_cache() + local real_time = os.time + local now = real_time() + os.time = function() + return now + end + + local ok, err = pcall(function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'typed' }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + now = now + 5 + vim.api.nvim_exec_autocmds('BufWritePost', { buffer = bufnr }) + now = now + 60 + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'typed more' }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + end) + os.time = real_time + assert(ok, err) + + local pending = heartbeat.flush() + assert.equals(3, #pending) + assert.is_false(pending[1].isWrite) + assert.is_true(pending[2].isWrite) + assert.equals(pending[1].time + 5, pending[2].time) + assert.is_false(pending[3].isWrite) + end) + + it('should skip the text change of a reload from disk', function() + -- :checktime after a git checkout, or a formatter or an AI agent rewrote the file + local bufnr = open_buffer('/tmp/shelltime-test/reloaded.lua') + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'from disk' }) + vim.bo[bufnr].modified = false + vim.api.nvim_exec_autocmds('FileChangedShellPost', { buffer = bufnr }) + + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + assert.equals(0, heartbeat.get_pending_count()) + + -- Typing after the reload counts again + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'typed' }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + assert.equals(1, heartbeat.get_pending_count()) + end) + + it('should count an undo back to the saved state', function() + local bufnr = open_buffer('/tmp/shelltime-test/undone.lua') + vim.api.nvim_exec_autocmds('BufReadPost', { buffer = bufnr }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'saved text again' }) + vim.bo[bufnr].modified = false + + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + + assert.equals(1, heartbeat.get_pending_count()) + end) + + it('should attribute a write to the buffer written, not the current one', function() + -- :wa writes other buffers while another one is current + local other = open_buffer('/tmp/shelltime-test/other.lua') + vim.api.nvim_buf_set_lines(other, 0, -1, false, { 'a', 'b', 'c' }) + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + local current = open_buffer('/tmp/shelltime-test/current.lua') + + vim.api.nvim_exec_autocmds('BufWritePost', { buffer = other }) + + local pending = heartbeat.flush() + assert.equals(1, #pending) + assert.equals('/tmp/shelltime-test/other.lua', pending[1].entity) + assert.is_true(pending[1].isWrite) + -- Where the cursor was left in that buffer, not the current window's + assert.equals(3, pending[1].lineNumber) + assert.equals(3, pending[1].lines) + assert.equals(current, vim.api.nvim_get_current_buf()) + end) + it('should skip repeated cursor events at the same position', function() local bufnr = open_buffer('/tmp/shelltime-test/nav.lua') diff --git a/tests/init_spec.lua b/tests/init_spec.lua index f537a74..5c175b6 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -112,6 +112,29 @@ describe('shelltime', function() fresh.disable() end) end) + + it('should leave what the daemon does not take for it to send later', function() + -- Disabling removes the exit flush, so nothing would save the queue on quit + shelltime.setup() + local heartbeat = require('shelltime.heartbeat') + local socket = require('shelltime.socket') + local offline = require('shelltime.offline') + heartbeat.requeue({ { entity = 'a' }, { entity = 'b' } }) + local send_stub = stub(socket, 'send_heartbeats') + send_stub.invokes(function(heartbeats, callback) + callback('failed', 'Connection failed: ENOENT') + end) + local save_stub = stub(offline, 'save') + + shelltime.disable() + + local saved = save_stub.calls[1].refs[1] + save_stub:revert() + send_stub:revert() + assert.equals(2, #saved) + assert.equals('a', saved[1].entity) + assert.equals(0, heartbeat.get_pending_count()) + end) end) describe('is_enabled', function() diff --git a/tests/language_spec.lua b/tests/language_spec.lua index 168b0f5..d27a39d 100644 --- a/tests/language_spec.lua +++ b/tests/language_spec.lua @@ -25,8 +25,13 @@ describe('shelltime.utils.language', function() assert.equals('javascript', language.get_language('javascript', '/path/to/file.js')) end) - it('should return filetype as-is for sh', function() - assert.equals('sh', language.get_language('sh', '/path/to/file.sh')) + it('should report VS Code ids for filetypes that differ', function() + assert.equals('shellscript', language.get_language('sh', '/path/to/file.sh')) + assert.equals('shellscript', language.get_language('zsh', '/path/to/.zshrc')) + assert.equals('plaintext', language.get_language('text', '/path/to/notes.txt')) + assert.equals('makefile', language.get_language('make', '/path/to/Makefile')) + assert.equals('csharp', language.get_language('cs', '/path/to/Program.cs')) + assert.equals('typescriptreact', language.get_language('typescriptreact', '/path/to/App.tsx')) end) it('should return filetype as-is for vim', function() diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 0e26b4d..45cc27f 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -17,3 +17,25 @@ package.path = plugin_dir .. '/tests/?/init.lua;' .. package.path vim.opt.swapfile = false vim.opt.backup = false vim.opt.writebackup = false + +-- Tests must never reach a ShellTime daemon or config on this machine: the +-- daemon sends what it gets to a real account. HOME is a temporary directory, +-- and connections to the default socket go to a path where nothing listens. +-- The test files run in child processes, which find plenary.nvim in the data +-- directory under the real HOME. +vim.env.XDG_DATA_HOME = vim.fn.fnamemodify(vim.fn.stdpath('data'), ':h') +vim.env.HOME = vim.fn.tempname() +vim.fn.mkdir(vim.env.HOME, 'p') + +local no_daemon_socket = vim.fn.tempname() .. '-no-daemon.sock' +local uv = vim.loop or vim.uv +local probe = uv.new_pipe(false) +local pipe_methods = getmetatable(probe).__index +probe:close() +local connect = pipe_methods.connect +pipe_methods.connect = function(pipe, path, callback) + if path == '/tmp/shelltime.sock' then + path = no_daemon_socket + end + return connect(pipe, path, callback) +end diff --git a/tests/offline_spec.lua b/tests/offline_spec.lua new file mode 100644 index 0000000..03224aa --- /dev/null +++ b/tests/offline_spec.lua @@ -0,0 +1,52 @@ +-- Tests for shelltime/offline.lua +describe('shelltime.offline', function() + local offline + local dir + + before_each(function() + package.loaded['shelltime.offline'] = nil + offline = require('shelltime.offline') + dir = vim.fn.tempname() + end) + + after_each(function() + vim.fn.delete(dir, 'rf') + end) + + it('should append payload lines in the format the daemon resends', function() + local path = dir .. '/.shelltime/coding-heartbeat.data.log' + + assert.is_true(offline.save({ { heartbeatId = 'a' }, { heartbeatId = 'b' } }, path)) + assert.is_true(offline.save({ { heartbeatId = 'c' } }, path)) + + local lines = vim.fn.readfile(path) + assert.equals(2, #lines) + local first = vim.json.decode(lines[1]) + assert.equals(2, #first.heartbeats) + assert.equals('a', first.heartbeats[1].heartbeatId) + end) + + it('should split a backlog into bounded payloads', function() + local path = dir .. '/log' + local heartbeats = {} + for i = 1, 501 do + heartbeats[i] = { heartbeatId = tostring(i) } + end + + offline.save(heartbeats, path) + + local lines = vim.fn.readfile(path) + assert.equals(500, #vim.json.decode(lines[1]).heartbeats) + assert.equals(1, #vim.json.decode(lines[2]).heartbeats) + end) + + it('should not write when there is nothing to keep', function() + local path = dir .. '/log' + assert.is_true(offline.save({}, path)) + assert.equals(0, vim.fn.filereadable(path)) + end) + + it('should use the daemon file under HOME by default', function() + assert.equals(os.getenv('HOME') .. '/.shelltime/coding-heartbeat.data.log', offline.default_path()) + end) +end) diff --git a/tests/sender_spec.lua b/tests/sender_spec.lua index 86ae702..d6dac42 100644 --- a/tests/sender_spec.lua +++ b/tests/sender_spec.lua @@ -87,7 +87,7 @@ describe('shelltime.sender', function() it('should call socket.send_heartbeats when there are heartbeats', function() socket_stub = stub(socket, 'send_heartbeats') socket_stub.invokes(function(heartbeats, callback) - callback(true, nil) + callback('ok', nil) end) local called = false @@ -113,7 +113,7 @@ describe('shelltime.sender', function() it('should work without callback', function() socket_stub = stub(socket, 'send_heartbeats') socket_stub.invokes(function(heartbeats, callback) - callback(true, nil) + callback('ok', nil) end) assert.has_no_errors(function() @@ -125,7 +125,7 @@ describe('shelltime.sender', function() heartbeat.requeue({ { entity = 'a' }, { entity = 'b' } }) socket_stub = stub(socket, 'send_heartbeats') socket_stub.invokes(function(heartbeats, callback) - callback(false, 'Connection failed: ENOENT') + callback('failed', 'Connection failed: ENOENT') end) local called = false @@ -141,11 +141,58 @@ describe('shelltime.sender', function() assert.equals('a', pending[1].entity) end) + it('should keep heartbeats and warn once when the daemon has code tracking off', function() + heartbeat.requeue({ { entity = 'a' }, { entity = 'b' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + callback('disabled', 'code tracking is disabled in the daemon') + end) + local notify_stub = stub(vim, 'notify') + + local called = false + sender.flush(function(success) + called = true + assert.is_false(success) + end) + vim.wait(100, function() return called end) + + assert.is_true(sender.is_tracking_disabled()) + assert.equals(2, heartbeat.get_pending_count()) + sender.flush() + assert.stub(notify_stub).was_called(1) + notify_stub:revert() + end) + + it('should send a backlog in bounded messages and keep what was not delivered', function() + local queued = {} + for i = 1, 1201 do + queued[i] = { entity = tostring(i) } + end + heartbeat.requeue(queued) + local sizes = {} + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + table.insert(sizes, #heartbeats) + callback(#sizes < 3 and 'ok' or 'failed', nil) + end) + + local called = false + sender.flush(function() + called = true + end) + vim.wait(100, function() return called end) + + assert.same({ 500, 500, 201 }, sizes) + local pending = heartbeat.flush() + assert.equals(201, #pending) + assert.equals('1001', pending[1].entity) + end) + it('should not keep heartbeats after a successful send', function() heartbeat.requeue({ { entity = 'a' } }) socket_stub = stub(socket, 'send_heartbeats') socket_stub.invokes(function(heartbeats, callback) - callback(true, nil) + callback('ok', nil) end) local called = false @@ -158,6 +205,120 @@ describe('shelltime.sender', function() end) end) + describe('exit_flush', function() + local socket_stub + local save_stub + local offline = require('shelltime.offline') + + before_each(function() + save_stub = stub(offline, 'save') + end) + + after_each(function() + save_stub:revert() + if socket_stub and socket_stub.revert then + socket_stub:revert() + socket_stub = nil + end + end) + + it('should leave heartbeats for the daemon when it is not running', function() + heartbeat.requeue({ { entity = 'a' }, { entity = 'b' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + callback('failed', 'Connection failed: ENOENT') + end) + + sender.exit_flush(100) + + assert.stub(save_stub).was_called(1) + assert.equals(2, #save_stub.calls[1].refs[1]) + end) + + it('should keep a send that got no answer in time', function() + heartbeat.requeue({ { entity = 'a' } }) + socket_stub = stub(socket, 'send_heartbeats') + + sender.exit_flush(20) + + assert.equals('a', save_stub.calls[1].refs[1][1].entity) + end) + + it('should leave nothing when the daemon took them', function() + heartbeat.requeue({ { entity = 'a' } }) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + callback('ok', nil) + end) + + sender.exit_flush(100) + + assert.equals(0, #save_stub.calls[1].refs[1]) + end) + + it('should wait for a send already in progress', function() + heartbeat.requeue({ { entity = 'a' } }) + local answered = false + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + vim.defer_fn(function() + answered = true + callback('ok', nil) + end, 20) + end) + + sender.flush() + sender.exit_flush(500) + + assert.is_true(answered) + assert.stub(socket_stub).was_called(1) + assert.equals(0, #save_stub.calls[1].refs[1]) + end) + + it('should keep what a send in progress has not delivered', function() + -- A periodic flush of a backlog is between chunks when Neovim exits + local queued = {} + for i = 1, 1200 do + queued[i] = { entity = tostring(i) } + end + heartbeat.requeue(queued) + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + if heartbeats[1].entity == '1' then + vim.defer_fn(function() + callback('ok', nil) + end, 10) + end + end) + + sender.flush() + heartbeat.requeue({ { entity = 'newer' } }) + sender.exit_flush(100) + + -- Heartbeats 501-1200, then the one queued later + local saved = save_stub.calls[1].refs[1] + assert.equals(701, #saved) + assert.equals('501', saved[1].entity) + assert.equals('1200', saved[700].entity) + assert.equals('newer', saved[701].entity) + end) + + it('should not queue again what it saved when the answer comes late', function() + heartbeat.requeue({ { entity = 'a' } }) + local answer + socket_stub = stub(socket, 'send_heartbeats') + socket_stub.invokes(function(heartbeats, callback) + answer = callback + end) + + sender.exit_flush(20) + answer('failed', 'Connection timeout') + + assert.equals('a', save_stub.calls[1].refs[1][1].entity) + assert.equals(0, heartbeat.get_pending_count()) + end) + end) + describe('flush_sync', function() local socket_stub @@ -173,7 +334,7 @@ describe('shelltime.sender', function() socket_stub = stub(socket, 'send_heartbeats') socket_stub.invokes(function(heartbeats, callback) vim.schedule(function() - callback(true, nil) + callback('ok', nil) end) end) diff --git a/tests/socket_spec.lua b/tests/socket_spec.lua index b03faf2..5e630f8 100644 --- a/tests/socket_spec.lua +++ b/tests/socket_spec.lua @@ -81,28 +81,28 @@ describe('shelltime.socket', function() { heartbeatId = 'test-1', entity = '/test/file.lua' }, } - local called = false - socket.send_heartbeats(heartbeats, function(success, err) - called = true - assert.is_boolean(success) + local result = nil + socket.send_heartbeats(heartbeats, function(status, err) + result = status end) - vim.wait(6000, function() return called end, 100) - assert.is_true(called) + vim.wait(6000, function() return result ~= nil end, 100) + assert.is_string(result) end) - it('should return success false on connection error', function() + it('should report failed on connection error', function() local heartbeats = {{ heartbeatId = 'test' }} - local called = false - socket.send_heartbeats(heartbeats, function(success, err) - called = true - assert.is_false(success) - assert.is_string(err) + local result = nil + local error_msg = nil + socket.send_heartbeats(heartbeats, function(status, err) + result = status + error_msg = err end) - vim.wait(6000, function() return called end, 100) - assert.is_true(called) + vim.wait(6000, function() return result ~= nil end, 100) + assert.equals('failed', result) + assert.is_string(error_msg) end) it('should handle empty heartbeats array', function() @@ -132,6 +132,90 @@ describe('shelltime.socket', function() end) end) + describe('send_heartbeats acknowledgment', function() + local uv = vim.loop or vim.uv + local server + local socket_path + + -- A daemon on a scratch socket: replies[type] is what it writes back to a + -- message of that type before closing (nil closes without a reply). + -- replies.stop stops listening once a heartbeat arrives, as a daemon that + -- is shutting down does before it drops the message. + local function serve(replies) + socket_path = vim.fn.tempname() .. '.sock' + server = uv.new_pipe(false) + server:bind(socket_path) + server:listen(16, function() + local client = uv.new_pipe(false) + server:accept(client) + local data = '' + client:read_start(function(_, chunk) + if chunk then + data = data .. chunk + return + end + local kind = data:find('"type":"status"', 1, true) and 'status' or 'heartbeat' + local function close() + client:close() + end + if kind == 'heartbeat' and replies.stop and not server:is_closing() then + server:close() + end + if replies[kind] then + client:write(replies[kind], close) + else + close() + end + end) + end) + config._set_for_testing({ socket_path = socket_path }) + end + + local function send() + local result = {} + socket.send_heartbeats({ { heartbeatId = 'test' } }, function(status, err) + result.status = status + result.err = err + end) + vim.wait(2000, function() return result.status ~= nil end, 10) + return result + end + + after_each(function() + if server then + if not server:is_closing() then + server:close() + end + server = nil + end + os.remove(socket_path) + end) + + it('should count an acknowledged batch as delivered', function() + serve({ heartbeat = '{"status":"ok"}' }) + assert.equals('ok', send().status) + end) + + it('should report a daemon with code tracking off', function() + serve({ heartbeat = '{"status":"disabled"}' }) + assert.equals('disabled', send().status) + end) + + it('should count an empty reply as delivered when the daemon still takes connections', function() + -- 0.1.42-0.1.47 daemons took heartbeats without replying; 0.1.42 answers no status either + serve({}) + assert.equals('ok', send().status) + end) + + it('should keep a batch the daemon closed without acknowledging', function() + -- A daemon that is going away stops listening before it drops the message + serve({ stop = true }) + local result = send() + assert.equals('failed', result.status) + assert.is_string(result.err) + end) + end) + describe('get_status', function() it('should send status message type', function() local called = false diff --git a/tests/system_spec.lua b/tests/system_spec.lua index 485079a..14c9bd2 100644 --- a/tests/system_spec.lua +++ b/tests/system_spec.lua @@ -174,7 +174,8 @@ describe('shelltime.utils.system', function() local root = system.get_project_root(test_file) assert.is_string(root) - assert.is_true(vim.fn.isdirectory(root .. '/.git') == 1) + -- A directory in a clone, a file in a worktree + assert.is_true(vim.fn.isdirectory(root .. '/.git') == 1 or vim.fn.filereadable(root .. '/.git') == 1) end) it('should return project root for nested file', function() diff --git a/tests/toml_spec.lua b/tests/toml_spec.lua new file mode 100644 index 0000000..6e9375a --- /dev/null +++ b/tests/toml_spec.lua @@ -0,0 +1,81 @@ +-- Tests for shelltime/utils/toml.lua +describe('shelltime.utils.toml', function() + local toml + + before_each(function() + package.loaded['shelltime.utils.toml'] = nil + toml = require('shelltime.utils.toml') + end) + + it('should parse the keys the CLI writes', function() + local parsed = toml.parse(table.concat({ + 'Token = "abc"', + 'APIEndpoint = "https://api.shelltime.xyz"', + "WebEndpoint = 'https://shelltime.xyz'", + 'FlushCount = 10', + 'socketPath = "/tmp/custom.sock" # where the daemon listens', + '', + '[codeTracking]', + 'enabled = true', + '', + '# comment line', + '[aiCodeOtel]', + 'enabled = false', + 'grpcPort = 54027', + }, '\n')) + + assert.equals('abc', parsed.Token) + assert.equals('https://api.shelltime.xyz', parsed.APIEndpoint) + assert.equals('https://shelltime.xyz', parsed.WebEndpoint) + assert.equals(10, parsed.FlushCount) + assert.equals('/tmp/custom.sock', parsed.socketPath) + assert.is_true(parsed.codeTracking.enabled) + assert.is_false(parsed.aiCodeOtel.enabled) + assert.equals(54027, parsed.aiCodeOtel.grpcPort) + end) + + it('should keep a # inside a quoted string', function() + assert.equals('/tmp/a#b.sock', toml.parse('socketPath = "/tmp/a#b.sock"').socketPath) + end) + + it('should nest dotted tables', function() + assert.is_true(toml.parse('[a.b]\nc = true').a.b.c) + end) + + it('should keep the keys under an array of tables out of the table before it', function() + -- As go-toml writes a config with Endpoints, after the root keys + local parsed = toml.parse(table.concat({ + "Token = 'tok'", + "APIEndpoint = 'https://api.shelltime.xyz'", + '', + '[codeTracking]', + 'enabled = true', + '', + '[[ENDPOINTS]]', + "APIEndpoint = 'http://localhost:27312'", + "Token = 'dev'", + '', + '[[ENDPOINTS]]', + "APIEndpoint = 'http://localhost:8080'", + '', + '[[a.b]]', + 'enabled = false', + }, '\n')) + + assert.equals('tok', parsed.Token) + assert.equals('https://api.shelltime.xyz', parsed.APIEndpoint) + assert.is_true(parsed.codeTracking.enabled) + assert.is_nil(parsed.codeTracking.APIEndpoint) + assert.equals(2, #parsed.ENDPOINTS) + assert.equals('http://localhost:27312', parsed.ENDPOINTS[1].APIEndpoint) + assert.equals('dev', parsed.ENDPOINTS[1].Token) + assert.equals('http://localhost:8080', parsed.ENDPOINTS[2].APIEndpoint) + assert.is_false(parsed.a.b[1].enabled) + end) + + it('should return an error for a missing file', function() + local result, err = toml.load_file('/nonexistent/config.toml') + assert.is_nil(result) + assert.is_string(err) + end) +end) diff --git a/tests/version_spec.lua b/tests/version_spec.lua index 9b88ea8..844f215 100644 --- a/tests/version_spec.lua +++ b/tests/version_spec.lua @@ -533,42 +533,15 @@ describe('shelltime.version', function() assert.equals(vim.log.levels.WARN, level) end) - it('should copy to clipboard when available', function() + it('should leave the clipboard alone', function() has_stub = create_stub(vim.fn, 'has') has_stub.returns(1) setreg_stub = create_stub(vim.fn, 'setreg') version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') - assert.stub(setreg_stub).was_called() - local reg = setreg_stub.calls[1].refs[1] - local content = setreg_stub.calls[1].refs[2] - assert.equals('+', reg) - assert.matches('curl %-sSL https://example%.com/i | bash', content) - end) - - it('should not crash when clipboard unavailable', function() - has_stub = create_stub(vim.fn, 'has') - has_stub.returns(0) - - assert.has_no_errors(function() - version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') - end) - end) - - it('should notify about clipboard copy', function() - has_stub = create_stub(vim.fn, 'has') - has_stub.returns(1) - setreg_stub = create_stub(vim.fn, 'setreg') - - version.show_update_warning('1.0.0', '2.0.0', 'https://example.com') - - -- Should have two notifications: warning and clipboard info - assert.equals(2, #notify_stub.calls) - local info_message = notify_stub.calls[2].refs[1] - local info_level = notify_stub.calls[2].refs[2] - assert.matches('clipboard', info_message) - assert.equals(vim.log.levels.INFO, info_level) + assert.stub(setreg_stub).was_not_called() + assert.equals(1, #notify_stub.calls) end) end) diff --git a/tests/yaml_spec.lua b/tests/yaml_spec.lua index 4dcb47f..ca576e5 100644 --- a/tests/yaml_spec.lua +++ b/tests/yaml_spec.lua @@ -46,6 +46,12 @@ describe('shelltime.utils.yaml', function() assert.is_true(result.enabled) end) + it('should ignore a trailing comment', function() + local result = yaml.parse('codeTracking:\n enabled: true # editor heartbeats\nsocketPath: "/tmp/a#b.sock" # quoted') + assert.is_true(result.codeTracking.enabled) + assert.equals('/tmp/a#b.sock', result.socketPath) + end) + it('should parse boolean false', function() local result = yaml.parse('enabled: false') assert.is_false(result.enabled)