--- *mini.cursorword* Autohighlight word under cursor --- *MiniCursorword* --- --- MIT License Copyright (c) 2021 Evgeni Chasnovski --- --- ============================================================================== --- --- Features: --- - Autohighlight word under cursor with customizable delay. --- --- - Current word under cursor can be highlighted differently. --- --- - Highlighting is triggered only if current cursor character is a |[:keyword:]|. --- --- - Highlighting stops in insert and terminal modes. --- --- - "Word under cursor" is meant as in Vim's ||: something user would --- get as 'iw' text object. --- --- # Setup~ --- --- This module needs a setup with `require('mini.cursorword').setup({})` --- (replace `{}` with your `config` table). It will create global Lua table --- `MiniCursorword` which you can use for scripting or manually (with --- `:lua MiniCursorword.*`). --- --- See |MiniCursorword.config| for `config` structure and default values. --- --- You can override runtime config settings locally to buffer inside --- `vim.b.minicursorword_config` which should have same structure as --- `MiniCursorword.config`. See |mini.nvim-buffer-local-config| for more details. --- --- # Highlight groups~ --- --- * `MiniCursorword` - highlight group of a non-current cursor word. --- Default: plain underline. --- --- * `MiniCursorwordCurrent` - highlight group of a current word under cursor. --- Default: links to `MiniCursorword` (so `:hi clear MiniCursorwordCurrent` --- will lead to showing `MiniCursorword` highlight group). --- Note: To not highlight it, use --- --- `:hi! MiniCursorwordCurrent guifg=NONE guibg=NONE gui=NONE cterm=NONE` --- --- To change any highlight group, modify it directly with |:highlight|. --- --- # Disabling~ --- --- To disable core functionality, set `vim.g.minicursorword_disable` (globally) or --- `vim.b.minicursorword_disable` (for a buffer) to `true`. Considering high --- number of different scenarios and customization intentions, writing exact --- rules for disabling module's functionality is left to user. See --- |mini.nvim-disabling-recipes| for common recipes. Note: after disabling --- there might be highlighting left; it will be removed after next --- highlighting update. --- --- Module-specific disabling: --- - Don't show highlighting if cursor is on the word that is in a blocklist --- of current filetype. In this example, blocklist for "lua" is "local" and --- "require" words, for "javascript" - "import": --- > --- _G.cursorword_blocklist = function() --- local curword = vim.fn.expand('') --- local filetype = vim.bo.filetype --- --- -- Add any disabling global or filetype-specific logic here --- local blocklist = {} --- if filetype == 'lua' then --- blocklist = { 'local', 'require' } --- elseif filetype == 'javascript' then --- blocklist = { 'import' } --- end --- --- vim.b.minicursorword_disable = vim.tbl_contains(blocklist, curword) --- end --- --- -- Make sure to add this autocommand *before* calling module's `setup()`. --- vim.cmd('au CursorMoved * lua _G.cursorword_blocklist()') -- Module definition ========================================================== local MiniCursorword = {} local H = {} --- Module setup --- ---@param config table|nil Module config table. See |MiniCursorword.config|. --- ---@usage `require('mini.cursorword').setup({})` (replace `{}` with your `config` table) MiniCursorword.setup = function(config) -- Export module _G.MiniCursorword = MiniCursorword -- Setup config config = H.setup_config(config) -- Apply config H.apply_config(config) -- Define behavior H.create_autocommands() -- Create default highlighting H.create_default_hl() end --- Module config --- --- Default values: ---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) MiniCursorword.config = { -- Delay (in ms) between when cursor moved and when highlighting appeared delay = 100, } --minidoc_afterlines_end -- Module functionality ======================================================= -- Helper data ================================================================ -- Module default config H.default_config = vim.deepcopy(MiniCursorword.config) -- Delay timer H.timer = vim.loop.new_timer() -- Information about last match highlighting (stored *per window*): -- - Key: windows' unique buffer identifiers. -- - Value: table with: -- - `id` field for match id (from `vim.fn.matchadd()`). -- - `word` field for matched word. H.window_matches = {} -- Helper functionality ======================================================= -- Settings ------------------------------------------------------------------- H.setup_config = function(config) -- General idea: if some table elements are not present in user-supplied -- `config`, take them from default config vim.validate({ config = { config, 'table', true } }) config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {}) vim.validate({ delay = { config.delay, 'number' } }) return config end H.apply_config = function(config) MiniCursorword.config = config end H.create_autocommands = function() local augroup = vim.api.nvim_create_augroup('MiniCursorword', {}) local au = function(event, pattern, callback, desc) vim.api.nvim_create_autocmd(event, { group = augroup, pattern = pattern, callback = callback, desc = desc }) end au('CursorMoved', '*', H.auto_highlight, 'Auto highlight cursorword') au({ 'InsertEnter', 'TermEnter', 'QuitPre' }, '*', H.auto_unhighlight, 'Auto unhighlight cursorword') au('ModeChanged', '*:[^i]', H.auto_highlight, 'Auto highlight cursorword') au('ColorScheme', '*', H.create_default_hl, 'Ensure proper colors') au('FileType', 'TelescopePrompt', function() vim.b.minicursorword_disable = true end, 'Disable locally') end --stylua: ignore H.create_default_hl = function() vim.api.nvim_set_hl(0, 'MiniCursorword', { default = true, underline = true }) vim.api.nvim_set_hl(0, 'MiniCursorwordCurrent', { default = true, link = 'MiniCursorword' }) end H.is_disabled = function() return vim.g.minicursorword_disable == true or vim.b.minicursorword_disable == true end H.get_config = function(config) return vim.tbl_deep_extend('force', MiniCursorword.config, vim.b.minicursorword_config or {}, config or {}) end -- Autocommands --------------------------------------------------------------- H.auto_highlight = function() -- Stop any possible previous delayed highlighting H.timer:stop() -- Stop highlighting immediately if module is disabled when cursor is not on -- 'keyword' if not H.should_highlight() then return H.unhighlight() end -- Get current information local win_id = vim.api.nvim_get_current_win() local win_match = H.window_matches[win_id] or {} local curword = H.get_cursor_word() -- Only immediately update highlighting of current word under cursor if -- currently highlighted word equals one under cursor if win_match.word == curword then H.unhighlight(true) H.highlight(true) return end -- Stop highlighting previous match (if it exists) H.unhighlight() -- Delay highlighting H.timer:start( H.get_config().delay, 0, vim.schedule_wrap(function() -- Ensure that always only one word is highlighted H.unhighlight() H.highlight() end) ) end H.auto_unhighlight = function() -- Stop any possible previous delayed highlighting H.timer:stop() H.unhighlight() end -- Highlighting --------------------------------------------------------------- ---@param only_current boolean|nil Whether to forcefully highlight only current word --- under cursor. ---@private H.highlight = function(only_current) -- A modified version of https://stackoverflow.com/a/25233145 -- Using `matchadd()` instead of a simpler `:match` to tweak priority of -- 'current word' highlighting: with `:match` it is higher than for -- `incsearch` which is not convenient. local win_id = vim.api.nvim_get_current_win() if not vim.api.nvim_win_is_valid(win_id) then return end if not H.should_highlight() then return end H.window_matches[win_id] = H.window_matches[win_id] or {} -- Add match highlight for current word under cursor local current_word_pattern = [[\k*\%#\k*]] local match_id_current = vim.fn.matchadd('MiniCursorwordCurrent', current_word_pattern, -1) H.window_matches[win_id].id_current = match_id_current -- Don't add main match id if not needed or if one is already present if only_current or H.window_matches[win_id].id ~= nil then return end -- Add match highlight for non-current word under cursor. NOTEs: -- - Using `\(...\)\@!` allows to not match current word. -- - Using 'very nomagic' ('\V') allows not escaping. -- - Using `\<` and `\>` matches whole word (and not as part). local curword = H.get_cursor_word() local pattern = string.format([[\(%s\)\@!\&\V\<%s\>]], current_word_pattern, curword) local match_id = vim.fn.matchadd('MiniCursorword', pattern, -1) -- Store information about highlight H.window_matches[win_id].id = match_id H.window_matches[win_id].word = curword end ---@param only_current boolean|nil Whether to remove highlighting only of current --- word under cursor. ---@private H.unhighlight = function(only_current) -- Don't do anything if there is no valid information to act upon local win_id = vim.api.nvim_get_current_win() local win_match = H.window_matches[win_id] if not vim.api.nvim_win_is_valid(win_id) or win_match == nil then return end -- Use `pcall` because there is an error if match id is not present. It can -- happen if something else called `clearmatches`. pcall(vim.fn.matchdelete, win_match.id_current) H.window_matches[win_id].id_current = nil if not only_current then pcall(vim.fn.matchdelete, win_match.id) H.window_matches[win_id] = nil end end H.should_highlight = function() return not H.is_disabled() and H.is_cursor_on_keyword() end H.is_cursor_on_keyword = function() local col = vim.fn.col('.') local curchar = vim.api.nvim_get_current_line():sub(col, col) -- Use `pcall()` to catch `E5108` (can happen in binary files, see #112) local ok, match_res = pcall(vim.fn.match, curchar, '[[:keyword:]]') return ok and match_res >= 0 end H.get_cursor_word = function() return vim.fn.escape(vim.fn.expand(''), [[\/]]) end return MiniCursorword