diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef173b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +stopinsert.nvim is a Neovim plugin that automatically exits Insert mode after a configurable period of inactivity. It helps users develop better vim habits by preventing them from staying in Insert mode unnecessarily. + +## Development Commands + +### Testing +```bash +make test +``` +Uses plenary.nvim to run test specs in the `tests/` directory with `plenary.busted`. + +### Code Formatting +```bash +stylua . +``` +Format Lua code according to the configuration in `stylua.toml` (3-space indentation, 90 column width). + +## Code Architecture + +### Module Structure +The plugin follows a modular Lua architecture: + +- `lua/stopinsert/init.lua` - Main entry point with setup() function and user commands +- `lua/stopinsert/config.lua` - Configuration management and default values +- `lua/stopinsert/util.lua` - Core timer logic and filetype checking +- `lua/stopinsert/popup.lua` - Popup message display functionality + +### Key Components + +**Timer System**: The plugin uses `vim.defer_fn()` to create a timer that triggers after idle time. The timer is reset on every keypress in Insert mode via `vim.on_key()`. + +**Filetype Filtering**: Uses pattern matching against `disabled_filetypes` list to exclude specific buffer types (TelescopePrompt, help, etc.). + +**Guard Function**: Optional user-provided function that can prevent the plugin from exiting Insert mode (useful for completion menus). + +**User Commands**: Provides `:StopInsertPlug` command with subcommands (enable, disable, toggle, status) for runtime control. + +### Event Handling +- `InsertEnter` autocmd starts the timer when entering Insert mode +- `vim.on_key()` callback resets timer on any keypress in Insert mode +- Timer callback uses `vim.cmd("stopinsert")` to exit Insert mode + +## Testing Setup + +Tests use plenary.nvim with a minimal Neovim configuration (`tests/minimal_init.lua`). The test suite focuses on utility functions in `tests/util_spec.lua`. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 0219c4c..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,8 +0,0 @@ -# Contributing - -All contributions are most welcome! Please open a PR or create an [issue](https://github.com/csessh/stopinsert.nvim/issues). - -## Coding Style - -- Follow the coding style of [LuaRocks](https://github.com/luarocks/lua-style-guide). -- Make sure you format the code with [StyLua](https://github.com/JohnnyMorganz/StyLua) before PR. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..241dbdc --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + @nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests { minimal_init = 'tests/minimal_init.lua' }" diff --git a/README.md b/README.md index 8a55808..8f47283 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,29 @@ This plugin automatically kicks you out of Insert mode after certain amount of t -- lazy.nvim { "csessh/stopinsert.nvim", - opts = {} + event = { "InsertEnter" }, -- lazy load + dependencies = { + -- "hrsh7th/nvim-cmp", + "saghen/blink.cmp", + }, + opts = { + -- Configuration options (see Configuration section below for details) + idle_time_ms = 5000, -- Maximum time (in milliseconds) before you are forced out of Insert mode + show_popup_msg = true, -- Enable/disable popup message + clear_popup_ms = 5000, -- Maximum time (in milliseconds) for which the popup message hangs around + disabled_filetypes = { -- List of filetypes to exclude the effect of this plugin. + "TelescopePrompt", + "checkhealth", + "help", + "lspinfo", + "mason", + "neo%-tree*", + }, + guard_func = function() -- Optional function that returns a boolean. If true, prevents stopinsert. + -- return require('cmp').visible_docs() + return require("blink.cmp").is_documentation_visible() + end, + } }, ``` @@ -27,16 +49,19 @@ require("stopinsert").setup() | Items | Type | Default Value | Description | | --------------------- | --------- | ------------------ | -------------- | | `idle_time_ms` | number | `5000` (5 seconds) | Maximum time (in milliseconds) before you are forced out of Insert mode back to Normal mode. | +| `show_popup_msg` | boolean | `true` | Enable/disable popup message | +| `clear_popup_ms` | number | `5000` | Maximum time (in milliseconds) for which the popup message hangs around | | `disabled_filetypes` | list | `{ "TelescopePrompt", "checkhealth", "help", "lspinfo", "mason", "neo%-tree*", }` | List of filetypes to exclude the effect of this plugin. | +| `guard_func` | function | `nil` | Optional function that returns a boolean. If true, prevents stopinsert. | **NOTE:** -By default, `stopinsert.nvim` excludes a list of filetypes, as shown in the table above. +By default, `stopinsert.nvim` excludes a list of filetypes, as shown in the table above. If you configure this attribute in `opts` with your package manager, like so, your list will replace `stopinsert.nvim` defaults. Filetypes can also be listed as regex, such as `neo%-tree*`. -## User command +## User command `stopinsert.nvim` is enabled by default. You can toggle its state on the fly with the following commands: @@ -44,12 +69,27 @@ Filetypes can also be listed as regex, such as `neo%-tree*`. :StopInsertPlug enable :StopInsertPlug disable :StopInsertPlug toggle +:StopInsertPlug status ``` Each of them does exactly what it says on the tin. - ## Contribution -See [CONTRIBUTING.md](./CONTRIBUTING.md). - +All contributions are most welcome! Please open a PR or create an [issue](https://github.com/csessh/stopinsert.nvim/issues). + +### Coding Style + +- Follow the coding style of [LuaRocks](https://github.com/luarocks/lua-style-guide). +- Make sure you format the code with [StyLua](https://github.com/JohnnyMorganz/StyLua) before PR. + +### Testing + +Run the test suite with [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) installed. +From the project root execute: + +```sh +make test +``` + +This command uses `plenary.busted` to run the specs found in the `tests/` directory. diff --git a/doc/stopinsert.nvim.txt b/doc/stopinsert.nvim.txt index d39b311..5c9c05c 100644 --- a/doc/stopinsert.nvim.txt +++ b/doc/stopinsert.nvim.txt @@ -1,4 +1,4 @@ -*stopinsert.nvim.txt* For NVIM v0.8.0 Last change: 2024 October 06 +*stopinsert.nvim.txt* For NVIM v0.8.0 Last change: 2025 June 14 ============================================================================== Table of Contents *stopinsert.nvim-table-of-contents* @@ -7,6 +7,7 @@ Table of Contents *stopinsert.nvim-table-of-contents* - Installation |stopinsert.nvim-installation| - Configuration |stopinsert.nvim-configuration| - User command |stopinsert.nvim-user-command| + - Contribution |stopinsert.nvim-contribution| INTRO *stopinsert.nvim-intro* @@ -26,7 +27,29 @@ INSTALLATION *stopinsert.nvim-installation* -- lazy.nvim { "csessh/stopinsert.nvim", - opts = {} + event = { "InsertEnter" }, -- lazy load + dependencies = { + -- "hrsh7th/nvim-cmp", + "saghen/blink.cmp", + }, + opts = { + -- Configuration options (see Configuration section below for details) + idle_time_ms = 5000, -- Maximum time (in milliseconds) before you are forced out of Insert mode + show_popup_msg = true, -- Enable/disable popup message + clear_popup_ms = 5000, -- Maximum time (in milliseconds) for which the popup message hangs around + disabled_filetypes = { -- List of filetypes to exclude the effect of this plugin. + "TelescopePrompt", + "checkhealth", + "help", + "lspinfo", + "mason", + "neo%-tree*", + }, + guard_func = function() -- Optional function that returns a boolean. If true, prevents stopinsert. + -- return require('cmp').visible_docs() + return require("blink.cmp").is_documentation_visible() + end, + } }, < @@ -49,11 +72,27 @@ CONFIGURATION *stopinsert.nvim-configuration* Insert mode back to Normal mode. + show_popup_msg boolean true Enable/disable + popup message + + clear_popup_ms number 5000 Maximum time (in + milliseconds) + for which the + popup message + hangs around + disabled_filetypes list { "TelescopePrompt", "checkhealth", "help", "lspinfo", "mason", "neo%-tree*", } List of filetypes to exclude the effect of this plugin. + + guard_func function nil Optional + function that + returns a + boolean. If + true, prevents + stopinsert. -------------------------------------------------------------------------------------------------------------------------------------- **NOTE:** By default, `stopinsert.nvim` excludes a list of filetypes, as shown in the table above. @@ -73,10 +112,36 @@ with the following commands: :StopInsertPlug enable :StopInsertPlug disable :StopInsertPlug toggle + :StopInsertPlug status < Each of them does exactly what it says on the tin. + +CONTRIBUTION *stopinsert.nvim-contribution* + +All contributions are most welcome! Please open a PR or create an issue +. + + +CODING STYLE ~ + +- Follow the coding style of LuaRocks . +- Make sure you format the code with StyLua before PR. + + +TESTING ~ + +Run the test suite with plenary.nvim +installed. From the project root execute: + +>sh + make test +< + +This command uses `plenary.busted` to run the specs found in the `tests/` +directory. + Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/stopinsert/config.lua b/lua/stopinsert/config.lua index 87e44ce..8d04391 100644 --- a/lua/stopinsert/config.lua +++ b/lua/stopinsert/config.lua @@ -1,7 +1,17 @@ local M = {} +--- Configuration options for StopInsert M.config = { + --- Time in milliseconds after which insert mode will be exited if no keys are pressed. idle_time_ms = 5000, + + --- Whether to show a popup message when exiting insert mode due to inactivity. + show_popup_msg = true, + + --- Time in milliseconds after which the popup message will be cleared. + clear_popup_ms = 5000, + + --- List of filetypes where StopInsert should be disabled. disabled_filetypes = { "TelescopePrompt", "checkhealth", @@ -10,6 +20,9 @@ M.config = { "mason", "neo%-tree*", }, + + --- Optional function that returns a boolean. If true, prevents stopinsert. + guard_func = nil, } ---@param user_config table diff --git a/lua/stopinsert/init.lua b/lua/stopinsert/init.lua index e567aec..2d61fd3 100644 --- a/lua/stopinsert/init.lua +++ b/lua/stopinsert/init.lua @@ -1,8 +1,7 @@ local M = {} -local util = require("stopinsert.util") -M.enable = true -local commands = { +M.enable = true +local user_cmds = { enable = function() M.enable = true end, @@ -12,30 +11,50 @@ local commands = { toggle = function() M.enable = not M.enable end, + status = function() + if M.enable then + print("StopInsert is active") + else + print("StopInsert is inactive") + end + end, } +local config = require("stopinsert.config") +local util = require("stopinsert.util") + ---@param opts table ---@return nil function M.setup(opts) opts = opts or {} - require("stopinsert.config").set(opts) + config.set(opts) + local plugin_autocmd_group = "StopInsertAutoCmd" vim.api.nvim_create_autocmd("InsertEnter", { - group = vim.api.nvim_create_augroup("InsertEnterListener", { clear = true }), + group = vim.api.nvim_create_augroup(plugin_autocmd_group, { clear = true }), callback = function() - if not M.enable and not util.is_filetype_disabled(vim.bo.ft) then + if not M.enable then + return + end + + if util.is_filetype_disabled(vim.bo.ft) then return end + util.reset_timer() end, }) vim.on_key(function(_, _) - if not M.enable and not util.is_filetype_disabled(vim.bo.ft) then + if vim.fn.mode() ~= "i" then return end - if vim.fn.mode() ~= "i" then + if not M.enable then + return + end + + if util.is_filetype_disabled(vim.bo.ft) then return end @@ -43,13 +62,18 @@ function M.setup(opts) end) vim.api.nvim_create_user_command("StopInsertPlug", function(cmd) - if commands[cmd.args] then - commands[cmd.args]() + if user_cmds[cmd.args] then + user_cmds[cmd.args]() end end, { nargs = 1, complete = function() - return { "enable", "disable", "toggle" } + return { + "enable", + "disable", + "toggle", + "status", + } end, }) end diff --git a/lua/stopinsert/popup.lua b/lua/stopinsert/popup.lua new file mode 100644 index 0000000..9552b53 --- /dev/null +++ b/lua/stopinsert/popup.lua @@ -0,0 +1,40 @@ +local M = {} + +--- Create a simple popup message, positioned in the bottom right corner of the buffer +--- Automatically close this popup after timeout_ms milliseconds +--- Courtersy of encourage.nvim +---@param message string +---@param timeout_ms number +---@return nil +function M.show(message, timeout_ms) + local width = #message + local height = 1 + local buf = vim.api.nvim_create_buf(false, true) + local current_win = vim.api.nvim_get_current_win() + local win_config = vim.api.nvim_win_get_config(current_win) + local win_width = win_config.width + local win_height = win_config.height + + local opts = { + style = "minimal", + relative = "win", + win = current_win, + width = width, + height = height, + row = win_height - height - 2, + col = win_width - width - 2, + border = "rounded", + } + + local win = vim.api.nvim_open_win(buf, false, opts) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { message }) + vim.api.nvim_win_set_option(win, "winhl", "Normal:NormalFloat,FloatBorder:FloatBorder") + + vim.defer_fn(function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, timeout_ms) +end + +return M diff --git a/lua/stopinsert/util.lua b/lua/stopinsert/util.lua index 47ab36f..b7a4432 100644 --- a/lua/stopinsert/util.lua +++ b/lua/stopinsert/util.lua @@ -1,11 +1,22 @@ local M = {} local timer = nil local config = require("stopinsert.config").config +local popup = require("stopinsert.popup") ---@return nil function M.force_exit_insert_mode() if vim.fn.mode() == "i" then + if type(config.guard_func) == "function" and config.guard_func() then + return + end + vim.cmd("stopinsert") + + if config.show_popup_msg then + local msg = + "StopInsertPlug: You were idling in Insert mode. Remember to when you finish editing." + popup.show(msg, config.clear_popup_ms) + end end end diff --git a/tests/config_spec.lua b/tests/config_spec.lua new file mode 100644 index 0000000..ddb2e76 --- /dev/null +++ b/tests/config_spec.lua @@ -0,0 +1,93 @@ +local config = require('stopinsert.config') + +describe('config module', function() + before_each(function() + -- Reset config to defaults before each test + config.config = { + idle_time_ms = 5000, + show_popup_msg = true, + clear_popup_ms = 5000, + disabled_filetypes = { + "TelescopePrompt", + "checkhealth", + "help", + "lspinfo", + "mason", + "neo%-tree*", + }, + guard_func = nil, + } + end) + + describe('default configuration', function() + it('has correct default idle_time_ms', function() + assert.equals(5000, config.config.idle_time_ms) + end) + + it('has popup message enabled by default', function() + assert.is_true(config.config.show_popup_msg) + end) + + it('has correct default clear_popup_ms', function() + assert.equals(5000, config.config.clear_popup_ms) + end) + + it('has default disabled filetypes', function() + assert.is_table(config.config.disabled_filetypes) + assert.is_true(vim.tbl_contains(config.config.disabled_filetypes, "TelescopePrompt")) + assert.is_true(vim.tbl_contains(config.config.disabled_filetypes, "help")) + assert.is_true(vim.tbl_contains(config.config.disabled_filetypes, "neo%-tree*")) + end) + + it('has no guard function by default', function() + assert.is_nil(config.config.guard_func) + end) + end) + + describe('config.set', function() + it('updates idle_time_ms', function() + config.set({ idle_time_ms = 3000 }) + assert.equals(3000, config.config.idle_time_ms) + end) + + it('updates show_popup_msg', function() + config.set({ show_popup_msg = false }) + assert.is_false(config.config.show_popup_msg) + end) + + it('updates clear_popup_ms', function() + config.set({ clear_popup_ms = 2000 }) + assert.equals(2000, config.config.clear_popup_ms) + end) + + it('replaces disabled_filetypes list', function() + local new_filetypes = { "custom", "another" } + config.set({ disabled_filetypes = new_filetypes }) + assert.same(new_filetypes, config.config.disabled_filetypes) + end) + + it('sets guard function', function() + local guard_func = function() return true end + config.set({ guard_func = guard_func }) + assert.equals(guard_func, config.config.guard_func) + end) + + it('updates multiple config values at once', function() + config.set({ + idle_time_ms = 7000, + show_popup_msg = false, + disabled_filetypes = { "test" } + }) + assert.equals(7000, config.config.idle_time_ms) + assert.is_false(config.config.show_popup_msg) + assert.same({ "test" }, config.config.disabled_filetypes) + end) + + it('preserves unmodified config values', function() + config.set({ idle_time_ms = 8000 }) + assert.equals(8000, config.config.idle_time_ms) + assert.is_true(config.config.show_popup_msg) -- should remain default + assert.equals(5000, config.config.clear_popup_ms) -- should remain default + end) + end) +end) \ No newline at end of file diff --git a/tests/init_spec.lua b/tests/init_spec.lua new file mode 100644 index 0000000..a2dbc7a --- /dev/null +++ b/tests/init_spec.lua @@ -0,0 +1,361 @@ +local stopinsert = require('stopinsert') + +describe('stopinsert init module', function() + local original_api = {} + local original_vim = {} + + before_each(function() + -- Mock vim API functions + original_api.nvim_create_autocmd = vim.api.nvim_create_autocmd + original_api.nvim_create_augroup = vim.api.nvim_create_augroup + original_api.nvim_create_user_command = vim.api.nvim_create_user_command + original_vim.on_key = vim.on_key + original_vim.bo = vim.bo + original_vim.fn = vim.fn + + vim.api.nvim_create_autocmd = function() end + vim.api.nvim_create_augroup = function() return 1 end + vim.api.nvim_create_user_command = function() end + vim.on_key = function() end + vim.bo = { ft = 'lua' } + vim.fn = { mode = function() return 'n' end } + + -- Reset plugin state + stopinsert.enable = true + end) + + after_each(function() + -- Restore original functions + for key, func in pairs(original_api) do + vim.api[key] = func + end + for key, func in pairs(original_vim) do + vim[key] = func + end + end) + + describe('module state', function() + it('is enabled by default', function() + assert.is_true(stopinsert.enable) + end) + end) + + describe('setup', function() + it('calls config.set with provided options', function() + local config = require('stopinsert.config') + local config_set_called = false + local passed_opts = nil + + local original_set = config.set + config.set = function(opts) + config_set_called = true + passed_opts = opts + end + + local test_opts = { idle_time_ms = 3000, show_popup_msg = false } + stopinsert.setup(test_opts) + + assert.is_true(config_set_called) + assert.same(test_opts, passed_opts) + + -- Restore original function + config.set = original_set + end) + + it('handles nil options', function() + local config = require('stopinsert.config') + local config_set_called = false + local passed_opts = nil + + local original_set = config.set + config.set = function(opts) + config_set_called = true + passed_opts = opts + end + + stopinsert.setup() + + assert.is_true(config_set_called) + assert.same({}, passed_opts) + + -- Restore original function + config.set = original_set + end) + + it('creates autocmd group', function() + local augroup_created = false + local augroup_name = nil + + vim.api.nvim_create_augroup = function(name, opts) + augroup_created = true + augroup_name = name + assert.same({ clear = true }, opts) + return 1 + end + + stopinsert.setup() + + assert.is_true(augroup_created) + assert.equals("StopInsertAutoCmd", augroup_name) + end) + + it('creates InsertEnter autocmd', function() + local autocmd_created = false + local autocmd_event = nil + local autocmd_group = nil + + vim.api.nvim_create_autocmd = function(event, opts) + autocmd_created = true + autocmd_event = event + autocmd_group = opts.group + assert.is_function(opts.callback) + end + + stopinsert.setup() + + assert.is_true(autocmd_created) + assert.equals("InsertEnter", autocmd_event) + assert.equals(1, autocmd_group) + end) + + it('sets up vim.on_key callback', function() + local on_key_setup = false + local key_callback = nil + + vim.on_key = function(callback) + on_key_setup = true + key_callback = callback + end + + stopinsert.setup() + + assert.is_true(on_key_setup) + assert.is_function(key_callback) + end) + + it('creates StopInsertPlug user command', function() + local command_created = false + local command_name = nil + local command_opts = nil + + vim.api.nvim_create_user_command = function(name, callback, opts) + command_created = true + command_name = name + command_opts = opts + assert.is_function(callback) + end + + stopinsert.setup() + + assert.is_true(command_created) + assert.equals("StopInsertPlug", command_name) + assert.equals(1, command_opts.nargs) + assert.is_function(command_opts.complete) + end) + + it('provides correct command completion', function() + local completion_func = nil + + vim.api.nvim_create_user_command = function(name, callback, opts) + completion_func = opts.complete + end + + stopinsert.setup() + + local completions = completion_func() + local expected = { "enable", "disable", "toggle", "status" } + assert.same(expected, completions) + end) + end) + + describe('InsertEnter autocmd callback', function() + local autocmd_callback = nil + local util = require('stopinsert.util') + + before_each(function() + vim.api.nvim_create_autocmd = function(event, opts) + if event == "InsertEnter" then + autocmd_callback = opts.callback + end + end + + stopinsert.setup() + end) + + it('returns early when plugin is disabled', function() + stopinsert.enable = false + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + autocmd_callback() + + assert.is_false(util_called) + util.reset_timer = original_reset + end) + + it('returns early for disabled filetypes', function() + stopinsert.enable = true + vim.bo.ft = 'help' + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + autocmd_callback() + + assert.is_false(util_called) + util.reset_timer = original_reset + end) + + it('calls util.reset_timer for enabled filetypes', function() + stopinsert.enable = true + vim.bo.ft = 'lua' + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + autocmd_callback() + + assert.is_true(util_called) + util.reset_timer = original_reset + end) + end) + + describe('on_key callback', function() + local key_callback = nil + local util = require('stopinsert.util') + + before_each(function() + vim.on_key = function(callback) + key_callback = callback + end + + stopinsert.setup() + end) + + it('returns early when not in insert mode', function() + vim.fn.mode = function() return 'n' end + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + key_callback('a', 'a') + + assert.is_false(util_called) + util.reset_timer = original_reset + end) + + it('returns early when plugin is disabled', function() + vim.fn.mode = function() return 'i' end + stopinsert.enable = false + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + key_callback('a', 'a') + + assert.is_false(util_called) + util.reset_timer = original_reset + end) + + it('returns early for disabled filetypes', function() + vim.fn.mode = function() return 'i' end + stopinsert.enable = true + vim.bo.ft = 'help' + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + key_callback('a', 'a') + + assert.is_false(util_called) + util.reset_timer = original_reset + end) + + it('calls util.reset_timer in insert mode for enabled filetypes', function() + vim.fn.mode = function() return 'i' end + stopinsert.enable = true + vim.bo.ft = 'lua' + local util_called = false + + local original_reset = util.reset_timer + util.reset_timer = function() util_called = true end + + key_callback('a', 'a') + + assert.is_true(util_called) + util.reset_timer = original_reset + end) + end) + + describe('user commands', function() + local user_command_callback = nil + + before_each(function() + vim.api.nvim_create_user_command = function(name, callback, opts) + user_command_callback = callback + end + + stopinsert.setup() + end) + + it('enables plugin with enable command', function() + stopinsert.enable = false + user_command_callback({ args = 'enable' }) + assert.is_true(stopinsert.enable) + end) + + it('disables plugin with disable command', function() + stopinsert.enable = true + user_command_callback({ args = 'disable' }) + assert.is_false(stopinsert.enable) + end) + + it('toggles plugin state with toggle command', function() + stopinsert.enable = true + user_command_callback({ args = 'toggle' }) + assert.is_false(stopinsert.enable) + + user_command_callback({ args = 'toggle' }) + assert.is_true(stopinsert.enable) + end) + + it('prints status when plugin is active', function() + stopinsert.enable = true + local printed_message = nil + + local original_print = print + print = function(msg) printed_message = msg end + + user_command_callback({ args = 'status' }) + + assert.equals("StopInsert is active", printed_message) + print = original_print + end) + + it('prints status when plugin is inactive', function() + stopinsert.enable = false + local printed_message = nil + + local original_print = print + print = function(msg) printed_message = msg end + + user_command_callback({ args = 'status' }) + + assert.equals("StopInsert is inactive", printed_message) + print = original_print + end) + + it('ignores invalid commands', function() + local original_state = stopinsert.enable + user_command_callback({ args = 'invalid' }) + assert.equals(original_state, stopinsert.enable) + end) + end) +end) \ No newline at end of file diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..f565e5b --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,4 @@ +local root = vim.fn.fnamemodify(""..debug.getinfo(1, 'S').source:sub(2).."", ':h:h') +vim.opt.runtimepath:append(root) +local plenary = os.getenv('PLENARY_PATH') or vim.fn.stdpath('data') .. '/site/pack/packer/start/plenary.nvim' +vim.opt.runtimepath:append(plenary) diff --git a/tests/popup_spec.lua b/tests/popup_spec.lua new file mode 100644 index 0000000..243eda5 --- /dev/null +++ b/tests/popup_spec.lua @@ -0,0 +1,235 @@ +local popup = require('stopinsert.popup') + +describe('popup module', function() + local original_api = {} + + before_each(function() + -- Mock vim.api functions for testing + original_api.nvim_create_buf = vim.api.nvim_create_buf + original_api.nvim_get_current_win = vim.api.nvim_get_current_win + original_api.nvim_win_get_config = vim.api.nvim_win_get_config + original_api.nvim_open_win = vim.api.nvim_open_win + original_api.nvim_buf_set_lines = vim.api.nvim_buf_set_lines + original_api.nvim_win_set_option = vim.api.nvim_win_set_option + original_api.nvim_win_is_valid = vim.api.nvim_win_is_valid + original_api.nvim_win_close = vim.api.nvim_win_close + + -- Set up mocks + vim.api.nvim_create_buf = function() return 1 end + vim.api.nvim_get_current_win = function() return 1000 end + vim.api.nvim_win_get_config = function() + return { width = 80, height = 24 } + end + vim.api.nvim_open_win = function() return 2000 end + vim.api.nvim_buf_set_lines = function() end + vim.api.nvim_win_set_option = function() end + vim.api.nvim_win_is_valid = function() return true end + vim.api.nvim_win_close = function() end + end) + + after_each(function() + -- Restore original functions + for key, func in pairs(original_api) do + vim.api[key] = func + end + end) + + describe('show', function() + it('creates a buffer for the popup', function() + local buf_created = false + vim.api.nvim_create_buf = function(listed, scratch) + buf_created = true + assert.is_false(listed) + assert.is_true(scratch) + return 1 + end + + popup.show("test message", 1000) + assert.is_true(buf_created) + end) + + it('gets current window information', function() + local current_win_called = false + local win_config_called = false + + vim.api.nvim_get_current_win = function() + current_win_called = true + return 1000 + end + + vim.api.nvim_win_get_config = function(win) + win_config_called = true + assert.equals(1000, win) + return { width = 80, height = 24 } + end + + popup.show("test", 1000) + assert.is_true(current_win_called) + assert.is_true(win_config_called) + end) + + it('opens window with correct configuration', function() + local win_opened = false + local expected_opts = {} + + vim.api.nvim_open_win = function(buf, enter, opts) + win_opened = true + expected_opts = opts + assert.equals(1, buf) + assert.is_false(enter) + return 2000 + end + + popup.show("Hello World", 1000) + + assert.is_true(win_opened) + assert.equals("minimal", expected_opts.style) + assert.equals("win", expected_opts.relative) + assert.equals(1000, expected_opts.win) + assert.equals(11, expected_opts.width) -- Length of "Hello World" + assert.equals(1, expected_opts.height) + assert.equals("rounded", expected_opts.border) + -- Position should be bottom-right: row = 24 - 1 - 2 = 21, col = 80 - 11 - 2 = 67 + assert.equals(21, expected_opts.row) + assert.equals(67, expected_opts.col) + end) + + it('sets buffer content correctly', function() + local buf_lines_set = false + local buffer_content = {} + + vim.api.nvim_buf_set_lines = function(buf, start_line, end_line, strict_indexing, replacement) + buf_lines_set = true + buffer_content = replacement + assert.equals(1, buf) + assert.equals(0, start_line) + assert.equals(-1, end_line) + assert.is_false(strict_indexing) + end + + popup.show("Test Message", 1000) + + assert.is_true(buf_lines_set) + assert.same({ "Test Message" }, buffer_content) + end) + + it('sets window highlight options', function() + local highlight_set = false + + vim.api.nvim_win_set_option = function(win, option, value) + highlight_set = true + assert.equals(2000, win) + assert.equals("winhl", option) + assert.equals("Normal:NormalFloat,FloatBorder:FloatBorder", value) + end + + popup.show("test", 1000) + assert.is_true(highlight_set) + end) + + it('calculates correct dimensions for different message lengths', function() + local opts_captured = {} + + vim.api.nvim_open_win = function(buf, enter, opts) + table.insert(opts_captured, { width = opts.width, height = opts.height }) + return 2000 + end + + popup.show("Hi", 1000) + popup.show("This is a longer message", 1000) + popup.show("", 1000) + + assert.equals(2, opts_captured[1].width) + assert.equals(1, opts_captured[1].height) + assert.equals(25, opts_captured[2].width) + assert.equals(1, opts_captured[2].height) + assert.equals(0, opts_captured[3].width) + assert.equals(1, opts_captured[3].height) + end) + + it('schedules window closure after timeout', function() + local defer_fn_called = false + local timeout_used = 0 + local closure_func = nil + + -- Mock vim.defer_fn + local original_defer_fn = vim.defer_fn + vim.defer_fn = function(func, timeout) + defer_fn_called = true + timeout_used = timeout + closure_func = func + end + + popup.show("test", 3000) + + assert.is_true(defer_fn_called) + assert.equals(3000, timeout_used) + assert.is_function(closure_func) + + -- Test the closure function + local close_called = false + vim.api.nvim_win_close = function(win, force) + close_called = true + assert.equals(2000, win) + assert.is_true(force) + end + + closure_func() + assert.is_true(close_called) + + -- Restore vim.defer_fn + vim.defer_fn = original_defer_fn + end) + + it('checks window validity before closing', function() + local original_defer_fn = vim.defer_fn + local validity_checked = false + local close_attempted = false + + vim.defer_fn = function(func, timeout) + -- Execute the closure function immediately for testing + func() + end + + vim.api.nvim_win_is_valid = function(win) + validity_checked = true + assert.equals(2000, win) + return false -- Window is not valid + end + + vim.api.nvim_win_close = function() + close_attempted = true + end + + popup.show("test", 1000) + + assert.is_true(validity_checked) + assert.is_false(close_attempted) -- Should not attempt to close invalid window + + -- Restore vim.defer_fn + vim.defer_fn = original_defer_fn + end) + + it('closes valid windows', function() + local original_defer_fn = vim.defer_fn + local close_called = false + + vim.defer_fn = function(func, timeout) + func() -- Execute immediately for testing + end + + vim.api.nvim_win_is_valid = function() return true end + vim.api.nvim_win_close = function(win, force) + close_called = true + assert.equals(2000, win) + assert.is_true(force) + end + + popup.show("test", 1000) + assert.is_true(close_called) + + -- Restore vim.defer_fn + vim.defer_fn = original_defer_fn + end) + end) +end) \ No newline at end of file diff --git a/tests/util_spec.lua b/tests/util_spec.lua new file mode 100644 index 0000000..6696f09 --- /dev/null +++ b/tests/util_spec.lua @@ -0,0 +1,159 @@ +local util = require('stopinsert.util') +local config = require('stopinsert.config') + +describe('util module', function() + before_each(function() + -- Reset config to defaults and stop any existing timers + config.config = { + idle_time_ms = 5000, + show_popup_msg = true, + clear_popup_ms = 5000, + disabled_filetypes = { + "TelescopePrompt", + "checkhealth", + "help", + "lspinfo", + "mason", + "neo%-tree*", + }, + guard_func = nil, + } + -- Clear any existing timer + util.reset_timer() + end) + + describe('is_filetype_disabled', function() + it('matches neo-tree when configured with neo%-tree* pattern', function() + assert.is_true(vim.tbl_contains(config.config.disabled_filetypes, 'neo%-tree*')) + assert.is_true(util.is_filetype_disabled('neo-tree')) + end) + + it('matches exact filetype names', function() + assert.is_true(util.is_filetype_disabled('TelescopePrompt')) + assert.is_true(util.is_filetype_disabled('help')) + assert.is_true(util.is_filetype_disabled('checkhealth')) + assert.is_true(util.is_filetype_disabled('lspinfo')) + assert.is_true(util.is_filetype_disabled('mason')) + end) + + it('matches pattern-based filetypes', function() + assert.is_true(util.is_filetype_disabled('neo-tree')) + assert.is_true(util.is_filetype_disabled('neo-tree-popup')) + assert.is_true(util.is_filetype_disabled('neo-tree-git')) + end) + + it('returns false for non-disabled filetypes', function() + assert.is_false(util.is_filetype_disabled('lua')) + assert.is_false(util.is_filetype_disabled('python')) + assert.is_false(util.is_filetype_disabled('javascript')) + assert.is_false(util.is_filetype_disabled('')) + end) + + it('handles empty disabled_filetypes list', function() + config.config.disabled_filetypes = {} + assert.is_false(util.is_filetype_disabled('help')) + assert.is_false(util.is_filetype_disabled('neo-tree')) + end) + + it('handles custom patterns', function() + config.config.disabled_filetypes = { "test%-.*", "custom" } + assert.is_true(util.is_filetype_disabled('test-file')) + assert.is_true(util.is_filetype_disabled('test-spec')) + assert.is_true(util.is_filetype_disabled('custom')) + assert.is_false(util.is_filetype_disabled('test')) + assert.is_false(util.is_filetype_disabled('other')) + end) + end) + + describe('timer functionality', function() + it('creates a timer when reset_timer is called', function() + -- This test verifies the timer is created without side effects + util.reset_timer() + -- Timer creation doesn't have direct observable effects in tests, + -- but we can verify no errors are thrown + assert.is_true(true) + end) + + it('stops existing timer when reset_timer is called multiple times', function() + util.reset_timer() + util.reset_timer() + util.reset_timer() + -- Multiple calls should not cause errors + assert.is_true(true) + end) + end) + + describe('force_exit_insert_mode', function() + local original_mode, original_cmd + + before_each(function() + -- Mock vim functions for testing + original_mode = vim.fn.mode + original_cmd = vim.cmd + vim.fn.mode = function() return 'i' end -- Mock insert mode + vim.cmd = function() end -- Mock command execution + end) + + after_each(function() + -- Restore original functions + vim.fn.mode = original_mode + vim.cmd = original_cmd + end) + + it('exits insert mode when in insert mode', function() + local cmd_called = false + vim.cmd = function(command) + if command == 'stopinsert' then + cmd_called = true + end + end + + util.force_exit_insert_mode() + assert.is_true(cmd_called) + end) + + it('does not exit when not in insert mode', function() + vim.fn.mode = function() return 'n' end -- Normal mode + local cmd_called = false + vim.cmd = function() cmd_called = true end + + util.force_exit_insert_mode() + assert.is_false(cmd_called) + end) + + it('respects guard function when provided', function() + config.config.guard_func = function() return true end -- Prevent exit + local cmd_called = false + vim.cmd = function() cmd_called = true end + + util.force_exit_insert_mode() + assert.is_false(cmd_called) + end) + + it('exits when guard function returns false', function() + config.config.guard_func = function() return false end -- Allow exit + local cmd_called = false + vim.cmd = function(command) + if command == 'stopinsert' then + cmd_called = true + end + end + + util.force_exit_insert_mode() + assert.is_true(cmd_called) + end) + + it('exits when guard function is nil', function() + config.config.guard_func = nil + local cmd_called = false + vim.cmd = function(command) + if command == 'stopinsert' then + cmd_called = true + end + end + + util.force_exit_insert_mode() + assert.is_true(cmd_called) + end) + end) +end)