local M = {} --- Jump to a location in the current buffer --- @param location table|nil Location data from search results function M.jump_to_location(location) if not location then return end local current_buf = vim.api.nvim_get_current_buf() local line_count = vim.api.nvim_buf_line_count(current_buf) if location.line then local target_line = math.max(1, math.min(location.line, line_count)) local target_col = location.col and math.max(0, location.col - 1) or 0 vim.api.nvim_win_set_cursor(0, { target_line, target_col }) vim.cmd('normal! zz') elseif location.start and location['end'] then -- Extract line numbers from nested structure local start_line = math.max(1, math.min(location.start.line, line_count)) local end_line = math.max(start_line, math.min(location['end'].line, line_count)) -- start in the visual mode and selecting the range backwards so the cursor ends up at the start vim.api.nvim_win_set_cursor(0, { end_line, 0 }) vim.cmd('normal! V') if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end vim.cmd('normal! zz') end end --- Highlight a location range in a buffer using extmarks --- @param bufnr number Buffer number --- @param location table|nil Location data from search results --- @param namespace number Namespace for extmarks --- @return table|nil Highlight extmark details for cleanup function M.highlight_location(bufnr, location, namespace) if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end local line_count = vim.api.nvim_buf_line_count(bufnr) local extmarks = {} -- Grep mode: highlight all occurrences of the search pattern across visible lines if location.grep_query and location.grep_query ~= '' then return M.highlight_grep_matches(bufnr, location, namespace) end if location.line then local target_line = math.max(1, math.min(location.line, line_count)) if location.col then local target_col = math.max(0, location.col - 1) local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or '' local end_col = math.min(target_col + 1, #line_content) local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, { end_col = end_col, hl_group = 'IncSearch', -- inc search are better visible for a single chars line_hl_group = 'CursorLine', number_hl_group = 'CursorLineNr', priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end else local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, { line_hl_group = 'Visual', number_hl_group = 'CursorLineNr', priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end end elseif location.start and location['end'] then local start_line = math.max(1, math.min(location.start.line, line_count)) local end_line = math.max(start_line, math.min(location['end'].line, line_count)) -- Check if we have column information for exact range highlighting if location.start.col and location['end'].col then if start_line == end_line then -- Single line range with columns: highlight exact character range local start_col = math.max(0, location.start.col - 1) local end_col = location['end'].col - 1 local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or '' end_col = math.min(end_col, #line_content) local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, { end_col = end_col, hl_group = 'IncSearch', line_hl_group = 'CursorLine', number_hl_group = 'CursorLineNr', priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end else -- Multi-line range with exact columns: highlight precise ranges for line = start_line, end_line do local line_start_col, line_end_col if line == start_line then -- First line: from start_col to end of line line_start_col = math.max(0, location.start.col - 1) local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or '' line_end_col = #line_content elseif line == end_line then -- Last line: from beginning to end_col line_start_col = 0 line_end_col = location['end'].col - 1 local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or '' line_end_col = math.min(line_end_col, #line_content) else -- Middle lines: entire line line_start_col = 0 local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or '' line_end_col = #line_content end local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, { end_col = line_end_col, hl_group = 'Visual', priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end end end else -- Multi-line or no columns: highlight entire lines for line = start_line, end_line do local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, { line_hl_group = 'Visual', priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end end end end return #extmarks > 0 and extmarks or nil end --- Highlight all occurrences of a grep pattern in the preview buffer. --- For plain text and regex modes: highlights every match on all loaded lines --- using Lua string.find with the query text. --- For fuzzy mode: uses the pre-computed match byte offsets from Rust on the --- target line only, since the fuzzy needle (e.g. "shcema") won't match via --- literal search against the actual content (e.g. "schema"). --- @param bufnr number Buffer number --- @param location table Location with .grep_query, .line, optional .col, optional .fuzzy_match_ranges --- @param namespace number Namespace for extmarks --- @return table|nil Highlight extmark details for cleanup function M.highlight_grep_matches(bufnr, location, namespace) if not vim.api.nvim_buf_is_valid(bufnr) then return nil end local line_count = vim.api.nvim_buf_line_count(bufnr) local extmarks = {} local grep_hl = require('fff.conf').get().hl.grep_match or 'IncSearch' -- Pin CursorLine + CursorLineNr to the target match line via extmark, so -- the highlight stays anchored when the user pages the preview viewport -- with /. The cursor itself moves with paging, but the match -- line stays styled until it scrolls out of view. if location.line then local target_line = math.max(1, math.min(location.line, line_count)) local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, { line_hl_group = 'CursorLine', number_hl_group = 'CursorLineNr', priority = 999, }) if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end end -- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices. -- These are the exact matched character positions within the line, already -- computed by the SIMD scoring + reference smith-waterman traceback. -- We only highlight the target line since each fuzzy result has its own -- unique set of matched positions. if location.fuzzy_match_ranges and location.line then local target_line = math.max(1, math.min(location.line, line_count)) for _, range in ipairs(location.fuzzy_match_ranges) do local start_byte = range[1] -- 0-based byte offset local end_byte = range[2] -- 0-based exclusive end local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, start_byte, { end_col = end_byte, hl_group = grep_hl, priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end end -- Fuzzy mode: only target line has matches; skip the plain-text scan below. return #extmarks > 0 and extmarks or nil end local query = location.grep_query -- Use the Rust GrepConfig parser as the single source of truth for -- stripping constraint tokens. This avoids duplicating constraint -- detection in Lua, which would break whenever a new token type is added. local fuzzy = require('fff.fuzzy') local parsed = fuzzy.parse_grep_query(query) local search_text = parsed.grep_text if search_text == '' then search_text = query end if not search_text or search_text == '' then return nil end -- Build case-insensitive pattern if the query has no uppercase (smart case) local has_upper = search_text:match('[A-Z]') -- Highlight pattern occurrences in a window around the target line. -- Limit to ±200 lines from target to keep it fast for large files. local scan_start = 1 local scan_end = line_count if location.line then scan_start = math.max(1, location.line - 200) scan_end = math.min(line_count, location.line + 200) end local lines = vim.api.nvim_buf_get_lines(bufnr, scan_start - 1, scan_end, false) for idx, line in ipairs(lines) do local i = scan_start + idx - 1 local search_line = has_upper and line or line:lower() local search_pat = has_upper and search_text or search_text:lower() local start_pos = 1 while true do local s, e = search_line:find(search_pat, start_pos, true) if not s then break end -- s and e are 1-based byte positions; extmarks need 0-based local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, i - 1, s - 1, { end_col = e, hl_group = grep_hl, priority = 1000, }) if ok then table.insert(extmarks, { id = mark_id, line = i - 1 }) end start_pos = e + 1 end end return #extmarks > 0 and extmarks or nil end --- Clear location highlights from a buffer --- @param bufnr number Buffer number --- @param namespace number Namespace for extmarks function M.clear_location_highlights(bufnr, namespace) if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end end --- Get the target line for scrolling preview to location --- @param location table|nil Location data from search results --- @return number|nil Target line number (1-indexed) for scrolling function M.get_target_line(location) if not location then return nil end if location.line then return location.line elseif location.start then return location.start.line end return nil end --- Check if location is valid for a buffer --- @param location table|nil Location data --- @param bufnr number Buffer number --- @return boolean True if location is valid for the buffer function M.is_valid_location(location, bufnr) if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end local line_count = vim.api.nvim_buf_line_count(bufnr) if location.line then return location.line > 0 and location.line <= line_count elseif location.start and location['end'] then return location.start.line > 0 and location.start.line <= line_count end return false end --- Format location for display --- @param location table|nil Location data --- @return string Formatted location string function M.format_location(location) if not location then return '' end if location.line and location.col then return string.format(':%d:%d', location.line, location.col) elseif location.line then return string.format(':%d', location.line) elseif location.start and location['end'] then -- Handle nested structure with optional column information if location.start.col and location['end'].col then return string.format( ':%d:%d-%d:%d', location.start.line, location.start.col, location['end'].line, location['end'].col ) else return string.format(':%d-%d', location.start.line, location['end'].line) end end return '' end return M