diff --git a/README.md b/README.md index 6df478d..41fe1c8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # visimatch.nvim A tiny plugin to highlight text matching the current selection in visual mode 💫 +![250307_15h51m26s_screenshot](https://github.com/user-attachments/assets/4e1091a6-982d-4d92-a3d1-c19700f8ef8f) ![visimatch](https://github.com/user-attachments/assets/c9547434-950c-4205-945d-097481baf85e) @@ -31,9 +32,9 @@ opts = { -- The highlight group to apply to matched text hl_group = "Search", -- The minimum number of selected characters required to trigger highlighting - chars_lower_limit = 6, + chars_lower_limit = 5, -- The maximum number of selected lines to trigger highlighting for - lines_upper_limit = 30, + lines_upper_limit = 45, -- By default, visimatch will highlight text even if it doesn't have exactly -- the same spacing as the selected region. You can set this to `true` if -- you're not a fan of this behaviour :) @@ -44,12 +45,12 @@ opts = { -- * `"all"`: highlight matches in all visible buffers -- * A function. This will be passed a buffer number and should return -- `true`/`false` to indicate whether the buffer should be highlighted. - buffers = "filetype" + buffers = "filetype" , -- Case-(in)nsitivity for matches. Valid options: -- * `true`: matches will never be case-sensitive -- * `false`/`{}`: matches will always be case-sensitive -- * a table of filetypes to use use case-insensitive matching for. - case_insensitive = { "markdown", "text", "help" }, + case_insensitive = { "markdown", "text", "help" , "oil" }, } ``` diff --git a/lua/visimatch/init.lua b/lua/visimatch/init.lua index 3b72534..702a2ab 100644 --- a/lua/visimatch/init.lua +++ b/lua/visimatch/init.lua @@ -1,235 +1,322 @@ local M = {} ----@class VisimatchConfig ---- ----The highlight group to apply to matched text; defaults to `Search`. ----@field hl_group? string ---- ----The minimum number of selected characters required to trigger highlighting; ----defaults to 6. ----@field chars_lower_limit? number ---- ----The maximum number of selected lines to trigger highlighting for; defaults ----to 30. ----@field lines_upper_limit? number ---- ----If `false` (the default) text will be highlighted even if the spacing is not ----exactly the same as the text you have selected. ----@field strict_spacing? boolean ---- ----Visible buffers which should be highlighted. Valid options: ----* `"filetype"` (the default): highlight buffers with the same filetype ----* `"current"`: highlight matches in the current buffer only ----* `"all"`: highlight matches in all visible buffers ----* A function. This will be passed a buffer number and should return ---- `true`/`false` to indicate whether the buffer should be highlighted. ----@field buffers? "filetype" | "all" | "current" | fun(buf): boolean ---- ----Case-(in)sensitivity for matches. Valid options: ----* `true`: matches will never be case-sensitive ----* `false`/`{}`: matches will always be case-sensitive ----* a table of filetypes to use use case-insensitive matching for ----@field case_insensitive? boolean | string[] - ----@type VisimatchConfig +-- Default configuration local config = { - hl_group = "Search", - chars_lower_limit = 6, - lines_upper_limit = 30, - strict_spacing = false, - buffers = "filetype", - case_insensitive = { "markdown", "text", "help" }, + hl_group = "Search", -- Highlight group for matches + chars_lower_limit = 6, -- Min characters to trigger highlighting + lines_upper_limit = 30, -- Max lines to highlight + strict_spacing = false, -- Whether spacing must match exactly + buffers = "filetype", -- Where to apply highlights: "current", "all", "filetype", or function + case_insensitive = { "markdown", "text", "help" }, -- Filetypes or boolean for case-insensitive matching + blink_enabled = true, -- Enable blinking for main selection + blink_time = 500, -- Blink interval in milliseconds + blink_hl_group = "IncSearch", -- Highlight group for blinking + block_hl_group = "Visual", -- Highlight group for block mode matches + block_max_width = 50, -- Max width for block mode highlights } ----@param opts? VisimatchConfig +-- Setup function to override defaults M.setup = function(opts) - config = vim.tbl_extend("force", config, opts or {}) - vim.validate({ - hl_group = { config.hl_group, "string" }, - chars_lower_limit = { config.chars_lower_limit, "number" }, - lines_upper_limit = { config.lines_upper_limit, "number" }, - strict_spacing = { config.strict_spacing, "boolean" }, - buffers = { config.buffers, { "string", "function" } }, - case_insensitive = { config.case_insensitive, { "boolean", "table" } }, - }) + config = vim.tbl_extend("force", config, opts or {}) + vim.validate({ + hl_group = { config.hl_group, "string" }, + chars_lower_limit = { config.chars_lower_limit, "number" }, + lines_upper_limit = { config.lines_upper_limit, "number" }, + strict_spacing = { config.strict_spacing, "boolean" }, + buffers = { config.buffers, { "string", "function" } }, + case_insensitive = { config.case_insensitive, { "boolean", "table" } }, + blink_enabled = { config.blink_enabled, "boolean", true }, + blink_time = { config.blink_time, "number", true }, + blink_hl_group = { config.blink_hl_group, "string", true }, + block_hl_group = { config.block_hl_group, "string" }, + block_max_width = { config.block_max_width, "number" }, + }) end --- string.find() seems to have a bug/issue where you get a `pattern too --- complex` error if the pattern used is too long _and_ matches the text in --- question. NB, to see this in action you just need to use a regular --- string.find() in the algorithm and try selecting a tonne of repeated text. --- This is a workaround for this bug, which tries a normal `find()` call, and --- if it fails, tries again by splitting the pattern up into ~100 character --- chunks and checking them in sequence. This function isn't smart enough --- to handle arbitrary patterns - but it is smart enough to handle the patterns --- used in this plugin. -local find2 = function(s, pattern, init, plain) - local ok, start, stop = pcall(string.find, s, pattern, init, plain) - if ok then - return start, stop - end - - local needle_length = 100 - local needle_start, any_matches = 1, false - local match_start - local match_stop = init and (init - 1) or nil - - local i = 0 - while needle_start < pattern:len() do - i = i + 1 - local needle_end = needle_start + needle_length - - -- If the end of the new pattern intersects either `%` or - -- `%s+`, we need to extend the pattern by a few chars. - local _, extra1 = pattern:find("^.?%%s%+", needle_end - 1) - local _, extra2 = pattern:find("^[^%%]%%.", needle_end - 1) - needle_end = extra1 or extra2 or needle_end - - local small_match_start, small_match_stop = s:find( - pattern:sub(needle_start, needle_end), - (match_stop or 0) + 1 - ) - - if small_match_start then - match_start = match_start or small_match_start - match_stop = small_match_stop - any_matches = true - elseif any_matches then - return nil, nil - end - - needle_start = needle_end + 1 - end - - return match_start, match_stop +-- Get windows to highlight based on config.buffers +local function get_wins(how) + local current_win = vim.api.nvim_get_current_win() + local current_buf = vim.api.nvim_win_get_buf(current_win) + local current_ft = vim.api.nvim_buf_get_option(current_buf, "filetype") + + if how == "current" then + return { current_win } + elseif how == "all" then + return vim.api.nvim_list_wins() + elseif how == "filetype" then + local wins = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local ft = vim.api.nvim_buf_get_option(buf, "filetype") + if ft == current_ft then + table.insert(wins, win) + end + end + return wins + elseif type(how) == "function" then + local wins = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + if how(buf) then + table.insert(wins, win) + end + end + return wins + else + error("Invalid 'buffers' option: " .. tostring(how)) + end end ----@alias TextPoint { line: number, col: number } ----@alias TextRegion { start: TextPoint, stop: TextPoint } - ----@param x string[] A table of strings; each string represents a line ----@param pattern string The pattern to match against ----@param plain boolean If `true`, special characters in `pattern` are ignored ----@return TextRegion[] -local gfind = function(x, pattern, plain) - local x_collapsed, matches, init = table.concat(x, "\n"), {}, 0 - - while true do - local start, stop = find2(x_collapsed, pattern, init, plain) - if start == nil then break end - table.insert(matches, { start = start, stop = stop }) - init = stop + 1 - end - - local match_line, match_col = 1, 0 - - for _, m in pairs(matches) do - for _, type in ipairs({ "start", "stop" }) do - local line_end = match_col + #x[match_line] - while m[type] > line_end do - match_col = match_col + #x[match_line] + 1 - match_line = match_line + 1 - line_end = match_col + #x[match_line] - end - m[type] = { line = match_line, col = m[type] - match_col } - end - end +-- Namespaces and augroup for managing highlights +local match_ns = vim.api.nvim_create_namespace("visimatch") +local block_match_ns = vim.api.nvim_create_namespace("visimatch-block") +local main_selection_ns = vim.api.nvim_create_namespace("visimatch_main_selection") +local augroup = vim.api.nvim_create_augroup("visimatch", { clear = true }) - return matches +-- Blinking state +local main_selection = nil +local blink_timer = nil +local blink_state = false + +-- Apply blinking highlight to main selection +local function apply_blink_highlight(selection) + local buf = selection.buf + vim.api.nvim_buf_clear_namespace(buf, main_selection_ns, 0, -1) + if selection.mode == "V" then + -- Line-wise selection + for line = selection.start_line, selection.end_line do + vim.api.nvim_buf_add_highlight(buf, main_selection_ns, config.blink_hl_group, line - 1, 0, -1) + end + elseif selection.mode == "" then + -- Block selection: highlight only the matching parts + local pattern = selection.matching_pattern + for line = selection.start_line, selection.end_line do + local text = vim.api.nvim_buf_get_lines(buf, line - 1, line, true)[1] or "" + local start_col = 1 + while true do + local s, e = string.find(text, pattern, start_col, true) + if not s then + break + end + vim.api.nvim_buf_add_highlight(buf, main_selection_ns, config.blink_hl_group, line - 1, s - 1, e) + start_col = e + 1 + end + end + else -- for 'v' + -- Character-wise selection + local start_line = selection.start_line + local end_line = selection.end_line + for line = start_line, end_line do + local start_col = (line == start_line) and (selection.start_col - 1) or 0 + local end_col = (line == end_line) and selection.end_col or -1 + vim.api.nvim_buf_add_highlight(buf, main_selection_ns, config.blink_hl_group, line - 1, start_col, end_col) + end + end end ----@param how "all" | "current" | "filetype" | fun(buf): boolean -local get_wins = function(how) - if how == "current" then - return { vim.api.nvim_get_current_win() } - elseif how == "all" then - return vim.api.nvim_tabpage_list_wins(0) - elseif how == "filetype" then - return vim.tbl_filter( - function(w) return vim.bo[vim.api.nvim_win_get_buf(w)].ft == vim.bo.ft end, - vim.api.nvim_tabpage_list_wins(0) - ) - elseif type(how) == "function" then - return vim.tbl_filter( - function(w) - return how(vim.api.nvim_win_get_buf(w)) and true or false - end, - vim.api.nvim_tabpage_list_wins(0) - ) - end - error(("Invalid input for `how`: `%s`"):format(vim.inspect(how))) +-- Toggle blinking effect +local function toggle_blink() + if not main_selection then + return + end + blink_state = not blink_state + if blink_state then + apply_blink_highlight(main_selection) + else + vim.api.nvim_buf_clear_namespace(main_selection.buf, main_selection_ns, 0, -1) + end end -local is_case_insensitive = function(ft1, ft2) - if type(config.case_insensitive) == "boolean" then return config.case_insensitive end - if type(config.case_insensitive) == "table" then - ---@diagnostic disable-next-line: param-type-mismatch - for _, special_ft in ipairs(config.case_insensitive) do - if ft1 == special_ft or ft2 == special_ft then return true end - end - end - return false +-- Process visual block mode () +local function process_block_selection() + local start_pos = vim.fn.getpos("v") + local end_pos = vim.fn.getpos(".") + local buf = vim.api.nvim_get_current_buf() + -- Normalize block coordinates + local start_line = math.min(start_pos[2], end_pos[2]) + local end_line = math.max(start_pos[2], end_pos[2]) + local start_col = math.min(start_pos[3], end_pos[3]) + local end_col = math.max(start_pos[3], end_pos[3]) + -- Check block width limit + if (end_col - start_col) > config.block_max_width then + return + end + -- Extract block text pattern + local block_pattern = {} + for lnum = start_line, end_line do + local line = vim.api.nvim_buf_get_lines(buf, lnum - 1, lnum, true)[1] or "" + local substring = line:sub(start_col, end_col) + table.insert(block_pattern, substring) + end + -- Handle case sensitivity + local current_ft = vim.api.nvim_buf_get_option(buf, "filetype") + local case_insensitive = type(config.case_insensitive) == "boolean" and config.case_insensitive + or vim.tbl_contains(config.case_insensitive, current_ft) + if case_insensitive then + for i, str in ipairs(block_pattern) do + block_pattern[i] = string.lower(str) + end + end + -- Use the first line's pattern as the matching pattern + local matching_pattern = block_pattern[1] + if case_insensitive then + matching_pattern = string.lower(matching_pattern) + end + -- Store the matching pattern for blinking + main_selection = { + buf = buf, + mode = "", + start_line = start_line, + end_line = end_line, + matching_pattern = matching_pattern, + } + -- Highlight matches in target windows + for _, win in ipairs(get_wins("filetype")) do + local tbuf = vim.api.nvim_win_get_buf(win) + local lines = vim.api.nvim_buf_get_lines(tbuf, 0, -1, false) + for lnum, line in ipairs(lines) do + local start_col = 1 + while true do + local s, e = string.find(line, matching_pattern, start_col, true) + if not s then + break + end + vim.api.nvim_buf_add_highlight(tbuf, block_match_ns, config.block_hl_group, lnum - 1, s - 1, e) + start_col = e + 1 + end + end + end end +-- Clear all highlights +local function clear_highlights() + -- Clear regular matches + local wins = get_wins(config.buffers) + for _, win in pairs(wins) do + local buf = vim.api.nvim_win_get_buf(win) + vim.api.nvim_buf_clear_namespace(buf, match_ns, 0, -1) + end + -- Clear block matches + local block_wins = get_wins("filetype") + for _, win in pairs(block_wins) do + local buf = vim.api.nvim_win_get_buf(win) + vim.api.nvim_buf_clear_namespace(buf, block_match_ns, 0, -1) + end + -- Clear main selection and stop blinking + local current_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_clear_namespace(current_buf, main_selection_ns, 0, -1) + if blink_timer then + blink_timer:stop() + blink_timer:close() + blink_timer = nil + end + main_selection = nil +end -local match_ns = vim.api.nvim_create_namespace("visimatch") -local augroup = vim.api.nvim_create_augroup("visimatch", { clear = true }) - +-- Main autocommand for handling highlights vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, { - group = augroup, - callback = function() - local wins = get_wins(config.buffers) - for _, win in pairs(wins) do - vim.api.nvim_buf_clear_namespace(vim.api.nvim_win_get_buf(win), match_ns, 0, -1) - end - - local mode = vim.fn.mode() - if mode ~= "v" and mode ~= "V" then return end - - local selection_start, selection_stop = vim.fn.getpos("v"), vim.fn.getpos(".") - local selection = vim.fn.getregion(selection_start, selection_stop, { type = mode }) - local selection_collapsed = vim.trim(table.concat(selection, "\n")) - local selection_buf = vim.api.nvim_get_current_buf() - - if #selection > config.lines_upper_limit then return end - if #selection_collapsed < config.chars_lower_limit then return end - - local pattern = selection_collapsed:gsub("(%p)", "%%%0") - if not config.strict_spacing then pattern = pattern:gsub("%s+", "%%s+") end - local pattern_lower - - for _, win in pairs(wins) do - local first_line = math.max(0, vim.fn.line("w0", win) - #selection) - local last_line = vim.fn.line("w$", win) + #selection - local buf = vim.api.nvim_win_get_buf(win) - local visible_text = vim.api.nvim_buf_get_lines(buf, first_line, last_line, false) - local case_insensitive = is_case_insensitive(vim.bo[buf].ft, vim.bo.ft) - - if case_insensitive and not pattern_lower then pattern_lower = pattern:lower() end - - local needle = case_insensitive and pattern_lower or pattern - local haystack = case_insensitive and vim.tbl_map(string.lower, visible_text) or visible_text - local matches = gfind(haystack, needle, false) - - for _, m in pairs(matches) do - m.start.line, m.stop.line = m.start.line + first_line, m.stop.line + first_line - - local m_starts_after_selection = m.start.line > selection_stop[2] or (m.start.line == selection_stop[2] and m.start.col > selection_stop[3]) - local m_ends_before_selection = m.stop.line < selection_start[2] or (m.stop.line == selection_start[2] and m.stop.col < selection_start[3]) + group = augroup, + callback = function() + clear_highlights() + local mode = vim.fn.mode() + if mode == "v" or mode == "V" or mode == "" then + local selection_start, selection_stop = vim.fn.getpos("v"), vim.fn.getpos(".") + local selection = vim.fn.getregion(selection_start, selection_stop, { type = mode }) + local selection_buf = vim.api.nvim_get_current_buf() + -- Calculate total characters for limit checks + local total_chars = 0 + for _, str in ipairs(selection) do + total_chars = total_chars + #str + end + if #selection > config.lines_upper_limit or total_chars < config.chars_lower_limit then + return + end + -- Normalize start and end positions + local start_line = math.min(selection_start[2], selection_stop[2]) + local end_line = math.max(selection_start[2], selection_stop[2]) + local start_col = mode == "" and math.min(selection_start[3], selection_stop[3]) or selection_start[3] + local end_col = mode == "" and math.max(selection_start[3], selection_stop[3]) or selection_stop[3] + -- Store main selection + if mode == "" then + -- For block mode, store the matching pattern + local block_pattern = {} + for lnum = start_line, end_line do + local line = vim.api.nvim_buf_get_lines(selection_buf, lnum - 1, lnum, true)[1] or "" + local substring = line:sub(start_col, end_col) + table.insert(block_pattern, substring) + end + -- Use the first line's pattern as the matching pattern + local matching_pattern = block_pattern[1] + main_selection = { + buf = selection_buf, + mode = mode, + start_line = start_line, + end_line = end_line, + matching_pattern = matching_pattern, + } + else + main_selection = { + buf = selection_buf, + mode = mode, + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, + } + end + -- Apply blinking if enabled + if config.blink_enabled then + apply_blink_highlight(main_selection) + blink_timer = vim.loop.new_timer() + blink_timer:start(config.blink_time, config.blink_time, vim.schedule_wrap(toggle_blink)) + end + -- Highlight matches based on mode + if mode == "v" then + local selected_text = table.concat(selection, "\n") + for _, win in ipairs(get_wins(config.buffers)) do + local buf = vim.api.nvim_win_get_buf(win) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + for lnum, line in ipairs(lines) do + local start_col = 1 + while true do + local s, e = string.find(line, selected_text, start_col, true) + if not s then + break + end + vim.api.nvim_buf_add_highlight(buf, match_ns, config.hl_group, lnum - 1, s - 1, e) + start_col = e + 1 + end + end + end + elseif mode == "V" then + local selected_lines = {} + for _, line in ipairs(selection) do + selected_lines[line] = true + end + for _, win in ipairs(get_wins(config.buffers)) do + local buf = vim.api.nvim_win_get_buf(win) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + for lnum, line in ipairs(lines) do + if selected_lines[line] then + vim.api.nvim_buf_add_highlight(buf, match_ns, config.hl_group, lnum - 1, 0, -1) + end + end + end + elseif mode == "" then + process_block_selection() + end + end + end, +}) - if buf ~= selection_buf or m_starts_after_selection or m_ends_before_selection then - for line = m.start.line, m.stop.line do - vim.api.nvim_buf_add_highlight( - buf, match_ns, config.hl_group, line - 1, - line == m.start.line and m.start.col - 1 or 0, - line == m.stop.line and m.stop.col or -1 - ) - end - end - end - end - end +-- Clear highlights when exiting visual modes +vim.api.nvim_create_autocmd("ModeChanged", { + group = augroup, + callback = function() + if vim.fn.mode() ~= "v" and vim.fn.mode() ~= "V" and vim.fn.mode() ~= "" then + clear_highlights() + end + end, }) return M -