*fff.nvim.txt*
                          For Neovim >= 0.10.0    Last change: 2026 October 20

==============================================================================
Table of Contents                                 *fff.nvim-table-of-contents*

1. fff.nvim                                                |fff.nvim-fff.nvim|

==============================================================================
1. fff.nvim                                                *fff.nvim-fff.nvim*

The best file search picker for Neovim. Frecency-ranked, typo-resistant,
git-award, very fast.

Demo on the Linux kernel repo (100k files, 8GB):


https://github.com/user-attachments/assets/5d0e1ce9-642c-4c44-aa88-01b05bb86abb


INSTALLATION ~


LAZY.NVIM

>lua
    -- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:Lazy clean`
    {
      'dmtrKovalenko/fff',
      build = function()
        -- downloads a prebuilt binary or falls back to cargo build
        require("fff.download").download_or_build_binary()
      end,
      -- for nixos:
      -- build = "nix run .#release",
      opts = {
        debug = {
          enabled = true,
          show_scores = true,
        },
      },
      lazy = false, -- the plugin lazy-initialises itself
      keys = {
        { "ff", function() require('fff').find_files() end, desc = 'FFFind files' },
        { "fg", function() require('fff').live_grep() end, desc = 'LiFFFe grep' },
        { "fz",
          function() require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } }) end,
          desc = 'Live fffuzy grep',
        },
        { "fw",
          function() require('fff').live_grep_under_cursor() end,
          mode = { 'n', 'x' },
          desc = 'Search current word / selection',
        },
      },
    }
<


VIM.PACK

>lua
    -- Package name changed from `fff.nvim` to `fff`. If you installed fff.nvim before, clean with `:packdel fff.nvim`
    vim.pack.add({ 'https://github.com/dmtrKovalenko/fff' })

    vim.api.nvim_create_autocmd('PackChanged', {
      callback = function(ev)
        local name, kind = ev.data.spec.name, ev.data.kind
        if name == 'fff' and (kind == 'install' or kind == 'update') then
          if not ev.data.active then vim.cmd.packadd('fff') end
          require('fff.download').download_or_build_binary()
        end
      end,
    })

    vim.g.fff = {
      lazy_sync = true,
      debug = { enabled = true, show_scores = true },
    }

    vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'FFFind files' })
<


PUBLIC API ~

>lua
    require('fff').find_files()                        -- find files in current repo
    require('fff').live_grep()                         -- live content grep
    require('fff').live_grep_under_cursor()            -- grep <cword> in normal, selection in visual
    require('fff').scan_files()                        -- force rescan
    require('fff').refresh_git_status()                -- refresh git status
    require('fff').find_files_in_dir(path)             -- find in a specific dir
    require('fff').change_indexing_directory(new_path) -- change root

    -- Programmatic search (no UI). Useful for plugin integrations.
    require('fff').file_search(query, opts)            -- fuzzy search files / dirs / mixed
    require('fff').content_search(query, opts)         -- programmatic grep
<


FILE_SEARCH(QUERY, OPTS)

Returns a structured result `{ items, scores, total_matched, total_files?,
total_dirs?, location? }`. Each item has a `type` field (`"file"` or
`"directory"`) and `name` / `relative_path`. File items also expose `size`,
`modified`, `git_status`, `is_binary`, and frecency scores.

>lua
    local r = require('fff').file_search('button', {
      mode             = 'mixed',  -- 'files' (default) | 'directories' | 'mixed'
      max_results      = 50,
      page             = 0,        -- 0-based pagination
      current_file     = nil,      -- path to deprioritize for distance scoring
      max_threads      = 4,
      cwd              = nil,      -- switch indexed root if different (see below)
      wait_for_index_ms = nil,     -- override the default scan wait timeout
    })
    for _, item in ipairs(r.items) do
      print(item.type, item.relative_path)
    end
<


CONTENT_SEARCH(QUERY, OPTS)

Returns a `GrepResult` `{ items, total_matched, total_files_searched,
total_files, filtered_file_count, next_file_offset, regex_fallback_error? }`.
Each match item has `relative_path`, `name`, `line_number`, `col`,
`line_content`, `match_ranges`, plus the same file metadata as `file_search`.

>lua
    local r = require('fff').content_search('TODO', {
      mode                  = 'plain',  -- 'plain' (default) | 'regex' | 'fuzzy'
      max_file_size         = 10 * 1024 * 1024,
      max_matches_per_file  = 100,
      smart_case            = true,
      casing             = nil,      -- 'smart' | 'sensitive' | 'insensitive'; overrides smart_case
      page_size             = 50,
      file_offset           = 0,
      time_budget_ms        = 0,
      enforce_time_budget   = false,    -- also bound zero-match searches
      trim_whitespace       = false,
      classify_definitions  = nil,      -- off unless grep.classify_definitions is set in your config
      cwd                   = nil,      -- switch indexed root if different
      wait_for_index_ms     = nil,      -- override the default scan wait timeout
    })
    for _, m in ipairs(r.items) do
      print(string.format('%s:%d %s', m.relative_path, m.line_number, m.line_content))
    end
<

Both functions accept the same constraint syntax as the UI pickers (e.g.
`git:modified`, `*.rs`, `!test/`, glob patterns).


CWD AND INDEXING

Both `file_search` and `content_search` honour an optional `cwd` field. The
first call to either function lazily initialises the picker at
`config.base_path` (your Neovim cwd by default).

- If `cwd` matches the currently indexed root, the call returns immediately against the existing index.
- If `cwd` differs, the picker is re-indexed at the new root and the call **blocks** (default up to 10 s) until the new picker is installed and its initial scan completes — so callers always get results from the right tree.
- If the index is still warming up after a `change_indexing_directory`, you can pass `wait_for_index_ms = N` to block for up to `N` ms regardless of whether `cwd` triggered the swap. Pass `0` to skip waiting entirely (useful for fire-and-forget calls where partial results are acceptable).
- Invalid or non-existent `cwd` paths return an empty result and emit an error via `vim.notify`.
- A `cwd` inside a linked git worktree shares frecency and query history with the main checkout, so new worktrees don’t start cold.


AUTOCMDS ~

The picker fires `User` autocmds when it opens and closes. `FFFOpen` runs with
the prompt window focused, `FFFClose` runs after every picker window is gone
— so hide global UI, not window-local options of the picker itself:

>lua
    vim.api.nvim_create_autocmd('User', {
      pattern = { 'FFFOpen', 'FFFClose' },
      callback = function(ev) vim.o.showtabline = ev.match == 'FFFOpen' and 0 or 2 end,
    })
<


COMMANDS ~

- `:FFFScan`. Rescan files.
- `:FFFRefreshGit`. Refresh git status.
- `:FFFClearCache [all|frecency|files]`. Clear caches.
- `:FFFHealth`. Health check.
- `:FFFDebug [on|off|toggle]`. Toggle the scoring display.
- `:FFFOpenLog`. Open `~/.local/state/nvim/log/fff.log`.


CONFIGURATION ~

Defaults are sensible. Override only what you care about.

>lua
    require('fff').setup({
      base_path = vim.fn.getcwd(),
      prompt = '🪿 ',
      title = 'FFFiles',
      max_results = 100,
      max_threads = 4,
      lazy_sync = true,
      prompt_vim_mode = false,
      wrap_around = false, -- true to wrap the cursor around when moving past the first/last item
      follow_symlinks = false,
      -- Allow indexing the user's $HOME directory. Enabled by default.
      -- Disable if you strictly sure you don't want this, as it makes whole fff error hard
      enable_home_dir_scanning = true,
      -- Allow indexing a filesystem root (e.g. `/`, `C:\`). Disabled by default
      enable_fs_root_scanning = false,
      layout = {
        height = 0.8,
        width = 0.8,
        prompt_position = 'bottom',   -- or 'top'
        preview_position = 'right',   -- 'left' | 'right' | 'top' | 'bottom'
        preview_size = 0.5,
        -- Border style for the picker windows. Leave unset (nil) to follow the
        -- global `vim.o.winborder`; set it to override fff's borders independently.
        border = nil, -- 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'none'
        -- border = {
        --   { ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' },
        --   { ' ', ' ', ' ', ' ', ' ' },
        -- },

        flex = { size = 130, wrap = 'top' },
        min_list_height = 10, --  do not display anything except the list below this threshold
        show_scrollbar = true,
        show_file_count = true, -- false hides the matched/total file count next to the prompt
        title_position = 'left', -- 'left' | 'center' | 'right'
        filename_position = 'left', -- preview title (file path) alignment: 'left' | 'center' | 'right'
        path_shorten_strategy = 'middle', -- 'middle' | 'middle_number' | 'end' | 'start'
        -- 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right'
        anchor = 'center',
        show_path_first = false, -- true renders results as `path/to/file` instead of `file path/to`
      },
      -- find_files specific rendering
      file_picker = {
        current_file_label = '(current)', -- virtual text marking the buffer the picker was opened from
        fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches, not just the literal query
      },
      preview = {
        enabled = true,
        max_size = 10 * 1024 * 1024,
        chunk_size = 8192,
        binary_file_threshold = 1024,
        imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
        line_numbers = false,
        cursorlineopt = 'both',
        wrap_lines = false,
        filetypes = {
          svg = { wrap_lines = true },
          markdown = { wrap_lines = true },
          text = { wrap_lines = true },
        },
      },
      keymaps = {
        close = '<Esc>',
        select = '<CR>',
        select_split = '<C-s>',
        select_vsplit = '<C-v>',
        select_tab = '<C-t>',
        move_up = { '<Up>', '<C-p>' },
        move_down = { '<Down>', '<C-n>' },
        preview_scroll_up = '<C-u>',
        preview_scroll_down = '<C-d>',
        toggle_debug = '<F2>',
        cycle_grep_modes = '<S-Tab>',
        insert_newline_escape = '<C-CR>',
        -- grep mode only: jump cursor to first match of next/prev file group
        grep_jump_to_next_file = { '<C-A-n>', '<A-Down>' },
        grep_jump_to_prev_file = { '<C-A-p>', '<A-Up>' },
        cycle_previous_query = '<C-Up>',
        cycle_forward_query = '<C-Down>',
        -- unbound by default, wipes the whole input line
        -- clear_query = '<C-u>', -- overrides preview_scroll_up in insert mode
        toggle_select = '<Tab>',
        send_to_quickfix = '<C-q>',
        focus_list = '<leader>l',
        focus_preview = '<leader>p',
      },
      -- extra keymaps for the picker input, keyed by mode, applied over the built-ins
      mappings = {
        -- i = { ['<A-BS>'] = function() vim.api.nvim_input('<C-w>') end },
      },
      frecency = {
        enabled = true,
        db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
      },
      history = {
        enabled = true,
        db_path = vim.fn.stdpath('data') .. '/fff_queries',
        min_combo_count = 3,
        combo_boost_score_multiplier = 100,
      },
      git = {
        status_text_color = false, -- true to color filenames by git status
        -- 'while_open' pauses git status while the picker is closed and refreshes it on open,
        -- 'always' tracks every change in the background, 'manual' updates only on :FFFRefreshGit
        status_updates = 'while_open',
        -- files that participated in the last N configured commits will get scoring bonus
        recency = {
          enabled = true, -- boost files from recent commits of the current branch
          max_commits = 10, -- analyze the last N branch-specific commits
          max_files_per_commit = 50, -- skip bulk commits touching more files than this
        },
      },
      select = {
        -- Return winid to open the chosen file in, or nil to open in the original window
        select_window = function(current_buf, action) --[[ default impl ]] end,
      },
      grep = {
        max_file_size = 10 * 1024 * 1024,
        max_matches_per_file = 100,
        smart_case = true,
        casing = nil, -- 'smart' | 'sensitive' | 'insensitive'; overrides smart_case when set
        time_budget_ms = 150,
        enforce_time_budget = false, -- apply time_budget_ms even before anything matched (off = zero-match queries scan everything)
        modes = { 'plain', 'regex', 'fuzzy', 'definition_only' },
        trim_whitespace = false,
        classify_definitions = true, -- show matching definitions first within each page
        enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text
        location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
      },
      suggestions = {
        enabled = true, -- when a query has no results, look them up in the other mode (files <-> grep) and show as a hint
        grep_time_budget_ms = 50, -- hard cap for the grep hint in file mode; it is skipped until content indexing finishes
      },
      debug = {
        enabled = false, -- show the file info panel next to the preview
        show_scores = false, -- inline scores in the file list
        -- Per-section toggles for the file info panel. Accepts a boolean shorthand
        -- (`show_file_info = true|false`) to flip everything at once. The panel
        -- adapts to width: narrow renders sections vertically, wide renders them
        -- as a two-column grid. Disable a section to also shrink the panel.
        show_file_info = {
          file_info = true, -- size, type, git status, frecency
          score_breakdown = true, -- total + match type, bonuses, modifiers, penalty
          -- modified + accessed timestamps; pass a table to hide individual rows:
          --   timings = { modified = false, accessed = true }
          timings = true,
          full_path = true, -- relative path at the bottom (wraps if too long)
        },
      },
      logging = {
        enabled = true,
        -- logs will be written in a parent directory of this file path in files like
        -- `<stem>+<UTC-timestamp>+<pid>.<ext>`. Run :FFFOpenLog to open current one
        log_file = vim.fn.stdpath('log') .. '/fff.log',
        log_level = 'info',
        retain_runs = 20,
      },
    })
<


LIVE GREP MODES ~

`<S-Tab>` cycles between `plain`, `regex`, `fuzzy`, and `definition_only`. The
list is configurable via `grep.modes`, and single-mode setups hide the
indicator entirely. `definition_only` uses plain substring matching and returns
only matched declarations, regardless of `grep.classify_definitions`. Its
status label uses `hl.grep_definition_active` (default: `DiagnosticOk`).
`regex` matches stay within one line unless the pattern contains `\n`.

`grep.classify_definitions` shows matching definition lines first within each
page, grouped under their file names. Each page uses one ordinary grep search;
classification does not scan ahead for definitions or search the files again
for usages. It defaults to `true`. Set it to `false` to keep file order without
definition grouping. It also accepts the strings `'classify'`,
`'definition_only'`, and `'usage_only'`. A **Definitions** divider separates
the sections when both are on the same page. With a bottom prompt, the order
reverses so definitions stay nearest the prompt. Files with both kinds of
matches appear in both sections, with each match shown once.

Definition filtering occurs before page and per-file result limits. A timed-out
page can be empty; move down (top prompt) or up (bottom prompt) to continue
searching. Context lines can still contain ordinary code.

Definition classification accepts source lines up to 256 bytes, including
indentation and excluding line endings. Longer lines stay ordinary matches and
are excluded from `definition_only`. The limit uses the original source bytes,
regardless of display truncation or whitespace trimming. For multiline regex
matches, it applies to the first physical line.

Supported language families are Rust, Python, Go, JavaScript/TypeScript, Lua,
C/C++/Objective-C, Java/C#/Dart/Groovy, Kotlin, Scala, Swift, Ruby, Elixir,
Zig, PHP, shell, OCaml/ReScript/F#, and Haskell. The byte-keyword rules in
`definition/languages.rs` <crates/fff-core/src/definition/languages.rs> compile
into per-language checkers. Each checker has fixed grammar constants and
returns a boolean without a runtime rule dispatcher. Language detection checks
at most the last eight filename bytes against a compile-time suffix table.
Unknown extensions and extensionless filenames are not classified.
`definition_only` excludes them before sorting, pagination, or content reads,
and preserves explicit file constraints on empty results.

Identifier scanning uses compile-time byte tables for short tokens, then checks
16 bytes at a time with NEON on little-endian ARM64 or SSE2 on x86-64. Other
targets use the scalar table. Loads stay within the source slice, including the
final partial block. The parser uses `memchr` for quotes and bracket groups and
does not allocate.

Files are flagged during scanning. A Rust byte parser uses SIMD-backed
delimiter searches on matched lines before pagination, including common
modifiers, methods, and function assignments. Matches must overlap the declared
name: `user_file` recognizes `fn user_file`, `mod user_file`, `fn
user_file_test`, and `fn test_user_file`, but not `fn other(value: user_file)`.
Implementation and extension blocks stay with ordinary matches. In fuzzy mode,
every matched character must fall inside the declared name; matches spread
across the name and its arguments, receiver, or return type are ordinary
matches. Classification adds no file reads or heap allocations. This is a line
heuristic; multiline declarations and surrounding comment or string context can
be missed.

Per-call override:

>lua
    require('fff').live_grep({ grep = { modes = { 'fuzzy', 'plain' } } })
    require('fff').live_grep({ query = 'search term' }) -- pre-fill
    require('fff').live_grep({ query = 'user_file', grep = { modes = { 'definition_only' } } })
    local page = require('fff').content_search('user_file', { mode = 'definition_only' })
<


CONSTRAINTS ~

Both find and grep accept these tokens to refine a query:

- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
- `test/`. Any deeply nested children of `test/`.
- `!something`, `!test/`, `!git:modified`. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like `!=` or `!==` work.
- `./**/*.{rs,lua}`. Any valid glob, powered by zlob <https://github.com/dmtrKovalenko/zlob>.

Grep-only:

- `*.md`, `*.{c,h}`. Extension filter.
- `src/main.rs`. Grep inside a single file.

Mix freely: `git:modified src/**/*.rs !src/**/mod.rs user controller`.


OPEN IN INVOKING WINDOW ~

By default fff.nvim will try to open a file in the most suitable window, so any
non-file buffers are not affected. You can customize or disable this by
providing:

>lua
    require('fff').setup({
      select = {
        select_window = function(_current_buf, _action) return nil end,
      },
    })
<

Caveat: the chosen file replaces the buffer in the invoking window even if
it’s a non-modifiable / special buftype. `winfixbuf` windows still fall back
to `:split` to avoid `E1513`.


MULTI-SELECT AND QUICKFIX ~

- `<Tab>`. Toggle selection (shows a thick `▊` in the signcolumn).
- `<C-q>`. Send selected files to the quickfix list and close the picker.


GIT STATUS HIGHLIGHTING ~

Sign-column indicators are on by default. To color filename text by git status,
set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help
fff.nvim` for the full list.


