d5f207424b
e2e Tests / e2e (macos-latest) (push) Waiting to run
Nix CI / check (push) Waiting to run
Python CI / Python bindings (macos-latest) (push) Waiting to run
e2e Tests / e2e (windows-latest) (push) Waiting to run
Python CI / Python bindings (windows-latest) (push) Waiting to run
Build & Publish / Build Neovim aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Neovim x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Waiting to run
Build & Publish / Release (push) Blocked by required conditions
Build & Publish / Publish Python wheels to PyPI (push) Blocked by required conditions
Build & Publish / Publish Rust crates (push) Blocked by required conditions
Build & Publish / Publish npm packages (push) Blocked by required conditions
Rust CI / Fuzz Tests (windows-latest) (push) Waiting to run
Rust CI / Test (macos-latest) (push) Waiting to run
Rust CI / Test (windows-latest) (push) Waiting to run
Rust CI / Fuzz Tests (macos-latest) (push) Waiting to run
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s
298 lines
10 KiB
Lua
298 lines
10 KiB
Lua
--- Grep search bridge and renderer.
|
|
--- Wraps the Rust `live_grep` FFI function with file-based pagination state tracking.
|
|
--- Also provides renderer for live grep results with file grouping.
|
|
local M = {
|
|
supports_cursor_rerender = true,
|
|
}
|
|
|
|
local fuzzy = require('fff.fuzzy')
|
|
local file_renderer = require('fff.picker_ui.file_renderer')
|
|
local tresitter_highlight = require('fff.treesitter_hl')
|
|
|
|
-- ===== Search Bridge =====
|
|
|
|
---@class fff.grep.SearchResult
|
|
---@field items table[] Array of grep match items
|
|
---@field total_matched number Total matches found in this call
|
|
---@field total_files_searched number Files actually searched in this call
|
|
---@field total_files number Total indexed files
|
|
---@field filtered_file_count number Total searchable files after filtering
|
|
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
|
|
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
|
|
|
|
local last_result = nil
|
|
|
|
--- Perform a grep search.
|
|
---@param query string The search query (may contain file constraints like *.rs)
|
|
---@param file_offset? number Index into sorted file list to start from (default 0)
|
|
---@param page_size? number Max matches to collect (default 50)
|
|
---@param config? table Grep configuration overrides
|
|
---@param grep_mode? string Search mode: "plain" (default), "regex", or "fuzzy"
|
|
---@return fff.grep.SearchResult
|
|
function M.search(query, file_offset, page_size, config, grep_mode)
|
|
local conf = config or {}
|
|
last_result = fuzzy.live_grep(
|
|
query or '',
|
|
file_offset or 0,
|
|
page_size or 50,
|
|
conf.max_file_size,
|
|
conf.max_matches_per_file,
|
|
conf.smart_case,
|
|
grep_mode or 'plain',
|
|
conf.time_budget_ms,
|
|
conf.trim_whitespace
|
|
)
|
|
return last_result
|
|
end
|
|
|
|
--- Get metadata from the last search result.
|
|
---@return { total_matched: number, total_files_searched: number, total_files: number, next_file_offset: number }
|
|
function M.get_search_metadata()
|
|
if not last_result then
|
|
return { total_matched = 0, total_files_searched = 0, total_files = 0, next_file_offset = 0 }
|
|
end
|
|
return {
|
|
total_matched = last_result.total_matched or 0,
|
|
total_files_searched = last_result.total_files_searched or 0,
|
|
total_files = last_result.total_files or 0,
|
|
next_file_offset = last_result.next_file_offset or 0,
|
|
}
|
|
end
|
|
|
|
-- ===== Renderer =====
|
|
|
|
--- Build the file group header line using the same layout as file_renderer.
|
|
--- Delegates to file_renderer.render_line (with combo disabled).
|
|
---@param item FileItem Grep match
|
|
---@param ctx table Render context
|
|
---@return string The header line string
|
|
local function build_group_header(item, ctx)
|
|
local lines = file_renderer.render_line(item, ctx)
|
|
return lines[1]
|
|
end
|
|
|
|
--- Apply highlights for a file group header line using file_renderer.
|
|
---@param item FileItem Grep match item
|
|
---@param ctx ListRenderContext Render context
|
|
---@param buf number Buffer handle
|
|
---@param ns_id number Namespace id
|
|
---@param row number 0-based row in buffer (header line)
|
|
local function apply_group_header_highlights(item, ctx, buf, ns_id, row)
|
|
local line_content = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] or ''
|
|
local saved_cursor = ctx.cursor
|
|
ctx.cursor = -1
|
|
file_renderer.apply_highlights(item, ctx, 0, buf, ns_id, row + 1, line_content)
|
|
ctx.cursor = saved_cursor
|
|
end
|
|
|
|
--- Format a grep match location string.
|
|
---@param item table Grep match item
|
|
---@param ctx table Render context
|
|
---@return string
|
|
local function format_location(item, ctx)
|
|
local fmt = (ctx.config and ctx.config.grep and ctx.config.grep.location_format) or ':%d:%d'
|
|
local ok, str = pcall(string.format, fmt, item.line_number or 0, (item.col or 0) + 1)
|
|
if not ok then str = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1) end
|
|
return str
|
|
end
|
|
|
|
local BINARY_PLACEHOLDER = '<binary content>'
|
|
|
|
--- Render a single grep match line.
|
|
---@param item table Grep match item
|
|
---@param ctx table Render context
|
|
---@return string
|
|
local function render_match_line(item, ctx)
|
|
local location = format_location(item, ctx)
|
|
local separator = ' '
|
|
local raw_content = item.line_content
|
|
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
|
|
local content = raw_content
|
|
if item.is_binary_content then content = BINARY_PLACEHOLDER end
|
|
|
|
local indent = ' '
|
|
local prefix_display_w = #indent + #location + #separator
|
|
local available = ctx.win_width - prefix_display_w - 2
|
|
local content_display_w = vim.fn.strdisplaywidth(content)
|
|
|
|
if content_display_w > available and available > 3 then
|
|
local nchars = vim.fn.strchars(content)
|
|
local lo, hi = 0, nchars
|
|
while lo < hi do
|
|
local mid = math.floor((lo + hi + 1) / 2)
|
|
if vim.fn.strdisplaywidth(vim.fn.strcharpart(content, 0, mid)) <= available - 1 then
|
|
lo = mid
|
|
else
|
|
hi = mid - 1
|
|
end
|
|
end
|
|
content = vim.fn.strcharpart(content, 0, lo) .. '…'
|
|
end
|
|
|
|
local line = indent .. location .. separator .. content
|
|
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
|
|
|
item._match_indent = #indent
|
|
item._content_offset = prefix_display_w
|
|
item._trimmed_content = content
|
|
|
|
return line .. string.rep(' ', padding)
|
|
end
|
|
|
|
--- Apply highlights for a grouped match line.
|
|
---@param item table Grep match item
|
|
---@param item_idx number 1-based item index
|
|
---@param buf number Buffer handle
|
|
---@param ns_id number Namespace id
|
|
---@param row number 0-based row in buffer
|
|
---@param line_content string The rendered line text
|
|
local function apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
|
|
local config = ctx.config
|
|
local is_cursor = item_idx == ctx.cursor
|
|
local indent = item._match_indent or 1
|
|
|
|
if is_cursor then
|
|
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
|
end_col = 0,
|
|
end_row = row + 1,
|
|
hl_group = config.hl.cursor,
|
|
hl_eol = true,
|
|
priority = 100,
|
|
})
|
|
end
|
|
|
|
local location_str = format_location(item, ctx)
|
|
local loc_start = indent
|
|
local loc_end = loc_start + #location_str
|
|
if loc_end <= #line_content then
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, loc_start, {
|
|
end_col = loc_end,
|
|
hl_group = config.hl.grep_line_number or 'LineNr',
|
|
priority = 150,
|
|
})
|
|
end
|
|
|
|
local sep_start = loc_end
|
|
local sep_end = sep_start + 2
|
|
if sep_end <= #line_content then
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, sep_start, {
|
|
end_col = sep_end,
|
|
hl_group = 'Comment',
|
|
priority = 150,
|
|
})
|
|
end
|
|
|
|
local content_start = sep_end
|
|
|
|
if item.is_binary_content then
|
|
local content_end = content_start + #BINARY_PLACEHOLDER
|
|
if content_end <= #line_content then
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, content_start, {
|
|
end_col = content_end,
|
|
hl_group = 'Comment',
|
|
priority = 150,
|
|
})
|
|
end
|
|
elseif item._trimmed_content and item.name then
|
|
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
|
|
local lang = ctx._ts_lang_cache[item.name]
|
|
if lang == nil then
|
|
lang = tresitter_highlight.lang_from_filename(item.name) or false
|
|
ctx._ts_lang_cache[item.name] = lang
|
|
end
|
|
|
|
if lang then
|
|
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
|
|
for _, hl in ipairs(highlights) do
|
|
local hl_start = content_start + hl.col
|
|
local hl_end = content_start + hl.end_col
|
|
if hl_start < #line_content and hl_end <= #line_content then
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
|
end_col = hl_end,
|
|
hl_group = hl.hl_group,
|
|
priority = 120,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if item.match_ranges and not item.is_binary_content then
|
|
for _, range in ipairs(item.match_ranges) do
|
|
local raw_start = range[1] or 0
|
|
local raw_end = range[2] or 0
|
|
|
|
if raw_end > 0 then
|
|
raw_start = math.max(0, raw_start)
|
|
local hl_start = content_start + raw_start
|
|
local hl_end = content_start + raw_end
|
|
if hl_start < #line_content and hl_end <= #line_content then
|
|
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
|
end_col = hl_end,
|
|
hl_group = config.hl.grep_match or 'IncSearch',
|
|
priority = 200,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if ctx.selected_items then
|
|
local key = string.format('%s:%d:%d', item.relative_path, item.line_number or 0, item.col or 0)
|
|
if ctx.selected_items[key] then
|
|
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
|
sign_text = '▊',
|
|
sign_hl_group = config.hl.selected or 'FFFSelected',
|
|
priority = 1001,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Render a single item's lines (called by list_renderer).
|
|
--- Returns 2 lines [header, match] for the first match of a file group,
|
|
--- or 1 line [match] for subsequent matches in the same file.
|
|
---@param item FileItem Grep match item
|
|
---@param ctx table Render context
|
|
---@param item_idx number 1-based item index in ctx.items
|
|
---@return string[]
|
|
function M.render_line(item, ctx, item_idx)
|
|
-- First rendered item in this pass always gets header — fixes missing header
|
|
-- when paginating backward in multi-page grep results (ctx is fresh per render).
|
|
local is_first_visible = (item_idx == ctx.iter_start)
|
|
local is_new_group = is_first_visible or (item.relative_path ~= ctx.grep_last_file)
|
|
ctx.grep_last_file = item.relative_path
|
|
|
|
local match_line = render_match_line(item, ctx)
|
|
|
|
if is_new_group then
|
|
---@diagnostic disable-next-line: inject-field
|
|
item._has_group_header = true
|
|
local header_line = build_group_header(item, ctx)
|
|
return { header_line, match_line }
|
|
else
|
|
---@diagnostic disable-next-line: inject-field
|
|
item._has_group_header = false
|
|
return { match_line }
|
|
end
|
|
end
|
|
|
|
--- Apply highlights for rendered lines (called by list_renderer).
|
|
---@param item FileItem Grep match item
|
|
---@param ctx ListRenderContext Render context
|
|
---@param item_idx number 1-based item index
|
|
---@param buf number Buffer handle
|
|
---@param ns_id number Namespace id
|
|
---@param line_idx number 1-based line index of the match line
|
|
---@param line_content string The rendered match line text
|
|
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
|
local row = line_idx - 1
|
|
|
|
apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
|
|
|
|
---@diagnostic disable-next-line: undefined-field
|
|
if item._has_group_header then apply_group_header_highlights(item, ctx, buf, ns_id, row - 1) end
|
|
end
|
|
|
|
return M
|