FLOAT COLORS ~

The picker maps its float content to `NormalFloat` (via `hl.normal`) and the
border to `FloatBorder`. Default `FloatBorder` links to `NormalFloat`, so
border and content share a background out of the box and the picker reads as a
single popup. Override `hl.normal = 'Normal'` to make the picker blend with the
editor instead.

For finer control, set `hl.winhl` to override the per-window `winhighlight`. It
accepts either a single string applied to every picker window, or a table with
optional `prompt`, `list`, `preview`, and `file_info` keys. Missing keys fall
back to the default built from `hl.normal`, `hl.border`, and `hl.title`.

>lua
    -- Apply the same winhighlight to all picker windows
    hl = { winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title' }

    -- Or override specific windows only
    hl = {
      winhl = {
        prompt  = 'Normal:Pmenu,FloatBorder:FloatBorder',
        list    = 'Normal:NormalFloat,FloatBorder:FloatBorder',
        preview = 'Normal:NormalFloat,FloatBorder:FloatBorder',
      },
    }
<


FILE INFO PANEL ~

Enable with `debug.enabled = true`. The panel sits above the preview and shows
file metadata, score breakdown, timestamps and the full absolute path. It
adapts to the panel width: at narrow widths sections stack vertically (B2), at
wide widths sections render as a two-column grid (H2). Each section can be
disabled individually via `debug.show_file_info`.

Customise the panel via `hl`:

  --------------------------------------------------------------------------
  key                     default           used for
  ----------------------- ----------------- --------------------------------
  file_info_section       Title             section header label

  file_info_separator     FloatBorder       dashes that act as section
                                            borders

  file_info_label         Comment           row labels (Size, Type, Git, …)

  file_info_value         Normal fg         plain values

  file_info_value_dim     NonText           dim values, separators inside
                                            rows

  file_info_size          Number            file size value

  file_info_type          Type              filetype value

  file_info_path          Directory         full path

  file_info_total_score   bold + Number     total score (bold)

  file_info_match_type    bold + Special    match type (bold)

  file_info_score_pos     DiagnosticOk      positive score components

  file_info_score_neg     DiagnosticError   negative score components
  --------------------------------------------------------------------------

FILE FILTERING ~

FFF honours `.gitignore`. For picker-only ignores that do not touch git, add a
sibling `.ignore` file:

>gitignore
    *.md
    docs/archive/**/*.md
<

Run `:FFFScan` to force a rescan.


TROUBLESHOOTING ~

- `:FFFHealth` verifies picker init, optional dependencies, and DB connectivity.
- `:FFFOpenLog` opens the current session’s log file.
- Historical log files are stored near the main log file `<state>/log/fff+<UTC-timestamp>+<pid>.log` (up to 20 files)
- For a crash backtrace, run `lldb -- nvim` or `gdb -- nvim` and reproduce
- fff keeps its allocator off transparent huge pages to keep the index RSS low (~15-20% on large repos); set `MIMALLOC_ALLOW_LARGE_OS_PAGES=2` before starting Neovim to trade that memory back for a slightly faster initial index build

Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>

vim:tw=78:ts=8:noet:ft=help:norl:
