Compare commits

...

19 Commits

Author SHA1 Message Date
Dmitriy Kovalenko b667de49fb fix: Correctly classify all the binaries files
closes https://github.com/dmtrKovalenko/fff/issues/546

Removes all the heuristics across all the binary size detection, now we
scan every single byte up to content searchable cap of fff to detct if
the file is not a text

+ some fff lua size guard
2026-06-01 20:06:10 -07:00
Gustav the Bot ba8907f683 fix: treat .pkl and .pickle as text (not binary) (#552)
Pkl (pkl-lang.org) is a configuration language with .pkl files. Was
classified as binary alongside Python pickle (.pickle). Remove both —
pkl is plain text; legacy pickle is rare and harmless to preview.

Closes #550

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-06-01 13:58:55 -07:00
Gabe Shahbazian a0c66d9bb1 feat(nvim): q to close pickers in normal mode (#543) 2026-06-01 13:00:18 -07:00
Gabe Shahbazian 785464eb9e feat(nvim): left click to select rows in picker (#525)
* left click to select rows in picker

single click moves the cursor to the clicked row
(updating preview and status)

double click opens the file with the default action

* stylua
2026-06-01 12:56:01 -07:00
Gustav the Bot ae6d9e7bfe feat(grep): add configurable location_format for grep results (#554)
Closes #549

Adds grep.location_format option (vim printf-style) controlling the
line/column prefix rendered before each grep match. Defaults to
':%d:%d' to preserve current behavior. Set to ':%d' for line-only.

Falls back to default if user format errors at runtime.

Co-authored-by: Dmitriy Kovalneko<dmitriy@iusevimbtw.com>
2026-06-01 12:55:13 -07:00
Gustav the Bot 1055c05a89 fix(windows pwsl install script): add default PathScope inside param block(#551) (#553)
ValidateSet validates the parameter at bind time, before the script
body executes. With no default in the param block, an unbound
$PathScope is the empty string and fails validation under
irm ... | iex.

Move the env-var fallback into the param default so the value is
already valid when ValidateSet runs.

Closes #551

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-06-01 12:51:01 -07:00
Dmitriy Kovalenko 5e53b6e8cf feat: Optimize repeatable greps for large files (#533)
* feat: Optimize repeatable greps for large files

* fix: gate get_cached_content for windows and fix typo

- Wrap unix get_cached_content in cfg(not(target_os = windows)) so it
  doesn't reference field absent on windows builds.
- Fix imperically -> empirically (typos CI).
- cargo fmt.

* fix: silence unused mmap_slot warning on windows

* fix: per-OS FRESH_MMAP_THRESHOLD (macOS 1MiB, Linux 256KiB, Windows 0)

* fix: silence dead_code for FRESH_MMAP_THRESHOLD on windows

Value is 0 and unused (mmap path is gated off on windows), but
-D warnings + -D dead-code in CI fails the build.

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-05-29 16:56:04 -07:00
Gustav the Bot 2853edf3f2 fix: defer file open past picker close to preserve folds (#538) (#539)
* fix: defer file open past picker close to preserve folds (#538)

closes #538

* test: flush schedule in picker_dir_resolution after select('edit') (#538)

---------

Co-authored-by: gustav-fff <286169375+gustav-fff@users.noreply.github.com>
2026-05-29 14:06:47 -07:00
Dmitriy Kovalenko 030a583b37 chore: release 0.8.4
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-05-25 11:50:08 -07:00
Dmitriy Kovalenko ca6eae5f3c chore(ci): Migrate to npm trusted publishing (#531) 2026-05-25 11:46:34 -07:00
Dmitriy Kovalenko 21a3078f69 chore: release 0.8.3
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-05-25 09:22:18 -07:00
Dmitriy Kovalenko c23ccb39d7 fix: Improve binary detection (#529)
closes https://github.com/dmtrKovalenko/fff/issues/524

Originally we used to scan first 512 bytes which is not enough, here I
am adding more files for initial extension triage, making it possible to
unmark file as binary and making binary scanning more coherent which
should support 99.9999999999% cases not breaking performance really hard
2026-05-25 09:15:53 -07:00
Ofek Lev f26fe14fed chore(readme): Fix incorrect link (#527) 2026-05-25 08:53:43 -07:00
Dmitriy Kovalenko 5c3a615e76 chore: release 0.8.2
Prebuild / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim aarch64-linux-android (push) Has been cancelled
Prebuild / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build Neovim x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI aarch64-linux-android (push) Has been cancelled
Prebuild / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build C FFI x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP aarch64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP aarch64-unknown-linux-musl (push) Has been cancelled
Prebuild / Build MCP x86_64-apple-darwin (push) Has been cancelled
Prebuild / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-gnu (push) Has been cancelled
Prebuild / Build MCP x86_64-unknown-linux-musl (push) Has been cancelled
Prebuild / Release (push) Has been cancelled
Prebuild / Publish Rust crates (push) Has been cancelled
Prebuild / Publish npm packages (push) Has been cancelled
2026-05-23 19:28:56 -07:00
Gustav the Bot cf3dcf6d0b fix: set is_warmup_completed true when warmup/indexing disabled (#451) (#502)
Refs #451

When both enable_mmap_cache and enable_content_indexing are false,
run_post_scan never executes (guarded by config.warmup || config.content_indexing).
This left bigram_index as None, so get_scan_progress().is_warmup_complete
returned false forever.

Added warmup_complete flag to ScanSignals. Set immediately after post-scan
finishes OR right after walking completes when no post-scan work needed.
get_scan_progress now reads warmup_complete instead of checking bigram_index
presence.

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-05-22 12:27:22 -07:00
Gustav the Bot 76dbd5fd47 fix: Prevent overflow arena reallocation during rayon workers (#476) (#499)
Refs #476

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-05-22 11:03:38 -07:00
Gustav the Bot c84db53454 fix: Upgrade rmcp to 1.7.0 for Content-Length framed stdio (#388) (#505)
Refs #388

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-05-22 11:02:34 -07:00
Gustav the Bot 99f5160e1c feat(neovim): winhighlight overrides for picker windows (#511)
Adds optional `hl.winhl` config that controls the picker windows'
`winhighlight`. Accepts either a single string applied to all picker
windows, or a table with optional `prompt`, `list`, `preview`, and
`file_info` keys. Missing keys fall back to the default composed from
`hl.normal`, `hl.border`, and `hl.title`.

Refs #140

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-05-22 10:07:43 -07:00
Dmitriy Kovalenko b5a7967fd2 feat(lua): Add programmatic api for lua (#514)
* feat(lua): Add programmatic api for lua

* fix(lua): Resolve CI lint errors

- main.lua: drop duplicate `open_file_under_cursor` impl,
  superseded by the path-resolving variant
- picker_ui.lua: replace undefined `canonicalize_fff_path`
  with `utils.canonicalize_picker_path`
- programmatic_search_spec.lua: cast `hit` to non-nil after
  the `assert.is_not_nil` so lua-ls stops flagging the
  follow-up field accesses
2026-05-22 09:17:30 -07:00
50 changed files with 2042 additions and 313 deletions
+11
View File
@@ -96,6 +96,17 @@ jobs:
shell: bash
run: make test-lua
- name: Dump fff trace log on failure
if: failure()
shell: bash
run: |
echo "=== fff-test.log ==="
if [ -f fff-test.log ]; then
cat fff-test.log
else
echo "(no log file produced)"
fi
- name: Run version resolution tests
shell: bash
run: make test-version
+16 -18
View File
@@ -2,7 +2,7 @@ name: Prebuild
on:
push:
branches: [main, fix/download-version]
branches: [main, fix/use-trusted-publishing]
tags:
- "v*"
pull_request:
@@ -16,6 +16,7 @@ jobs:
runs-on: ${{ matrix.os }}
permissions:
contents: read
id-token: write
strategy:
matrix:
include:
@@ -362,7 +363,7 @@ jobs:
name: Release
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: write
steps:
@@ -469,10 +470,14 @@ jobs:
name: Publish Rust crates
needs: [build-nvim, build-c, build-mcp]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- name: Install Lua
uses: leafo/gh-actions-lua@v12
@@ -489,16 +494,17 @@ jobs:
- name: Publish crates
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: make publish-crates V="${{ steps.version.outputs.version }}"
npm-publish:
name: Publish npm packages
needs: [build-c]
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/download-version' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
@@ -522,8 +528,6 @@ jobs:
path: ./npm-packages
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
@@ -536,14 +540,12 @@ jobs:
make set-npm-version PKG="$pkg_dir" VERSION="$VERSION"
cd "$pkg_dir"
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
npm publish --tag "$TAG" --access public --provenance
cd -
fi
done
- name: Publish bun package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
@@ -552,11 +554,9 @@ jobs:
make set-npm-version PKG=packages/fff-bun VERSION="$VERSION"
cd packages/fff-bun
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
npm publish --tag "$TAG" --access public --provenance
- name: Publish Node.js package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
@@ -567,11 +567,9 @@ jobs:
cd packages/fff-node
npm install
npm run build
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-node (may already exist)"
npm publish --tag "$TAG" --access public --provenance
- name: Publish pi-fff package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.npm_tag }}"
@@ -580,4 +578,4 @@ jobs:
make set-npm-version PKG=packages/pi-fff VERSION="$VERSION"
cd packages/pi-fff
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/pi-fff (may already exist)"
npm publish --tag "$TAG" --access public --provenance
+3
View File
@@ -27,3 +27,6 @@ scripts/benchmark-results/
# Instruments traces
*.trace/
# Test logs
fff-test.log
+2 -2
View File
@@ -5,14 +5,14 @@
},
"workspace": {
"library": [
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library"
],
"checkThirdParty": false
},
"diagnostics": {
"globals": ["vim"],
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
Generated
+10 -10
View File
@@ -633,7 +633,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fff-c"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"fff-query-parser",
"fff-search",
@@ -643,7 +643,7 @@ dependencies = [
[[package]]
name = "fff-grep"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"bstr",
"memchr",
@@ -651,7 +651,7 @@ dependencies = [
[[package]]
name = "fff-mcp"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"clap",
"fff-query-parser",
@@ -682,7 +682,7 @@ dependencies = [
[[package]]
name = "fff-nvim"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"ahash",
"chrono",
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "fff-query-parser"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"criterion",
"zlob",
@@ -709,7 +709,7 @@ dependencies = [
[[package]]
name = "fff-search"
version = "0.8.1"
version = "0.8.4"
dependencies = [
"ahash",
"aho-corasick",
@@ -2032,9 +2032,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rmcp"
version = "1.2.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f"
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
dependencies = [
"async-trait",
"base64",
@@ -2054,9 +2054,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "1.2.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e"
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
dependencies = [
"darling",
"proc-macro2",
+2 -2
View File
@@ -10,8 +10,8 @@ members = [
resolver = "2"
[workspace.dependencies]
fff-grep = { version = "0.8.1", path = "crates/fff-grep" }
fff-query-parser = { version = "0.8.1", path = "crates/fff-query-parser", default-features = false }
fff-grep = { version = "0.8.4", path = "crates/fff-grep" }
fff-query-parser = { version = "0.8.4", path = "crates/fff-query-parser", default-features = false }
# Shared dependencies
ahash = "0.8"
+74 -1
View File
@@ -176,8 +176,64 @@ 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,
page_size = 50,
file_offset = 0,
time_budget_ms = 0,
trim_whitespace = false,
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`.
### Commands
- `:FFFScan`. Rescan files.
@@ -265,6 +321,7 @@ require('fff').setup({
time_budget_ms = 150,
modes = { 'plain', 'regex', 'fuzzy' },
trim_whitespace = false,
location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
},
debug = {
enabled = false, -- show the file info panel next to the preview
@@ -330,6 +387,22 @@ Sign-column indicators are on by default. To color filename text by git status,
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
@@ -540,7 +613,7 @@ Algorithm for fuzzy matching is much more comprehensive than fzf's algorithm it
### What the core actually does
- **Frecency-ranked fuzzy matching.** Every indexed file carries an access score and a modification score. Searches rank files you have opened recently and frequently above cold results. This is the same idea as VS Code's recently-opened list, but applied to every search result, not just a sidebar.
- **Typo-resistant matching for both paths and content.** Smith-Waterman fuzzy scoring is available on the grep path; path search uses SIMD-accelerated fuzzy matching (via the [`frizbee`](https://github.com/saghm/frizbee)-derived core) that survives dropped characters and reorderings.
- **Typo-resistant matching for both paths and content.** Smith-Waterman fuzzy scoring is available on the grep path; path search uses SIMD-accelerated fuzzy matching (via the [`frizbee`](https://github.com/saghen/frizbee)-derived core) that survives dropped characters and reorderings.
- **Content grep with three modes.** Plain literal (SIMD memmem), regex (the Rust `regex` crate), and fuzzy (Smith-Waterman per line). Auto-detects which mode to use from the pattern, falls back to fuzzy when a plain search returns zero hits.
- **Multi-pattern OR search.** SIMD Aho-Corasick for "find any of these 20 identifiers at once", which is faster than regex alternation and a lot faster than 20 separate ripgrep runs.
- **Background file watcher.** The index updates as files change. You never pay for a rescan on the hot path.
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-c"
version = "0.8.1"
version = "0.8.4"
edition = "2024"
description = "Raw C api of FFF file finder"
license = "MIT"
@@ -15,6 +15,6 @@ zlob = ["fff/zlob"]
[dependencies]
git2.workspace = true
fff = { package = "fff-search", path = "../fff-core" , version = "0.8.1" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.8.1" }
fff = { package = "fff-search", path = "../fff-core" , version = "0.8.4" }
fff-query-parser = { path = "../fff-query-parser" , version = "0.8.4" }
serde_json = "1.0"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-search"
version = "0.8.1"
version = "0.8.4"
edition = "2024"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>"]
@@ -39,14 +39,14 @@ rayon = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
fff-query-parser = { workspace = true }
fff-query-parser = { workspace = true , version = "0.8.2" }
blake3 = { workspace = true }
dirs = { workspace = true }
libc = "0.2"
git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
fff-grep = { workspace = true }
fff-grep = { workspace = true , version = "0.8.2" }
aho-corasick = "1"
memchr = "2"
heed = { workspace = true }
+6 -5
View File
@@ -1,5 +1,6 @@
use crate::constants::MAX_OVERFLOW_FILES;
use crate::error::Error;
use crate::file_picker::{FFFMode, MAX_OVERFLOW_FILES};
use crate::file_picker::FFFMode;
use crate::git::GitStatusCache;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::sort_buffer::sort_with_buffer;
@@ -25,7 +26,6 @@ pub struct BackgroundWatcher {
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
const MAX_PATHS_THRESHOLD: usize = 1024;
/// On macOS, each `watch()` call creates a separate FSEventStream. When the
/// number of directories exceeds this threshold we fall back to a single
/// recursive watch to avoid exhausting the per-process stream limit.
@@ -497,10 +497,11 @@ fn handle_debounced_events(
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
if affected_paths_count > MAX_OVERFLOW_FILES {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
?affected_paths_count,
max = MAX_OVERFLOW_FILES,
"Too many affected paths in a single batch, triggering full rescan",
);
need_full_rescan = true;
+37 -14
View File
@@ -1,3 +1,4 @@
use crate::constants::MAX_INDEXABLE_FILE_SIZE;
use ahash::AHashMap;
use rayon::iter::{IndexedParallelIterator, ParallelIterator};
use rayon::slice::ParallelSlice;
@@ -5,6 +6,8 @@ use std::cell::UnsafeCell;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering};
use crate::{FileItem, constants};
/// Maximum number of distinct bigrams tracked in the inverted index.
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
/// We cap at 5000 to cover all printable bigrams with margin.
@@ -107,14 +110,8 @@ impl BigramIndexBuilder {
&slab[start..start + self.words]
}
// `pub` (via `#[doc(hidden)]`) only so the criterion bench can drive
// `add_file_content` directly. External consumers should use
// `build_bigram_index` instead.
///
/// SAFETY: concurrent callers must partition `file_idx` by
/// word-aligned ranges so that `file_idx / 64` never collides across
/// threads. The `file_picker::build_bigram_index` driver enforces
/// this via `par_chunks` with a word-aligned chunk size.
// `pub` (via `#[doc(hidden)]`) only for benchmarking
// External consumers should use `build_bigram_index` instead.
#[doc(hidden)]
pub fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
if content.len() < 2 {
@@ -596,7 +593,6 @@ impl BigramOverlay {
}
}
pub(crate) const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
const BIGRAM_CHUNK_FILES: usize = 4 * 64;
/// Sparse-column cutoff for the skip-1 sub-index. Rare skip columns add
@@ -616,7 +612,7 @@ thread_local! {
/// mmap should only be used by the locked version of grep which absolutely minimizes any riscs
#[inline]
fn read_bigram_chunk<'a>(
file: &crate::types::FileItem,
file: &FileItem,
base_fd: libc::c_int,
base_path: &std::path::Path,
arena: crate::simd_path::ArenaPtr,
@@ -630,10 +626,7 @@ fn read_bigram_chunk<'a>(
}
let data = &buf[..filled];
if crate::file_picker::detect_binary_content(data) {
file.set_binary(true);
return None;
}
Some(data)
}
@@ -681,6 +674,14 @@ pub(crate) fn build_bigram_index(
&mut buf[..],
&mut path_buf,
) {
// we have to manually ensure that every byte is a valid text byte to
// perform this we have to scan every file, first 512 bytes is not enough
// so basically we rely on the fact that first 2MB will always contain
// an invalid text sequence if this is not a binary file.
//
// Need to find a better way to do this.
file.set_binary(crate::types::detect_binary_content(content));
builder.add_file_content(&skip_builder, file_idx, content);
}
});
@@ -705,6 +706,28 @@ pub(crate) fn build_bigram_index(
index
}
#[tracing::instrument(skip_all, name = "Sniffing Large Files Binary", level = tracing::Level::DEBUG)]
pub(crate) fn sniff_binary_for_non_indexable(
files: &[FileItem],
base_path: &std::path::Path,
arena: crate::simd_path::ArenaPtr,
) {
// Non-indexable files are few in a typical repo, so a serial pass with a
// single reused chunk buffer beats spinning up the thread pool.
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let mut chunk = vec![0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
for file in files {
// check only the files that we are able to grep
if file.size == 0 || file.size > constants::MAX_FFFILE_SIZE {
continue;
}
let abs = file.write_absolute_path(arena, base_path, &mut path_buf);
file.detect_binary_per_byte(abs, &mut chunk);
}
}
/// Open the base directory for the `openat` fast path. Returns `-1` on
/// failure — callers interpret a negative fd as "fall back to absolute
/// paths".
+39
View File
@@ -0,0 +1,39 @@
/// Largest file whose full content fff will touch: the default grep read cap
/// (`GrepSearchOptions::max_file_size`) and the content-cache mmap cap
/// (`ContentCacheBudget::max_file_size`). Binary detection also streams up to
/// this far so nothing grep would read is left unclassified.
pub const MAX_FFFILE_SIZE: u64 = 10 * 1024 * 1024;
/// Upper bound on a file the bigram builder will build, if the file is very large there is a
/// big probability it will only bloat the available bigrams and will anyway pop ut from the prefilter
pub const MAX_INDEXABLE_FILE_SIZE: usize = 2 * 1024 * 1024;
/// Total bytes the persistent content mmap cache may hold for a small repo.
pub const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
/// Files below one page waste the remainder when mmapped, so the cache skips
/// them and falls back to chunked reads. Unused on Windows (no content cache).
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
pub const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
pub const MMAP_THRESHOLD: u64 = 4 * 1024;
/// Capacity reserved for files the watcher discovers after the initial scan;
/// exceeding it forces a full rescan.
pub const MAX_OVERFLOW_FILES: usize = 1024;
/// Fresh-mmap threshold: files at or above this size get mmapped directly on
/// cache miss instead of chunked reads into Vec. Empirically tuned per-platform.
/// Only referenced on Unix; Windows uses the `std::fs::read` fallback so this
/// constant is gated to non-Windows targets to keep `-D unused-imports` happy.
#[cfg(target_os = "macos")]
pub const FRESH_MMAP_THRESHOLD: u64 = 1024 * 1024;
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub const FRESH_MMAP_THRESHOLD: u64 = 256 * 1024;
// we do not support 32kb path limit on windows
#[cfg(target_os = "windows")]
pub const PATH_BUF_SIZE: usize = 4096;
#[cfg(not(target_os = "windows"))]
pub const PATH_BUF_SIZE: usize = libc::PATH_MAX as usize;
+54 -44
View File
@@ -33,6 +33,7 @@
use crate::FFFStringStorage;
use crate::background_watcher::{BackgroundWatcher, is_git_file};
use crate::bigram_filter::{BigramFilter, BigramOverlay};
use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
@@ -42,7 +43,7 @@ use crate::query_tracker::QueryTracker;
use crate::scan::{ScanConfig, ScanJob, ScanSignals};
use crate::score::fuzzy_match_and_score_files;
use crate::shared::{SharedFilePicker, SharedFrecency};
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
use crate::simd_path::ArenaPtr;
use crate::stable_vec::StableVec;
use crate::types::{
ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
@@ -62,11 +63,6 @@ use std::thread::JoinHandle;
use std::time::SystemTime;
use tracing::{Level, debug, error, info, warn};
/// Max overflow files before the watcher triggers a full rescan.
/// `walk_filesystem` reserves this much extra capacity so the Vec never
/// reallocates while raw pointers are held during post-scan.
pub(crate) const MAX_OVERFLOW_FILES: usize = 1024;
/// Dedicated thread pool for background work (scan, warmup, bigram build).
/// Uses fewer threads than the global rayon pool so Neovim's event loop
/// and search queries can still get CPU time.
@@ -812,8 +808,6 @@ impl FilePicker {
self.sync_data = sync;
// Recalculate cache budget based on actual file count (unless
// the caller provided an explicit budget via FilePickerOptions).
if !self.has_explicit_cache_budget {
let file_count = self.sync_data.files().len();
self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
@@ -821,14 +815,18 @@ impl FilePicker {
self.cache_budget.reset();
}
// Apply git status synchronously.
if let Some(handle) = git_handle
&& let Ok(Some(git_cache)) = handle.join()
{
let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
let arena = self.arena_base_ptr();
for file in self.sync_data.files.iter_mut() {
file.git_status =
git_cache.lookup_status(&file.absolute_path(arena, &self.base_path));
file.git_status = git_cache.lookup_status(file.write_absolute_path(
arena,
&self.base_path,
&mut path_buf,
));
}
}
@@ -864,6 +862,7 @@ impl FilePicker {
/// The query should be parsed using [`FFFQuery`]::parse() before calling
/// this function. If a [`QueryTracker`] is provided, the search will
/// automatically look up the last selected file for this query and boost it
#[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
pub fn fuzzy_search<'q>(
&self,
query: &'q FFFQuery<'q>,
@@ -1210,11 +1209,13 @@ impl FilePicker {
pub fn get_scan_progress(&self) -> ScanProgress {
let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
let is_scanning = self.signals.scanning.load(Ordering::Relaxed);
ScanProgress {
scanned_files_count: scanned_count,
is_scanning,
is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed),
is_warmup_complete: self.sync_data.bigram_index.is_some(),
is_warmup_complete: !self.enable_content_indexing
|| self.sync_data.bigram_index.is_some(),
}
}
@@ -1271,9 +1272,9 @@ impl FilePicker {
base_count: self.sync_data.base_count,
indexable_count: self.sync_data.indexable_count,
base_path: self.base_path.clone(),
budget: Arc::clone(&self.cache_budget),
cancelled: Arc::clone(&self.signals.cancelled),
post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
_budget: Arc::clone(&self.cache_budget),
})
}
@@ -1419,7 +1420,14 @@ impl FilePicker {
file.update_metadata(&self.cache_budget, modified_time, Some(size));
// only base-region entries participate in the bigram overlay
// Re-classify binary status from current content (chunked, fixed
// buffer). Already-binary files are left alone.
if !file.is_binary() {
let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
file.detect_binary_per_byte(path, &mut chunk);
}
// Indexable base-region files feed fresh content to the bigram overlay.
if matches!(slot, FileSlot::Base(_))
&& let Some(ref overlay) = overlay
{
@@ -1449,12 +1457,10 @@ impl FilePicker {
} else if let Ok(c) = crate::path_utils::canonicalize(path) {
Some(c)
} else {
let parent = path.parent()?;
let file_name = path.file_name()?;
let mut p = crate::path_utils::canonicalize(parent).ok()?;
p.push(file_name);
Some(p)
tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
return None;
};
#[cfg(windows)]
let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
#[cfg(not(windows))]
@@ -1463,14 +1469,20 @@ impl FilePicker {
let (mut file_item, rel_path) =
FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
// Lazily create the shared overflow builder if not exists yet
let builder = self
.sync_data
.overflow_builder
.get_or_insert_with(|| crate::simd_path::ChunkedPathStoreBuilder::new(64));
// we have to perform manual classification for every new file this will be
// batched during the scan, this is the path when the file is ad-hoc added to the sync
file_item.detect_binary_per_byte(
path_for_index,
// inline chunk buf
&mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
);
let chunked_path = builder.add_file_immediate(&rel_path, file_item.path.filename_offset);
file_item.set_path(chunked_path);
let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
// we know that overflow would never create more files during the file
crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
});
file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
file_item.set_overflow(true);
if !self.sync_data.files.push(file_item) {
@@ -1657,7 +1669,8 @@ pub(crate) struct PostScanUnsafeSnapshot {
pub files: StableVec<FileItem>,
pub dirs: StableVec<crate::types::DirItem>,
pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
pub budget: Arc<crate::types::ContentCacheBudget>,
// TODO figure this out
pub _budget: Arc<crate::types::ContentCacheBudget>,
pub base_count: usize,
pub indexable_count: usize,
pub base_path: PathBuf,
@@ -1846,7 +1859,7 @@ impl FileSync {
let is_indexable = |f: &FileItem| {
!f.is_binary()
&& f.size > 0
&& f.size <= crate::bigram_filter::MAX_INDEXABLE_FILE_SIZE as u64
&& f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
};
BACKGROUND_THREAD_POOL.install(|| {
@@ -1986,7 +1999,12 @@ pub fn is_known_binary_extension(path: &Path) -> bool {
ext,
// Images
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "tif" | "avif" |
"heic" | "psd" | "icns" | "cur" | "raw" | "cr2" | "nef" | "dng" | "tga" |
"heic" | "heif" | "jxl" | "jp2" | "j2k" | "psd" | "icns" | "cur" | "cr2" |
"nef" | "dng" | "tga" |
// GPU / VFX texture formats
"rgbe" | "hdr" | "exr" | "dds" | "ktx" | "ktx2" | "pvr" | "astc" |
// Adobe Illustrator (PDF wrapper) / Apple webarchive / MIME HTML archive
"ai" | "webarchive" | "mhtml" |
// Video/Audio
"mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" |
"aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" |
@@ -2010,32 +2028,24 @@ pub fn is_known_binary_extension(path: &Path) -> bool {
// Compiled/Runtime
"class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" |
// OCaml / Swift / Objective-C build artefacts
"cmi" | "cmt" | "cmti" | "cmx" | "cof" | "cop" | "nib" |
"cmi" | "cmt" | "cmti" | "cmx" | "nib" |
"swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" |
// ML/Data Science
"npy" | "npz" | "pkl" | "pickle" | "h5" | "hdf5" | "pt" | "pth" | "onnx" |
"safetensors" | "tfrecord" |
"npy" | "npz" | "h5" | "hdf5" | "pt" | "onnx" |
"safetensors" | "tfrecord" | "tflite" | "gguf" | "ggml" | "joblib" |
// 3D/Game assets
"glb" | "fbx" | "blend" | "blp" |
// Compressed-text formats (gzip/binary on disk)
"dia" | "tfx" | "flm" | "bcmap" | "journal" |
"glb" | "blend" | "blp" |
// Gzipped-XML / binary maps
"dia" | "bcmap" |
// Protobuf wire format
"pb" |
// Data/serialized
"parquet" | "arrow" |
// IDE/OS metadata
"DS_Store" | "suo"
"suo"
)
}
/// Detect binary content by checking for NUL bytes in the first 512 bytes.
/// Called lazily when file content is first loaded, not during initial scan.
#[inline]
pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
let check_len = content.len().min(512);
content[..check_len].contains(&0)
}
/// Length of the longest shared directory prefix of two relative dir
/// paths (without a trailing separator), measured as the number of bytes
/// up to and including the last shared separator — plus the full shorter
+27 -15
View File
@@ -11,7 +11,7 @@ use crate::{
constraints::apply_constraints,
extract_bigrams,
sort_buffer::sort_with_buffer,
types::{ContentCacheBudget, FileItem, FileSliceExt},
types::{ContentCacheBudget, FileItem, FileSliceExt, MmapSlot},
};
use aho_corasick::AhoCorasick;
pub use fff_grep::{
@@ -333,6 +333,8 @@ pub struct GrepResult<'a> {
pub regex_fallback_error: Option<String>,
}
pub use crate::constants::MAX_FFFILE_SIZE;
/// Options for grep search.
#[derive(Debug, Clone)]
pub struct GrepSearchOptions {
@@ -371,7 +373,7 @@ pub struct GrepSearchOptions {
impl Default for GrepSearchOptions {
fn default() -> Self {
Self {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
@@ -1243,16 +1245,17 @@ where
for chunk in files_to_search.chunks(chunk_size) {
let chunk_offset = files_consumed;
// Parallel phase: search all files in this chunk concurrently.
// Within a chunk every file is visited (no gaps), so pagination
// offsets remain correct across chunk boundaries.
let chunk_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = chunk
.par_iter()
.enumerate()
.map_init(
// allocatge a single reusable buffer per thread
|| Vec::with_capacity(64 * 1024),
|buf, (local_idx, file)| {
// Per-thread scratch: a reusable read buffer for small files
// and an mmap slot for cache-miss large files (≥ FRESH_MMAP_THRESHOLD).
|| {
tracing::info!("LMAOTHREAD");
(Vec::with_capacity(64 * 1024), MmapSlot::default())
},
|(buf, mmap_slot), (local_idx, file)| {
if ctx.abort_signal.load(Ordering::Relaxed) {
budget_exceeded.store(true, Ordering::Relaxed);
return None;
@@ -1268,6 +1271,7 @@ where
let content = file.get_content_for_search(
buf,
mmap_slot,
ctx.arena_for_file(file),
ctx.base_path,
ctx.budget,
@@ -1635,14 +1639,21 @@ fn fuzzy_grep_search<'a>(
let budget_exceeded = AtomicBool::new(false);
let max_matches_per_file = options.max_matches_per_file;
// Parallel phase with `map_init`: each rayon worker thread clones the
// matcher once and gets a reusable read buffer. The buffer avoids
// mmap/munmap syscalls for non-cached files.
// matcher once and gets a reusable read buffer + mmap slot. Buffer holds
// small files, slot holds fresh mmap for cache-miss files
// ≥ FRESH_MMAP_THRESHOLD.
let per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = files_to_search
.par_iter()
.enumerate()
.map_init(
|| (matcher.clone(), Vec::with_capacity(64 * 1024)),
|(matcher, buf), (idx, file)| {
|| {
(
matcher.clone(),
Vec::with_capacity(64 * 1024),
MmapSlot::default(),
)
},
|(matcher, buf, mmap_slot), (idx, file)| {
if abort_signal.load(Ordering::Relaxed) {
budget_exceeded.store(true, Ordering::Relaxed);
return None;
@@ -1660,7 +1671,8 @@ fn fuzzy_grep_search<'a>(
} else {
arena
};
let file_bytes = file.get_content_for_search(buf, file_arena, base_path, budget)?;
let file_bytes =
file.get_content_for_search(buf, mmap_slot, file_arena, base_path, budget)?;
// File-level prefilter: check if enough distinct needle chars
// exist anywhere in the file bytes. Uses memchr for speed.
@@ -2423,7 +2435,7 @@ mod tests {
let arena = picker.arena_base_ptr();
let options = super::GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
@@ -2607,7 +2619,7 @@ mod tests {
// (a, b, c in base + f, g, h in overflow).
let query = super::parse_grep_query("unicorn");
let options = super::GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_file_size: MAX_FFFILE_SIZE,
max_matches_per_file: 0,
smart_case: true,
file_offset: 0,
+1
View File
@@ -98,6 +98,7 @@ mod scan;
#[doc(hidden)]
pub mod bigram_filter;
pub mod bigram_query;
pub mod constants;
mod constraints;
mod error;
mod score;
+28 -10
View File
@@ -7,7 +7,7 @@ use tracing::{error, info};
use crate::FileSync;
use crate::background_watcher::BackgroundWatcher;
use crate::bigram_filter::build_bigram_index;
use crate::bigram_filter::{build_bigram_index, sniff_binary_for_non_indexable};
use crate::error::Error;
use crate::file_picker::{BACKGROUND_THREAD_POOL, FFFMode};
use crate::git::GitStatusCache;
@@ -211,8 +211,9 @@ impl ScanJob {
// 3. Post-scan warmup + bigram build — runs in parallel with the
// git-status thread to overlap the two expensive phases.
if (config.warmup || config.content_indexing)
&& !signals.cancelled.load(Ordering::Acquire)
// Always runs (even with both flags off) so binary-content files
// with unknown extensions get reclassified before user search hits.
if !signals.cancelled.load(Ordering::Acquire)
&& let Some(snap) = snapshot.as_ref()
{
Self::run_post_scan(&shared_picker, &signals, &config, snap);
@@ -289,20 +290,23 @@ impl ScanJob {
config: &ScanConfig,
unsafe_snapshot: &crate::file_picker::PostScanUnsafeSnapshot,
) {
let arena = unsafe_snapshot
.arena
let Some(arena) = unsafe_snapshot
.arena // we are never touching overlays so this arena is always correct
.as_ref()
.map(|s| s.as_arena_ptr())
.unwrap_or(ArenaPtr::null());
let _budget: &ContentCacheBudget = &unsafe_snapshot.budget;
let files: &[crate::types::FileItem] = &unsafe_snapshot.files[..unsafe_snapshot.base_count];
else {
tracing::error!("Failed to run post scan: arena is invalid");
return;
};
let files: &[crate::types::FileItem] = &unsafe_snapshot.files[..unsafe_snapshot.base_count];
if signals.cancelled.load(Ordering::Acquire) {
return;
}
if config.content_indexing {
let indexable_files = &files[..unsafe_snapshot.indexable_count.min(files.len())];
let indexable_count = unsafe_snapshot.indexable_count.min(files.len());
let (indexable_files, non_indexable_files) = files.split_at(indexable_count);
let index = build_bigram_index(indexable_files, &unsafe_snapshot.base_path, arena);
if let Ok(mut guard) = shared_picker.write()
@@ -310,9 +314,23 @@ impl ScanJob {
{
picker.set_bigram_index(index);
}
// Bigram only sniffs files <= MAX_INDEXABLE_FILE_SIZE; large
// unknown-extension binaries slip past it and would otherwise be
// grep-able as text. Cheap header sniff catches those.
if !signals.cancelled.load(Ordering::Acquire) {
sniff_binary_for_non_indexable(
non_indexable_files,
&unsafe_snapshot.base_path,
arena,
);
}
} else {
// this potentially a long running as we are not parallelizing it but it's okay
sniff_binary_for_non_indexable(files, &unsafe_snapshot.base_path, arena);
}
// Skipped as potentially unsafe - figure this out later
// TODO Skipped as potentially unsafe - figure this out later
// if config.warmup && !signals.cancelled.load(Ordering::Acquire) {
// warmup_mmaps(files, budget, &unsafe_snapshot.base_path, arena);
// }
+5 -4
View File
@@ -60,7 +60,7 @@ impl std::fmt::Debug for SimdChunk {
}
}
pub const PATH_BUF_SIZE: usize = 4096;
pub use crate::constants::PATH_BUF_SIZE;
/// Indices into a shared `SimdChunk` arena representing a file path.
///
@@ -295,10 +295,11 @@ pub(crate) struct ChunkedPathStoreBuilder {
impl ChunkedPathStoreBuilder {
pub fn new(estimated_files: usize) -> Self {
let est_chunks = estimated_files * 3;
let est_chunks = estimated_files * INLINE_CHUNKS; // we know that most of repos will fit
// most paths into 64 = 16 * INLINE_CHUNKS
Self {
arena: Vec::with_capacity(est_chunks / 2),
chunk_dedup: AHashMap::with_capacity(est_chunks / 2),
arena: Vec::with_capacity(est_chunks),
chunk_dedup: AHashMap::with_capacity(est_chunks),
}
}
+80 -35
View File
@@ -4,9 +4,12 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering};
#[cfg(not(target_os = "windows"))]
use crate::constants::{FRESH_MMAP_THRESHOLD, MMAP_THRESHOLD};
use crate::constants::{MAX_CACHED_CONTENT_BYTES, MAX_FFFILE_SIZE, PATH_BUF_SIZE};
use crate::constraints::Constrainable;
use crate::query_tracker::QueryMatchEntry;
use crate::simd_path::{ArenaPtr, PATH_BUF_SIZE};
use crate::simd_path::ArenaPtr;
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
/// Different sources of the string storage used by FFF
@@ -237,6 +240,18 @@ impl Clone for FileItem {
}
}
/// Single-block read used by the binary classifier. Most binaries reveal a
/// NUL byte within the first filesystem block, so 16 KB lets one read settle
/// the classification for typical files while keeping the scratch buffer
/// small enough to live on the stack.
pub const BINARY_CLASSIFICATION_CHUNK_SIZE: usize = 16 * 1024;
/// A file is treated as binary if any NUL byte appears in the scanned prefix.
#[inline]
pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
memchr::memchr(0, content).is_some()
}
impl FileItem {
pub fn new_raw(
filename_start: u16,
@@ -499,6 +514,38 @@ impl FileItem {
}
}
/// Chunked classifier of the binary content of the file chunk by chunk
/// accepts path which to reuse the allocated buffer for absolute path read
pub(crate) fn detect_binary_per_byte(&self, path: &Path, chunk: &mut [u8]) {
if self.size == 0 {
return;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.write(false)
.read(true)
.open(path)
else {
tracing::error!(path = ?path.display(), "Failed to open indexed file");
return;
};
loop {
match file.read(chunk) {
Ok(0) => break,
Err(e) => {
tracing::error!(?e, "Failed to read file chunk");
break;
}
Ok(n) => {
if detect_binary_content(&chunk[..n]) {
self.set_binary(true);
}
}
}
}
}
#[inline]
pub fn is_deleted(&self) -> bool {
self.flags.load(Ordering::Relaxed) & FileItemFlags::DELETED != 0
@@ -597,13 +644,7 @@ impl FileItem {
/// Returns a reference to a cached mmap of the file's contents.
///
/// SAFETY-CRITICAL: callers must hold the picker read lock for as long as
/// the returned slice is in use. The watcher mutates `FileItem` (including
/// `invalidate_mmap`) under the picker write lock, so the read lock is
/// what prevents UAF (`OnceLock` reset → `munmap`) and SIGBUS (in-place
/// truncate → access past new EOF). Detached background tasks (e.g. the
/// bigram builder running on `BACKGROUND_THREAD_POOL`) MUST NOT call this
/// — use `read_trimmed_into_buf` instead.
/// SAFETY-CRITICAL: callers must hold the picker read lock for as long as the returned slice is in use.
#[cfg(not(target_os = "windows"))]
pub(crate) fn get_cached_content(
&self,
@@ -615,10 +656,6 @@ impl FileItem {
return Some(content);
}
// Skip caching when mmap can't pay for itself. Files under one page
// worth of bytes waste kernel VM structures and a per-file syscall
// pair — the chunked `read_into_buf` fallback is cheaper for them
// and hits the OS page cache on repeat reads anyway.
if self.size < MMAP_THRESHOLD || self.size > budget.max_file_size {
return None;
}
@@ -654,16 +691,20 @@ impl FileItem {
#[inline]
pub(crate) fn get_content_for_search<'a>(
&'a self,
buf: &'a mut Vec<u8>, // we allow it to grow
buf: &'a mut Vec<u8>,
#[cfg_attr(target_os = "windows", allow(unused_variables))] mmap_slot: &'a mut MmapSlot,
arena: ArenaPtr,
base_path: &Path,
budget: &ContentCacheBudget,
) -> Option<&'a [u8]> {
// Fast path: persistent cache hit (zero-copy). Safe here because grep
// callers hold the picker read lock for the lifetime of the returned
// slice — see [`Self::get_cached_content`] safety note.
if let Some(cached) = self.get_cached_content(arena, base_path, budget) {
return Some(cached);
#[cfg(not(target_os = "windows"))]
{
// Fast path: persistent cache hit (zero-copy). Safe here because
// grep callers hold the picker read lock for the lifetime of the
// returned slice — see [`Self::get_cached_content`] safety note.
if let Some(cached) = self.get_cached_content(arena, base_path, budget) {
return Some(cached);
}
}
let max_file_size = budget.max_file_size;
@@ -671,26 +712,34 @@ impl FileItem {
return None;
}
// Slow path: read into the reusable buffer — open() + read_exact() + close().
// No mmap()/munmap() syscalls, no page table setup/teardown.
// We know the exact size so we use read_exact (1 read syscall) instead of
// read_to_end (2 read syscalls — one for data, one for EOF confirmation).
let abs = self.absolute_path(arena, base_path);
#[cfg(not(target_os = "windows"))]
if self.size >= FRESH_MMAP_THRESHOLD {
let file = std::fs::File::open(&abs).ok()?;
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
let stored = mmap_slot.insert(mmap);
return Some(&stored[..]);
} else {
let _ = (mmap_slot, arena);
}
let len = self.size as usize;
buf.resize(len, 0);
let mut file = std::fs::File::open(&abs).ok()?;
file.read_exact(buf).ok()?;
Some(buf.as_slice())
}
}
/// Files smaller than one page waste the remainder when mmapped.
/// Files smaller than one page waste the remainder when mmapped. Unused
/// on Windows where the persistent content cache is disabled.
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
const MMAP_THRESHOLD: u64 = 16 * 1024;
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
const MMAP_THRESHOLD: u64 = 4 * 1024;
/// Per-thread scratch slot owning a transient mmap returned from
/// [`FileItem::get_content_for_search`]. `Option<Mmap>` on Unix,
/// unit on Windows where mmap is unused.
#[cfg(not(target_os = "windows"))]
pub type MmapSlot = Option<memmap2::Mmap>;
#[cfg(target_os = "windows")]
pub type MmapSlot = ();
impl Constrainable for FileItem {
#[inline]
@@ -807,10 +856,6 @@ impl Default for MixedItemRef<'_> {
}
}
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_CACHED_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug)]
pub struct ContentCacheBudget {
pub max_files: usize,
@@ -825,7 +870,7 @@ impl ContentCacheBudget {
Self {
max_files: usize::MAX,
max_bytes: u64::MAX,
max_file_size: MAX_MMAP_FILE_SIZE,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
@@ -867,7 +912,7 @@ impl ContentCacheBudget {
Self {
max_files,
max_bytes,
max_file_size: MAX_MMAP_FILE_SIZE,
max_file_size: MAX_FFFILE_SIZE,
cached_count: AtomicUsize::new(0),
cached_bytes: AtomicU64::new(0),
}
Binary file not shown.
Binary file not shown.
+439
View File
@@ -302,6 +302,445 @@ fn plain_text_binary_files_are_skipped() {
assert!(result.files[0].relative_path(&picker).contains("text.txt"));
}
#[test]
fn binary_payload_after_long_ascii_header_is_detected() {
// Mimics formats like Radiance .hdr / Apple bplist / Adobe .ai where the
// first ~1KB is plain ASCII and the binary payload (NULs) starts later.
// The legacy 512-byte sniff missed these; the bigram-build memchr scan
// over the whole indexed buffer must catch them.
use fff_search::file_picker::FFFMode;
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
let mut content = Vec::new();
// 1 KiB of plain ASCII header — escapes any small fixed-window NUL sniff.
content.extend(std::iter::repeat_n(b'A', 1024));
content.extend_from_slice(b"\nmatch this text\n");
// Binary payload: NUL bytes that prove the file is not text.
content.extend(std::iter::repeat_n(0u8, 256));
content.extend_from_slice(b"\nmatch this text\n");
// Use a *text* extension so the scan-time heuristic does NOT pre-flag it.
// Only the bigram-time content scan can mark it binary.
fs::write(base.join("header.txt"), &content).unwrap();
fs::write(base.join("plain.txt"), b"match this text\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let was_flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("header.txt") && f.is_binary());
assert!(
was_flagged,
"header.txt with NULs past 512 bytes must be flagged binary by the whole-buffer memchr scan"
);
let parsed = parse_grep_query("match this text");
let result = picker.grep(&parsed, &plain_opts());
assert_eq!(
result.files.len(),
1,
"only plain.txt should be searched; header.txt must be skipped as binary"
);
assert!(
result.files[0].relative_path(picker).contains("plain.txt"),
"the only match should come from plain.txt"
);
}
#[test]
fn unknown_extension_binary_added_after_scan_is_reclassified() {
// The initial-scan path runs detect_binary_content as part of bigram build,
// but the watcher path used to fall back to extension-only triage and
// missed binary files with unknown extensions like `.codex`.
use fff_search::file_picker::FFFMode;
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Seed one tracked text file so the initial scan has something to work with.
fs::write(base.join("seed.txt"), b"seed\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
// Drop the file on disk after indexing finished, then announce it through
// the watcher entry point. `.codex` is intentionally not in the extension
// allow-list — only a content sniff can flag it.
let mut payload = vec![0x03u8, 0x00, 0x04, 0x05];
payload.extend(std::iter::repeat_n(0u8, 256));
let new_path = base.join("snapshot.codex");
fs::write(&new_path, &payload).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker.handle_create_or_modify(&new_path).is_some(),
"handle_create_or_modify must accept the new file"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let was_flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("snapshot.codex") && f.is_binary());
assert!(
was_flagged,
"snapshot.codex must be flagged binary when added via the watcher path"
);
}
#[test]
fn text_file_modified_to_binary_is_reclassified() {
// A file that started life as text and later got rewritten with NUL bytes
// (e.g. a generator overwrote a .log) must lose its text classification.
use fff_search::file_picker::FFFMode;
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// Start as plain text with a known extension.
fs::write(base.join("notes.txt"), b"hello world\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
// Sanity: it's text right now.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let is_text = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("notes.txt") && !f.is_binary());
assert!(is_text, "notes.txt should start as text");
}
// Overwrite with binary content and replay through the watcher entry point.
// Bump mtime so update_metadata records it as a real change.
std::thread::sleep(Duration::from_secs(1));
let mut payload = b"header text\n".to_vec();
payload.extend(std::iter::repeat_n(0u8, 256));
fs::write(base.join("notes.txt"), &payload).unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(
picker
.handle_create_or_modify(base.join("notes.txt"))
.is_some(),
"handle_create_or_modify must succeed for the modify case"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let now_binary = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("notes.txt") && f.is_binary());
assert!(
now_binary,
"notes.txt must flip to binary after being overwritten with NULs"
);
}
#[test]
fn large_unknown_extension_binary_is_classified_at_scan_time() {
// Files larger than MAX_INDEXABLE_FILE_SIZE never enter build_bigram_index,
// so without a separate header sniff they default to is_binary=false and
// pollute grep results with NUL-laden lines (e.g. a committed ELF blob
// named `codex_view` with no extension).
use fff_search::file_picker::FFFMode;
use fff_search::grep::{GrepSearchOptions, parse_grep_query};
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// 3 MiB: above the 2 MiB bigram cap and below the 10 MiB grep cap.
// ELF-like header with NULs at the very start, then ASCII filler so a
// grep for "match this text" would otherwise return polluted lines.
let mut blob = Vec::new();
blob.extend_from_slice(b"\x7fELF\x02\x01\x01\x00");
blob.extend(std::iter::repeat_n(0u8, 256));
blob.extend_from_slice(b"\nmatch this text\n");
blob.extend(std::iter::repeat_n(b'A', 3 * 1024 * 1024));
blob.extend_from_slice(b"\nmatch this text\n");
fs::write(base.join("codex_view"), &blob).unwrap();
fs::write(base.join("plain.txt"), b"match this text\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let was_flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("codex_view") && f.is_binary());
assert!(
was_flagged,
"large no-extension binary must be flagged via the header sniff"
);
let parsed = parse_grep_query("match this text");
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
..plain_opts()
};
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.files.len(),
1,
"only plain.txt should be searched; codex_view must be skipped as binary"
);
assert!(
result.files[0].relative_path(picker).contains("plain.txt"),
"the only match should come from plain.txt"
);
}
#[test]
fn large_binary_with_nuls_past_header_is_classified() {
// Guards the streaming sniff: a >2 MB file that is pure ASCII well past any
// fixed header window (the old code only checked the first 8 KB) but has
// NULs deeper in. Grep reads the whole file up to max_file_size, so the
// detector must scan the same range or the binary tail leaks as "text".
use fff_search::file_picker::FFFMode;
use fff_search::grep::{GrepSearchOptions, parse_grep_query};
use fff_search::{SharedFilePicker, SharedFrecency};
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
// 1 MiB of clean ASCII (with a grep marker) — dwarfs any header sniff —
// then NUL bytes, keeping the total above the 2 MiB non-indexable cap.
let mut blob = Vec::new();
blob.extend_from_slice(b"match this text\n");
blob.extend(std::iter::repeat_n(b'A', 1024 * 1024));
blob.extend_from_slice(b"match this text\n");
blob.extend(std::iter::repeat_n(0u8, 1024 * 1024 + 4096)); // NULs start ~1 MiB in
blob.extend_from_slice(b"match this text\n");
assert!(blob.len() > 2 * 1024 * 1024);
fs::write(base.join("late_nul.dat"), &blob).unwrap();
fs::write(base.join("plain.txt"), b"match this text\n").unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("Failed to create FilePicker");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
std::thread::sleep(Duration::from_millis(25));
let ready = shared_picker
.read()
.ok()
.and_then(|g| {
g.as_ref()
.map(|p| !p.is_scan_active() && p.bigram_index().is_some())
})
.unwrap_or(false);
if ready {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for bigram build"
);
}
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).contains("late_nul.dat") && f.is_binary());
assert!(
flagged,
"NULs past the 8 KB header window must still be detected by the streaming scan"
);
let parsed = parse_grep_query("match this text");
let opts = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
..plain_opts()
};
let result = picker.grep(&parsed, &opts);
assert_eq!(
result.files.len(),
1,
"only plain.txt should match; late_nul.dat must be skipped as binary"
);
assert!(result.files[0].relative_path(picker).contains("plain.txt"));
}
#[test]
fn plain_text_max_matches_per_file() {
let tmp = TempDir::new().unwrap();
@@ -0,0 +1,153 @@
//! Real-world binary fixture regression.
//!
//! Reproduces the exact bug chain we hit with `codex_view` (4.5 MB ELF, no
//! extension) and `codex_view.codex` (127 KB, unknown extension): both are
//! binary by content but slip past extension-only triage, so a plain grep
//! used to surface their NUL-laden bytes as "text" matches.
//!
//! The fixtures live in `tests/fixtures/binaries/`. `MARKER` is a string that
//! is present (as raw bytes) in BOTH binaries — the test first asserts that,
//! then drops the two binaries plus a single plain-text file containing the
//! same marker into a closed temp dir and greps for it. Only the text file may
//! come back; if binary detection ever regresses, a binary file re-enters the
//! results and this test fails.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use fff_search::file_picker::{FFFMode, FilePicker};
use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query};
use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency};
const MARKER: &str = "__jai_runtime_init";
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/binaries")
}
fn plain_opts() -> GrepSearchOptions {
GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: 200,
smart_case: true,
file_offset: 0,
page_limit: 200,
mode: GrepMode::PlainText,
time_budget_ms: 0,
before_context: 0,
after_context: 0,
classify_definitions: false,
trim_whitespace: false,
abort_signal: None,
}
}
#[test]
fn real_binary_fixtures_are_detected_and_excluded_from_grep() {
let fixtures = fixtures_dir();
let large = fixtures.join("codex_view"); // 4.5 MB ELF, no extension (> 2 MB)
let small = fixtures.join("codex_view.codex"); // 127 KB, unknown extension (< 2 MB)
assert!(
large.exists() && small.exists(),
"missing binary fixtures in {}",
fixtures.display()
);
// Both fixtures must really contain the marker bytes, otherwise the grep
// exclusion assertion below would be vacuous.
let large_bytes = fs::read(&large).unwrap();
let small_bytes = fs::read(&small).unwrap();
assert!(
contains_subslice(&large_bytes, MARKER.as_bytes()),
"fixture codex_view no longer contains the marker {MARKER:?}"
);
assert!(
contains_subslice(&small_bytes, MARKER.as_bytes()),
"fixture codex_view.codex no longer contains the marker {MARKER:?}"
);
// Sanity on the size split that drives the two distinct code paths.
assert!(
large_bytes.len() > 2 * 1024 * 1024,
"codex_view must exceed the 2 MB non-indexable threshold"
);
assert!(
small_bytes.len() < 2 * 1024 * 1024,
"codex_view.codex must stay under the 2 MB bigram cap"
);
// Closed environment: the two real binaries + one plain-text file that
// legitimately contains the marker.
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path();
fs::copy(&large, base.join("codex_view")).unwrap();
fs::copy(&small, base.join("codex_view.codex")).unwrap();
fs::write(
base.join("marker.txt"),
format!("the only legitimate hit lives here: {MARKER}\n"),
)
.unwrap();
let shared_picker = SharedFilePicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
enable_mmap_cache: false,
enable_content_indexing: true,
mode: FFFMode::Neovim,
watch: false,
..Default::default()
},
)
.expect("failed to create FilePicker");
shared_picker.wait_for_indexing_complete(Duration::from_secs(5));
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
// Both binaries must be classified binary.
for name in ["codex_view", "codex_view.codex"] {
let flagged = picker
.get_files()
.iter()
.any(|f| f.relative_path(picker).ends_with(name) && f.is_binary());
assert!(flagged, "{name} must be flagged is_binary");
}
// we need to make sure that marker.txt ONLY can match as we have to match
// grep as binaries are excluded from the matching process
let parsed = parse_grep_query(MARKER);
let result = picker.grep(&parsed, &plain_opts());
let matched: Vec<String> = result
.files
.iter()
.map(|f| f.relative_path(picker))
.collect();
assert_eq!(
result.files.len(),
1,
"exactly one file should match {MARKER:?}, got: {matched:?}"
);
assert!(
matched[0].ends_with("marker.txt"),
"the only match must be marker.txt, got {:?}",
matched[0]
);
}
/// Tiny substring search over raw bytes (the marker may be surrounded by NULs).
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
+1 -1
View File
@@ -3,7 +3,7 @@ name = "fff-grep"
description = "File grepping logic for fff"
license = "MIT"
authors = ["Dmitriy Kovalenko <dmtr.kovalenko@outlok.com>"]
version = "0.8.1"
version = "0.8.4"
edition = "2024"
[dependencies]
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-mcp"
version = "0.8.1"
version = "0.8.4"
edition = "2024"
description = "MCP server for FFF file finder - drop-in replacement for AI code assistant search tools"
license = "MIT"
@@ -14,10 +14,10 @@ default = ["zlob"]
zlob = ["fff/zlob"]
[dependencies]
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.8.1" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.8.1" }
fff = { package = "fff-search", path = "../fff-core", default-features = false , version = "0.8.4" }
fff-query-parser = { path = "../fff-query-parser", default-features = false , version = "0.8.4" }
mimalloc = { workspace = true }
rmcp = { version = "1.1.0", features = ["server", "transport-io"] }
rmcp = { version = "1.7.0", features = ["server", "transport-io"] }
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
-3
View File
@@ -14,7 +14,6 @@ use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff::types::{FileItem, PaginationArgs};
use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker, SharedFrecency};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
@@ -186,7 +185,6 @@ pub struct FffServer {
frecency: SharedFrecency,
cursor_store: Arc<Mutex<CursorStore>>,
update_notice_sent: Arc<AtomicBool>,
tool_router: ToolRouter<Self>,
}
impl FffServer {
@@ -196,7 +194,6 @@ impl FffServer {
frecency,
cursor_store: Arc::new(Mutex::new(CursorStore::new())),
update_notice_sent: Arc::new(AtomicBool::new(false)),
tool_router: Self::tool_router(),
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-nvim"
version = "0.8.1"
version = "0.8.4"
edition = "2024"
[lib]
@@ -61,10 +61,10 @@ ahash = { workspace = true }
tracing = { workspace = true }
# Local crates
fff = { package = "fff-search", path = "../fff-core", version = "0.8.1", features = [
fff = { package = "fff-search", path = "../fff-core", version = "0.8.4", features = [
"mimalloc-collect",
] }
fff-query-parser = { path = "../fff-query-parser", version = "0.8.1" }
fff-query-parser = { path = "../fff-query-parser", version = "0.8.4" }
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
git2 = { workspace = true }
+97 -3
View File
@@ -5,9 +5,9 @@ use fff::frecency::FrecencyTracker;
use fff::path_utils::expand_tilde;
use fff::query_tracker::QueryTracker;
use fff::{
DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig,
PaginationArgs, QueryParser, Score, SearchResult, SharedFilePicker, SharedFrecency,
SharedQueryTracker,
DbHealthChecker, DirSearchConfig, Error, FFFMode, FileSearchConfig, FuzzySearchOptions,
GrepConfig, MixedSearchConfig, PaginationArgs, QueryParser, Score, SearchResult,
SharedFilePicker, SharedFrecency, SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
@@ -260,6 +260,92 @@ pub fn fuzzy_search_files(
lua_types::SearchResultLua::new(results, picker).into_lua(lua)
}
#[allow(clippy::type_complexity)]
pub fn fuzzy_search_directories(
lua: &Lua,
(query, max_threads, current_file, page_index, page_size): (
String,
usize,
Option<String>,
Option<usize>,
Option<usize>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let parser = QueryParser::new(DirSearchConfig);
let parsed = parser.parse(&query);
let results = picker.fuzzy_search_directories(
&parsed,
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
combo_boost_score_multiplier: 0,
min_combo_count: 0,
pagination: PaginationArgs {
offset: page_index.unwrap_or(0),
limit: page_size.unwrap_or(0),
},
},
);
lua_types::DirSearchResultLua::new(results, picker).into_lua(lua)
}
#[allow(clippy::type_complexity)]
pub fn fuzzy_search_mixed(
lua: &Lua,
(
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
page_index,
page_size,
): (
String,
usize,
Option<String>,
i32,
Option<u32>,
Option<usize>,
Option<usize>,
),
) -> LuaResult<LuaValue> {
let file_picker_guard = FILE_PICKER.read().into_lua_result()?;
let Some(ref picker) = *file_picker_guard else {
return Err(error::to_lua_error(Error::FilePickerMissing));
};
let query_tracker_guard = QUERY_TRACKER.read().into_lua_result()?;
let parser = QueryParser::new(MixedSearchConfig);
let parsed = parser.parse(&query);
let results = picker.fuzzy_search_mixed(
&parsed,
query_tracker_guard.as_ref(),
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
combo_boost_score_multiplier,
min_combo_count: min_combo_count.unwrap_or(3),
pagination: PaginationArgs {
offset: page_index.unwrap_or(0),
limit: page_size.unwrap_or(0),
},
},
);
lua_types::MixedSearchResultLua::new(results, picker).into_lua(lua)
}
#[allow(clippy::type_complexity)]
pub fn live_grep(
lua: &Lua,
@@ -803,6 +889,14 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
"fuzzy_search_files",
lua.create_function(fuzzy_search_files)?,
)?;
exports.set(
"fuzzy_search_directories",
lua.create_function(fuzzy_search_directories)?,
)?;
exports.set(
"fuzzy_search_mixed",
lua.create_function(fuzzy_search_mixed)?,
)?;
exports.set("live_grep", lua.create_function(live_grep)?)?;
exports.set("track_access", lua.create_function(track_access)?)?;
exports.set(
+118 -16
View File
@@ -1,6 +1,9 @@
use fff::file_picker::FilePicker;
use fff::git::format_git_status;
use fff::{FileItem, GrepResult, Location, Score, SearchResult};
use fff::{
DirItem, DirSearchResult, FileItem, GrepResult, Location, MixedItemRef, MixedSearchResult,
Score, SearchResult,
};
use mlua::prelude::*;
pub struct SearchResultLua<'a> {
@@ -25,6 +28,28 @@ impl<'a> GrepResultLua<'a> {
}
}
pub struct DirSearchResultLua<'a> {
inner: DirSearchResult<'a>,
picker: &'a FilePicker,
}
impl<'a> DirSearchResultLua<'a> {
pub fn new(inner: DirSearchResult<'a>, picker: &'a FilePicker) -> Self {
Self { inner, picker }
}
}
pub struct MixedSearchResultLua<'a> {
inner: MixedSearchResult<'a>,
picker: &'a FilePicker,
}
impl<'a> MixedSearchResultLua<'a> {
pub fn new(inner: MixedSearchResult<'a>, picker: &'a FilePicker) -> Self {
Self { inner, picker }
}
}
struct LuaPosition((i32, i32));
impl IntoLua for LuaPosition {
@@ -38,6 +63,7 @@ impl IntoLua for LuaPosition {
fn file_item_into_lua(item: &FileItem, lua: &Lua, picker: &FilePicker) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("type", "file")?;
table.set("relative_path", item.relative_path(picker))?;
table.set("name", item.file_name(picker))?;
table.set("size", item.size)?;
@@ -53,6 +79,20 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua, picker: &FilePicker) -> LuaRes
Ok(LuaValue::Table(table))
}
fn dir_item_into_lua(item: &DirItem, lua: &Lua, picker: &FilePicker) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let name = item
.dir_name(picker)
.trim_end_matches(std::path::MAIN_SEPARATOR)
.trim_end_matches('/')
.to_owned();
table.set("type", "directory")?;
table.set("relative_path", item.relative_path(picker))?;
table.set("name", name)?;
table.set("max_access_frecency", item.max_access_frecency())?;
Ok(LuaValue::Table(table))
}
fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("total", score.total)?;
@@ -69,6 +109,24 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
Ok(LuaValue::Table(table))
}
fn location_into_lua(location: &Location, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
match location {
Location::Line(line) => {
table.set("line", *line)?;
}
Location::Position { line, col } => {
table.set("line", *line)?;
table.set("col", *col)?;
}
Location::Range { start, end } => {
table.set("start", LuaPosition(*start))?;
table.set("end", LuaPosition(*end))?;
}
}
Ok(LuaValue::Table(table))
}
impl IntoLua for SearchResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
@@ -91,23 +149,62 @@ impl IntoLua for SearchResultLua<'_> {
table.set("total_files", self.inner.total_files)?;
if let Some(location) = &self.inner.location {
let location_table = lua.create_table()?;
table.set("location", location_into_lua(location, lua)?)?;
}
match location {
Location::Line(line) => {
location_table.set("line", *line)?;
}
Location::Position { line, col } => {
location_table.set("line", *line)?;
location_table.set("col", *col)?;
}
Location::Range { start, end } => {
location_table.set("start", LuaPosition(*start))?;
location_table.set("end", LuaPosition(*end))?;
}
}
Ok(LuaValue::Table(table))
}
}
table.set("location", location_table)?;
impl IntoLua for DirSearchResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let items_table = lua.create_table()?;
for (i, item) in self.inner.items.iter().enumerate() {
items_table.set(i + 1, dir_item_into_lua(item, lua, self.picker)?)?;
}
table.set("items", items_table)?;
let scores_table = lua.create_table()?;
for (i, score) in self.inner.scores.iter().enumerate() {
scores_table.set(i + 1, score_into_lua(score, lua)?)?;
}
table.set("scores", scores_table)?;
table.set("total_matched", self.inner.total_matched)?;
table.set("total_dirs", self.inner.total_dirs)?;
Ok(LuaValue::Table(table))
}
}
impl IntoLua for MixedSearchResultLua<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let items_table = lua.create_table()?;
for (i, item) in self.inner.items.iter().enumerate() {
let lua_item = match item {
MixedItemRef::File(file) => file_item_into_lua(file, lua, self.picker)?,
MixedItemRef::Dir(dir) => dir_item_into_lua(dir, lua, self.picker)?,
};
items_table.set(i + 1, lua_item)?;
}
table.set("items", items_table)?;
let scores_table = lua.create_table()?;
for (i, score) in self.inner.scores.iter().enumerate() {
scores_table.set(i + 1, score_into_lua(score, lua)?)?;
}
table.set("scores", scores_table)?;
table.set("total_matched", self.inner.total_matched)?;
table.set("total_files", self.inner.total_files)?;
table.set("total_dirs", self.inner.total_dirs)?;
if let Some(location) = &self.inner.location {
table.set("location", location_into_lua(location, lua)?)?;
}
Ok(LuaValue::Table(table))
@@ -142,6 +239,11 @@ impl IntoLua for GrepResultLua<'_> {
item.set("line_number", m.line_number)?;
item.set("col", m.col)?;
item.set("byte_offset", m.byte_offset)?;
// There is a little race window when fff can return matches inside of a non-binary
// classified entities, the window is minimal but it errors out neovim so guard it
let is_binary_content = m.line_content.as_bytes().contains(&0u8);
item.set("is_binary_content", is_binary_content)?;
item.set("line_content", m.line_content.as_str())?;
// Match byte ranges within line_content
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fff-query-parser"
version = "0.8.1"
version = "0.8.4"
edition = "2024"
description = "Query parser for fff file finder - includes specific syntax for various constraints like globs, extensions, regex etc"
license = "MIT"
+1 -4
View File
@@ -21,11 +21,8 @@ param(
[string]$Version = $env:FFF_MCP_VERSION,
[string]$InstallDir = $env:FFF_MCP_INSTALL_DIR,
[ValidateSet('User', 'Profile', 'None')]
[string]$PathScope
[string]$PathScope = $(if ($env:FFF_MCP_PATH_SCOPE) { $env:FFF_MCP_PATH_SCOPE } else { 'User' })
)
if (-not $PathScope) {
$PathScope = if ($env:FFF_MCP_PATH_SCOPE) { $env:FFF_MCP_PATH_SCOPE } else { 'User' }
}
$ErrorActionPreference = 'Stop'
+10
View File
@@ -57,6 +57,7 @@ local M = {}
--- @field time_budget_ms number
--- @field modes string[]
--- @field trim_whitespace boolean
--- @field location_format string
--- @class FffConfig
--- @field base_path string
@@ -324,6 +325,11 @@ local function init()
file_info_match_type = 'FFFFileInfoMatchType', -- match_type label (bold)
file_info_score_pos = 'FFFFileInfoScorePos', -- Positive score components
file_info_score_neg = 'FFFFileInfoScoreNeg', -- Negative score components / penalties
-- Per-window 'winhighlight' overrides. When nil, falls back to a combination of `normal`, `border`, and `title` above.
-- Accepts either a string applied to every picker window, or a table with optional `prompt`, `list`, `preview`, `file_info` keys.
-- Example: `winhl = 'Normal:NormalFloat,FloatBorder:FloatBorder,FloatTitle:Title'`
-- Example: `winhl = { prompt = 'Normal:Pmenu,...', list = 'Normal:NormalFloat,...' }`
winhl = nil,
},
-- Store file open frecency
frecency = {
@@ -371,6 +377,10 @@ local function init()
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
trim_whitespace = false, -- Strip leading whitespace from matched lines (useful for cleaner display)
-- Format string for the line/column location prefix in grep results.
-- Uses vim's printf-style format: %d placeholders for line and column (1-based).
-- Default ':%d:%d' renders as ':356:1'. Use ':%d' for line-only ':356'.
location_format = ':%d:%d',
},
}
+2
View File
@@ -20,6 +20,8 @@ M.restart_index_in_path = rust_module.restart_index_in_path
M.scan_files = rust_module.scan_files
M.get_cached_files = rust_module.get_cached_files
M.fuzzy_search_files = rust_module.fuzzy_search_files
M.fuzzy_search_directories = rust_module.fuzzy_search_directories
M.fuzzy_search_mixed = rust_module.fuzzy_search_mixed
M.track_access = rust_module.track_access
M.get_file_access_count = rust_module.get_file_access_count
M.add_file = rust_module.add_file
+24 -4
View File
@@ -41,13 +41,23 @@ end
---@param item table Grep match item
---@param ctx table Render context
---@return string The match line 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>'
local function render_match_line(item, ctx)
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local location = format_location(item, ctx)
local separator = ' '
-- vim.json.decode may return Blobs for strings with NUL bytes; coerce to string.
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
-- Indent + location + separator + content
local indent = ' '
@@ -105,7 +115,7 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
end
-- 2. Location (:line:col) dimmed — use extmark with priority so it layers with cursor
local location_str = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local location_str = format_location(item, ctx)
local loc_start = indent
local loc_end = loc_start + #location_str
if loc_end <= #line_content then
@@ -131,7 +141,17 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- Priority 120: above CursorLine (100) so syntax is visible on cursor line,
-- below IncSearch match ranges (200) so search matches take precedence.
local content_start = sep_end
if item._trimmed_content and item.name then
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
-- Resolve language once per file group (cache on the render context)
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
@@ -159,7 +179,7 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- 5. Match ranges highlighted with IncSearch
-- Use extmarks with priority > cursor line (100) so IncSearch renders
-- properly on the selected line instead of being overridden by CursorLine.
if item.match_ranges then
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
+2 -1
View File
@@ -223,6 +223,7 @@ end
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
--- @return table<number, ItemLineMapping> item_to_lines
--- @return number|nil separator_line 1-based buffer line of the separator (post-padding), nil if none
function M.render(ctx, list_buf, list_win, ns_id)
local lines, item_to_lines, separator_line = generate_item_lines(ctx)
@@ -251,7 +252,7 @@ function M.render(ctx, list_buf, list_win, ns_id)
end
end
return separator_line
return item_to_lines, separator_line
end
return M
+378 -13
View File
@@ -129,6 +129,222 @@ function M.search(query, max_results)
return {}
end
--- @class fff.FileSearchOpts
--- @field mode? "files"|"directories"|"mixed" Item type to search (default: 'files').
--- @field max_results? number Max items per page (default: config.max_results).
--- @field page? number 0-based page index (default: 0).
--- @field current_file? string Path to deprioritize (default: nil).
--- @field max_threads? number Worker threads (default: config.max_threads).
--- @field combo_boost_score_multiplier? number Override history combo boost.
--- @field min_combo_count? number Override history min_combo_count.
--- @field cwd? string If set and different from the current indexed root, switch the index to this directory before searching. Implies waiting for the new scan unless `wait_for_index_ms = 0`.
--- @field wait_for_index_ms? number Block up to this many ms for the index to be ready (default: 10000 when `cwd` triggers a re-index, 0 otherwise). Set to 0 to never block.
--- Switch the indexed root if `cwd` is set and different from the current
--- `base_path`, then optionally block until the new scan completes.
--- Returns `true` when the index is ready (or no wait requested), or `false`
--- with an error message on timeout / invalid cwd.
--- @param cwd string|nil
--- @param wait_for_index_ms number|nil
--- @return boolean ok, string? err
local function ensure_indexed(cwd, wait_for_index_ms)
-- ensure_initialized is idempotent; first call kicks off the initial scan
-- at config.base_path.
require('fff.core').ensure_initialized()
local config = require('fff.conf').get()
local is_windows = vim.fn.has('win32') == 1
local function canon(p)
if not p or p == '' then return '' end
local abs = vim.fn.fnamemodify(vim.fn.expand(p), ':p')
abs = (abs:gsub('[/\\]+$', ''))
-- fs_realpath resolves Windows 8.3 short names (RUNNER~1 -> runneradmin)
-- so picker base_path (canonicalized in rust) compares equal to the cwd
-- argument. fnamemodify(':p') alone keeps the short form on Windows.
local realpath_ok, realpath = pcall(vim.uv.fs_realpath, abs)
if realpath_ok and realpath then abs = realpath end
local normalized = vim.fs.normalize(abs)
if is_windows then normalized = normalized:lower() end
return normalized
end
local cwd_triggered_reindex = false
if cwd and cwd ~= '' then
local expanded = vim.fn.expand(cwd)
if vim.fn.isdirectory(expanded) ~= 1 then return false, 'cwd does not exist: ' .. expanded end
if canon(config.base_path) ~= canon(expanded) then
if not require('fff.core').change_indexing_directory(expanded) then
return false, 'failed to change indexing directory to ' .. expanded
end
cwd_triggered_reindex = true
end
end
-- Default: only wait when cwd actually swapped the picker. Callers can
-- pass wait_for_index_ms explicitly to force a wait on first-time init too.
local wait_ms = wait_for_index_ms
if wait_ms == nil then wait_ms = cwd_triggered_reindex and 10000 or 0 end
if wait_ms <= 0 then return true end
local fff_rust = require('fff.rust')
-- The picker swap runs on a background thread; wait_for_scan reads the
-- picker pointer once at entry, so polling health_check first guarantees
-- we wait on the new picker rather than racing the old one.
if cwd_triggered_reindex then
local target = canon(cwd)
local deadline = vim.uv.hrtime() + wait_ms * 1e6
local matched = false
while vim.uv.hrtime() < deadline do
local ok, health = pcall(fff_rust.health_check, target)
if ok and health and health.file_picker and health.file_picker.base_path then
if canon(health.file_picker.base_path) == target then
matched = true
break
end
end
vim.wait(20, function() return false end)
end
if not matched then return false, 'timeout waiting for re-index swap' end
-- Subtract the time we spent polling so the scan wait stays bounded.
local remaining = math.max(0, math.floor((deadline - vim.uv.hrtime()) / 1e6))
if remaining == 0 then return false, 'timeout waiting for index scan' end
wait_ms = remaining
end
local scan_ok = require('fff.file_picker').wait_for_initial_scan(wait_ms)
if not scan_ok then return false, 'timeout waiting for index scan' end
return true
end
--- Programmatic file search.
--- Returns the full structured result so callers can read scores, totals,
--- and (for `files`/`mixed` modes) the parsed `location`.
---
--- For `mixed` mode each item has a `type` field of `"file"` or `"directory"`.
--- @param query string Search query (constraint syntax supported)
--- @param opts? fff.FileSearchOpts
--- @return { items: table[], scores: table[], total_matched: number, total_files?: number, total_dirs?: number, location?: table }
function M.file_search(query, opts)
vim.validate({
query = { query, 'string' },
opts = { opts, 'table', true },
})
opts = opts or {}
local indexed_ok, err = ensure_indexed(opts.cwd, opts.wait_for_index_ms)
if not indexed_ok then
vim.notify('FFF file_search: ' .. err, vim.log.levels.ERROR)
return { items = {}, scores = {}, total_matched = 0 }
end
local fuzzy = require('fff.fuzzy')
local config = require('fff.conf').get()
local mode = opts.mode or 'files'
local max_threads = opts.max_threads or config.max_threads or 4
local page_size = opts.max_results or config.max_results or 100
local page_index = opts.page or 0
local current_file = opts.current_file
local combo_boost = opts.combo_boost_score_multiplier
or (config.history and config.history.combo_boost_score_multiplier)
or 100
local min_combo = opts.min_combo_count or (config.history and config.history.min_combo_count) or 3
local empty = { items = {}, scores = {}, total_matched = 0 }
if mode == 'files' then
local offset = page_index * page_size
local ok, result =
pcall(fuzzy.fuzzy_search_files, query, max_threads, current_file, combo_boost, min_combo, offset, page_size)
if not ok then
vim.notify('FFF file_search failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
elseif mode == 'directories' then
local ok, result = pcall(fuzzy.fuzzy_search_directories, query, max_threads, current_file, page_index, page_size)
if not ok then
vim.notify('FFF file_search(directories) failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
elseif mode == 'mixed' then
local ok, result =
pcall(fuzzy.fuzzy_search_mixed, query, max_threads, current_file, combo_boost, min_combo, page_index, page_size)
if not ok then
vim.notify('FFF file_search(mixed) failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
else
error("fff.file_search: opts.mode must be 'files', 'directories', or 'mixed', got " .. tostring(mode))
end
end
--- @class fff.ContentSearchOpts
--- @field mode? "plain"|"regex"|"fuzzy" Grep mode (default: 'plain').
--- @field max_file_size? number Skip files larger than N bytes (default: config.grep.max_file_size).
--- @field max_matches_per_file? number Cap matches per file, 0 = unlimited (default: config.grep.max_matches_per_file).
--- @field smart_case? boolean Case-insensitive when query is all lowercase (default: config.grep.smart_case).
--- @field page_size? number Max matches returned (default: 50).
--- @field file_offset? number File-based pagination offset (default: 0).
--- @field time_budget_ms? number Max wall-clock time, 0 = unlimited (default: config.grep.time_budget_ms).
--- @field trim_whitespace? boolean Strip leading whitespace from matched lines (default: config.grep.trim_whitespace).
--- @field cwd? string Switch indexed root before grepping (same semantics as `file_search`).
--- @field wait_for_index_ms? number Block up to this many ms for the index to be ready.
--- Programmatic content (grep) search.
--- Returns the full structured `GrepResult` (items, totals, regex fallback).
--- @param query string Grep query (`*.rs pattern`, glob constraints, etc. supported)
--- @param opts? fff.ContentSearchOpts
--- @return { items: table[], total_matched: number, total_files_searched: number, total_files: number, filtered_file_count: number, next_file_offset: number, regex_fallback_error?: string }
function M.content_search(query, opts)
vim.validate({
query = { query, 'string' },
opts = { opts, 'table', true },
})
opts = opts or {}
local mode = opts.mode or 'plain'
if mode ~= 'plain' and mode ~= 'regex' and mode ~= 'fuzzy' then
error("fff.content_search: opts.mode must be 'plain', 'regex', or 'fuzzy', got " .. tostring(mode))
end
local empty = {
items = {},
total_matched = 0,
total_files_searched = 0,
total_files = 0,
filtered_file_count = 0,
next_file_offset = 0,
}
local indexed_ok, err = ensure_indexed(opts.cwd, opts.wait_for_index_ms)
if not indexed_ok then
vim.notify('FFF content_search: ' .. err, vim.log.levels.ERROR)
return empty
end
local config = require('fff.conf').get()
local grep_cfg = config.grep or {}
local grep = require('fff.grep')
local merged_grep_cfg = {
max_file_size = opts.max_file_size or grep_cfg.max_file_size,
max_matches_per_file = opts.max_matches_per_file or grep_cfg.max_matches_per_file,
smart_case = opts.smart_case == nil and grep_cfg.smart_case or opts.smart_case,
time_budget_ms = opts.time_budget_ms or grep_cfg.time_budget_ms,
trim_whitespace = opts.trim_whitespace == nil and grep_cfg.trim_whitespace or opts.trim_whitespace,
}
local ok, result = pcall(grep.search, query, opts.file_offset or 0, opts.page_size or 50, merged_grep_cfg, mode)
if not ok then
vim.notify('FFF content_search failed: ' .. tostring(result), vim.log.levels.ERROR)
return empty
end
return result
end
--- Search and show results in a nice format
--- @param query string Search query
function M.search_and_show(query)
@@ -211,11 +427,153 @@ end
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path) return require('fff.core').change_indexing_directory(new_path) end
--- Opens the file under the cursor with an optional callback if the only file
--- is found and we are about to inline open it
--- @param open_cb function|nil Optional callback function to execute after opening the file
-- Strip wrapper punctuation that frequently surrounds paths in prose: leading
-- markdown-link `[`, parens `(`, brackets `<`, quotes; trailing sentence
-- punctuation. We additionally truncate at the first closing wrapper so a
-- cWORD like `[file.lua](./somewhere)` collapses to just `file.lua`. We
-- deliberately keep `:` and digits inside the word so `path:line:col`
-- suffixes survive.
local function strip_path_wrappers(s)
if not s or s == '' then return s end
s = s:gsub('^[%(%[%{<"\'`]+', '')
s = s:gsub('([%)%]%}>"\'`]).*$', '')
s = s:gsub('[,;!%?]+$', '')
s = s:gsub('([^%.])%.$', '%1')
-- Drop a leading `./` or `.\` — purely presentational, but the rust scorer
-- otherwise can't recognise the path as an exact filename / path match.
s = s:gsub('^%./', '')
s = s:gsub('^%.\\', '')
return s
end
-- Split a `path:line:col` or `path:line` suffix off a path candidate.
-- Returns `(path, location|nil)`.
local function split_location_suffix(s)
if not s or s == '' then return s, nil end
local p, l, c = s:match('^(.-):(%d+):(%d+)$')
if p and p ~= '' then return p, { line = tonumber(l), col = tonumber(c) } end
local p2, l2 = s:match('^(.-):(%d+)$')
if p2 and p2 ~= '' then return p2, { line = tonumber(l2) } end
return s, nil
end
-- Heuristic: only a string with an explicit path separator (or `~`) is treated
-- as "definitely a path" worth resolving directly. Bare names like `foo.lua`
-- still go through the fuzzy picker so frecency / disambiguation can help.
local function looks_like_path(s)
if not s or s == '' then return false end
if vim.startswith(s, '~') then return true end
return s:find('[/\\]') ~= nil
end
-- Resolve `path` to an existing file on disk. Tries (in order): expanded
-- absolute, base_path-relative, cwd-relative. Returns the absolute path on
-- success, otherwise `nil`.
local function resolve_existing_file(path)
if not path or path == '' then return nil end
local expanded = vim.fn.expand(path)
-- Absolute (after ~ expansion): check directly
if vim.fn.fnamemodify(expanded, ':p') == expanded then
if vim.fn.filereadable(expanded) == 1 then return expanded end
return nil
end
local seen = {}
local function try(candidate)
if not candidate or seen[candidate] then return nil end
seen[candidate] = true
if vim.fn.filereadable(candidate) == 1 then return candidate end
return nil
end
local base = require('fff.conf').get().base_path
if base and base ~= '' then
local hit = try(vim.fs.normalize(base .. '/' .. expanded))
if hit then return hit end
end
return try(vim.fs.normalize(vim.fn.getcwd() .. '/' .. expanded))
end
-- Open `abs_path` honouring the same window-targeting dance as `M.select`:
-- if the current window is `winfixbuf` / has a special buftype, retarget to
-- a suitable window, else fall back to `:split`. Optionally jumps to a
-- `location = { line, col }` after the buffer loads.
local function open_resolved_file(abs_path, relative_path, location, open_cb)
local utils = require('fff.utils')
local cwd_relative = vim.fn.fnamemodify(abs_path, ':.')
if open_cb and type(open_cb) == 'function' then
local cb_ok, cb_err = pcall(open_cb, abs_path, relative_path or cwd_relative)
if not cb_ok then vim.notify('open_file_under_cursor open_cb error: ' .. tostring(cb_err), vim.log.levels.ERROR) end
end
local current_win = vim.api.nvim_get_current_win()
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = utils.window_has_winfixbuf(current_win)
local opened_via_split = false
if current_buftype ~= '' or not current_modifiable or current_winfixbuf then
local suitable_win = utils.find_suitable_window()
if suitable_win then
vim.api.nvim_set_current_win(suitable_win)
elseif current_winfixbuf then
vim.cmd('split ' .. vim.fn.fnameescape(cwd_relative))
opened_via_split = true
end
end
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(cwd_relative)) end
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
end
--- Try to open the file/path under the cursor.
---
--- Picks up the `<cWORD>` (whitespace-delimited token) from the current line,
--- strips wrapping punctuation (`[]`, `()`, quotes, trailing `,`/`.`/etc.),
--- and tries to open it. Resolution order:
---
--- 1. **Direct path**: if the cWORD looks like a path (has `/`, `\`, or
--- `~`) and resolves to a real file (absolute, or relative to the
--- picker's `base_path`, then to neovim's cwd), open it directly. A
--- `:line:col` suffix is parsed and the cursor jumps to that location.
--- This skips the fuzzy picker entirely — when the user has clearly
--- typed a path, we don't second-guess them.
--- 2. **Fuzzy match**: otherwise run a fuzzy search. If exactly one file
--- matches, or the top hit is an exact-path match, open it.
--- 3. **Picker UI fallback**: if the cWORD looks like a path but several
--- files match ambiguously, open the picker UI with the cWORD as a
--- starter query.
--- 4. **No-op**: if the cWORD is empty or matches nothing (and isn't a
--- resolvable path), do nothing — no surprise UI popup.
---
--- `:edit` is window-aware: if the current window has `winfixbuf` or a
--- special buftype, the file is opened in another suitable window or via
--- `:split`.
---
--- The optional `open_cb` is invoked **before** `:edit` runs with
--- `(absolute_path, relative_path)` — useful for plugins that want to mirror
--- the open into a side panel, log the access, etc.
--- @param open_cb fun(abs_path: string, relative_path: string)|nil
function M.open_file_under_cursor(open_cb)
local full_path_with_suffix = vim.fn.expand('<cWORD>')
local raw_word = vim.fn.expand('<cWORD>')
local query = strip_path_wrappers(raw_word)
if not query or query == '' then return end
-- Fast path: cWORD looks like a path AND resolves on disk → just open it.
-- This catches the common `gf`-on-`./file_picker.rs` case where the fuzzy
-- search would otherwise return many substring matches and pop the UI.
local path_part, location = split_location_suffix(query)
if looks_like_path(path_part) then
local resolved = resolve_existing_file(path_part)
if resolved then
open_resolved_file(resolved, path_part, location, open_cb)
return
end
end
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if not picker_ok then
@@ -223,17 +581,24 @@ function M.open_file_under_cursor(open_cb)
return
end
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
if open_cb and type(open_cb) == 'function' then open_cb(files[1].relative_path) end
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].relative_path)))
picker_ui.open_with_callback(query, function(files, _, fuzzy_location, get_file_score)
-- Empty results: don't pop up the picker UI on words that aren't paths.
if not files or #files == 0 then return true end
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
return true
else
return false -- Open UI with results
local first_score = get_file_score and get_file_score(1) or nil
local exact = first_score and first_score.exact_match or false
if #files ~= 1 and not exact then
-- Ambiguous: let the picker UI surface the candidates.
return false
end
local utils = require('fff.utils')
local item = files[1]
local abs_path = utils.canonicalize_fff_path(item.relative_path)
if not abs_path then return true end
open_resolved_file(abs_path, item.relative_path, fuzzy_location, open_cb)
return true
end)
end
+137 -77
View File
@@ -47,7 +47,7 @@ M.state = {
cursor = 1,
top = 1,
query = '',
item_line_map = {},
line_to_item = {},
location = nil, -- Current location from search results
-- History cycling state
@@ -113,6 +113,17 @@ M.state = {
suggestion_source = nil,
}
function M.resolve_winhl(kind)
local hl = M.state.config.hl
local winhl = hl.winhl
local default_winhl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
if winhl == nil then return default_winhl end
if type(winhl) == 'string' then return winhl end
if type(winhl) == 'table' then return winhl[kind] or default_winhl end
return default_winhl
end
local function open_preview(win_cfg)
if not win_cfg then return end
if M.state.preview_win and vim.api.nvim_win_is_valid(M.state.preview_win) then return end
@@ -128,8 +139,7 @@ local function open_preview(win_cfg)
M.state.preview_win = vim.api.nvim_open_win(M.state.preview_buf, false, win_cfg)
local hl = M.state.config.hl
local win_hl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
local win_hl = M.resolve_winhl('preview')
local cursorlineopt = utils.resolve_config_value(
preview_config.cursorlineopt,
vim.o.columns,
@@ -269,8 +279,9 @@ function M.setup_buffers()
end
function M.setup_windows()
local hl = M.state.config.hl
local win_hl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
local prompt_win_hl = M.resolve_winhl('prompt')
local list_win_hl = M.resolve_winhl('list')
local file_info_win_hl = M.resolve_winhl('file_info')
vim.api.nvim_set_option_value('wrap', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.input_win })
@@ -278,7 +289,7 @@ function M.setup_windows()
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.input_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.input_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.input_win })
vim.api.nvim_set_option_value('winhighlight', prompt_win_hl, { win = M.state.input_win })
vim.api.nvim_set_option_value('wrap', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.list_win })
@@ -286,7 +297,7 @@ function M.setup_windows()
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('signcolumn', 'yes:1', { win = M.state.list_win }) -- Enable signcolumn for git status borders
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.list_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.list_win })
vim.api.nvim_set_option_value('winhighlight', list_win_hl, { win = M.state.list_win })
if M.state.file_info_win and vim.api.nvim_win_is_valid(M.state.file_info_win) then
vim.api.nvim_set_option_value('wrap', false, { win = M.state.file_info_win })
@@ -295,7 +306,7 @@ function M.setup_windows()
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.file_info_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.file_info_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.file_info_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.file_info_win })
vim.api.nvim_set_option_value('winhighlight', file_info_win_hl, { win = M.state.file_info_win })
end
local picker_group = vim.api.nvim_create_augroup('fff_picker_focus', { clear = true })
@@ -385,6 +396,36 @@ function M.focus_preview_win()
vim.api.nvim_set_current_win(M.state.preview_win)
end
local function handle_mouse_click_or_fallback(action, fallback)
local pos = vim.fn.getmousepos()
if M.state.active and pos.winid == M.state.list_win then
local item_idx = M.state.line_to_item[pos.line]
if not item_idx then return '' end
vim.schedule(function()
if not M.state.active then return end
if not M.state.filtered_items[item_idx] then return end
if M.state.cursor ~= item_idx then
M.state.cursor = item_idx
M.render_list()
if M.state.mode == 'grep' or M.state.suggestion_source == 'grep' then
M.update_preview_smart()
else
M.update_preview()
end
M.update_status()
end
if action then M.select(action) end
end)
return ''
end
return fallback
end
local function move_list_cursor(direction)
if not M.state.active then return end
@@ -442,6 +483,7 @@ function M.setup_keymaps()
set_keymap('i', keymaps.cycle_forward_query, M.cycle_forward_query, input_opts)
set_keymap('n', 'j', M.move_down, input_opts)
set_keymap('n', 'k', M.move_up, input_opts)
set_keymap('n', 'q', M.close, input_opts)
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
@@ -463,6 +505,20 @@ function M.setup_keymaps()
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, M.send_to_quickfix, input_opts)
set_keymap({ 'i', 'n' }, keymaps.cycle_grep_modes, M.cycle_grep_modes, input_opts)
local input_mouse_opts = vim.tbl_extend('force', input_opts, { expr = true, replace_keycodes = true })
set_keymap(
{ 'i', 'n' },
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
input_mouse_opts
)
set_keymap(
{ 'i', 'n' },
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
input_mouse_opts
)
-- List buffer
set_keymap('n', keymaps.close, M.close, list_opts)
set_keymap('n', 'q', M.close, list_opts)
@@ -480,6 +536,20 @@ function M.setup_keymaps()
set_keymap('n', keymaps.toggle_select, M.toggle_select, list_opts)
set_keymap('n', keymaps.send_to_quickfix, M.send_to_quickfix, list_opts)
local list_mouse_opts = vim.tbl_extend('force', list_opts, { expr = true, replace_keycodes = true })
set_keymap(
'n',
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
list_mouse_opts
)
set_keymap(
'n',
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
list_mouse_opts
)
-- Preview buffer
if M.state.preview_buf then
local preview_opts = { buffer = M.state.preview_buf, noremap = true, silent = true }
@@ -1243,11 +1313,21 @@ function M.render_list()
local ctx = build_render_context()
if M.state.mode == 'grep' and #ctx.items == 0 then
M.state.line_to_item = {}
render_grep_empty_state(ctx)
return
end
local separator_line = list_renderer.render(ctx, M.state.list_buf, M.state.list_win, M.state.ns_id)
local item_to_lines, separator_line = list_renderer.render(ctx, M.state.list_buf, M.state.list_win, M.state.ns_id)
local line_to_item = {}
for item_idx, mapping in pairs(item_to_lines) do
for line = mapping.first, mapping.last do
line_to_item[line] = item_idx
end
end
M.state.line_to_item = line_to_item
-- For bottom prompt, always ensure content is anchored at the bottom after rendering
-- This prevents results from appearing in the middle when there are few items
if ctx.prompt_position == 'bottom' then scroll_to_bottom() end
@@ -1855,48 +1935,18 @@ end
--- Check whether the given window has 'winfixbuf' enabled.
--- pcall-guarded so this stays safe on Neovim versions that predate the option.
--- @param win number Window ID
--- @return boolean
local function window_has_winfixbuf(win)
local ok, val = pcall(vim.api.nvim_get_option_value, 'winfixbuf', { win = win })
return ok and val == true
end
local window_has_winfixbuf = utils.window_has_winfixbuf
--- Find the first visible window with a normal file buffer
--- Find the first visible window with a normal file buffer, skipping the
--- picker's own floats.
--- @return number|nil Window ID of the first suitable window, or nil if none found
local function find_suitable_window()
local current_tabpage = vim.api.nvim_get_current_tabpage()
local windows = vim.api.nvim_tabpage_list_wins(current_tabpage)
for _, win in ipairs(windows) do
if vim.api.nvim_win_is_valid(win) then
local buf = vim.api.nvim_win_get_buf(win)
if vim.api.nvim_buf_is_valid(buf) then
local buftype = vim.api.nvim_get_option_value('buftype', { buf = buf })
local modifiable = vim.api.nvim_get_option_value('modifiable', { buf = buf })
local filetype = vim.api.nvim_get_option_value('filetype', { buf = buf })
local is_picker_window = (
win == M.state.input_win
or win == M.state.list_win
or win == M.state.preview_win
or win == M.state.file_info_win
)
if
(buftype == '' or buftype == 'acwrite')
and modifiable
and not is_picker_window
and filetype ~= 'undotree'
and not window_has_winfixbuf(win)
then
return win
end
end
end
end
return nil
local exclude = {}
exclude[M.state.input_win or -1] = true
exclude[M.state.list_win or -1] = true
exclude[M.state.preview_win or -1] = true
exclude[M.state.file_info_win or -1] = true
return utils.find_suitable_window(exclude)
end
--- Build a unique key for a grep match occurrence.
@@ -2099,39 +2149,40 @@ function M.select(action)
vim.cmd('stopinsert')
M.close()
if action == 'edit' then
local current_win = vim.api.nvim_get_current_win()
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = window_has_winfixbuf(current_win)
-- Defer file open past picker float teardown. Without this, foldexpr is not
-- recomputed on the new window (folds appear missing) on some platforms.
vim.schedule(function()
if action == 'edit' then
local current_win = vim.api.nvim_get_current_win()
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
local current_winfixbuf = window_has_winfixbuf(current_win)
-- If the current window can't host a new buffer (special buftype, non-modifiable,
-- or 'winfixbuf' locking it), retarget a suitable window or fall back to a split.
-- Without this, :edit raises E1513 ("Cannot switch buffer. 'winfixbuf' is enabled")
-- whenever the picker is invoked from a window pinned via :h winfixbuf.
local opened_via_split = false
if current_buftype ~= '' or not current_buf_modifiable or current_winfixbuf then
local suitable_win = find_suitable_window()
if suitable_win then
vim.api.nvim_set_current_win(suitable_win)
elseif current_winfixbuf then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
opened_via_split = true
-- If the current window can't host a new buffer (special buftype, non-modifiable,
-- or 'winfixbuf' locking it), retarget a suitable window or fall back to a split.
-- Without this, :edit raises E1513 ("Cannot switch buffer. 'winfixbuf' is enabled")
-- whenever the picker is invoked from a window pinned via :h winfixbuf.
local opened_via_split = false
if current_buftype ~= '' or not current_buf_modifiable or current_winfixbuf then
local suitable_win = find_suitable_window()
if suitable_win then
vim.api.nvim_set_current_win(suitable_win)
elseif current_winfixbuf then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
opened_via_split = true
end
end
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end
elseif action == 'split' then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
elseif action == 'vsplit' then
vim.cmd('vsplit ' .. vim.fn.fnameescape(relative_path))
elseif action == 'tab' then
vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path))
end
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(relative_path)) end
elseif action == 'split' then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
elseif action == 'vsplit' then
vim.cmd('vsplit ' .. vim.fn.fnameescape(relative_path))
elseif action == 'tab' then
vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path))
end
-- Derive side effects on vim schedule to ensure they run after the file is opened
vim.schedule(function()
if location then location_utils.jump_to_location(location) end
if query and query ~= '' then
@@ -2287,6 +2338,7 @@ function M.close()
M.state.grep_regex_fallback_error = nil
M.state.suggestion_items = nil
M.state.suggestion_source = nil
M.state.renderer = nil
M.state.restore_paste = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
@@ -2409,6 +2461,14 @@ end
function M.open_with_callback(query, callback, opts)
if M.state.active then return false end
-- open_with_callback runs the file-picker flow, never grep. Reset the
-- renderer/mode/grep_config defensively so we can't inherit stale state
-- from a previous live_grep session (close() must always do this too,
-- but belt-and-braces).
M.state.renderer = nil
M.state.mode = nil
M.state.grep_config = nil
local merged_config, base_path = initialize_picker(opts)
if not merged_config then return false end
+35
View File
@@ -102,4 +102,39 @@ function M.canonicalize_fff_path(relative_path)
return vim.fs.normalize(base .. '/' .. path)
end
--- Whether a window has `winfixbuf` set (cannot host a different buffer).
--- @param win number Window ID
--- @return boolean
function M.window_has_winfixbuf(win)
local ok, val = pcall(vim.api.nvim_get_option_value, 'winfixbuf', { win = win })
return ok and val == true
end
--- Find the first window in the current tabpage that can host a regular file
--- buffer (writable, not locked, not the picker's own floats).
--- @param exclude_wins? table<number, boolean> Optional set of window IDs to skip.
--- @return number|nil
function M.find_suitable_window(exclude_wins)
exclude_wins = exclude_wins or {}
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(vim.api.nvim_get_current_tabpage())) do
if vim.api.nvim_win_is_valid(win) and not exclude_wins[win] then
local buf = vim.api.nvim_win_get_buf(win)
if vim.api.nvim_buf_is_valid(buf) then
local buftype = vim.api.nvim_get_option_value('buftype', { buf = buf })
local modifiable = vim.api.nvim_get_option_value('modifiable', { buf = buf })
local filetype = vim.api.nvim_get_option_value('filetype', { buf = buf })
if
(buftype == '' or buftype == 'acwrite')
and modifiable
and filetype ~= 'undotree'
and not M.window_has_winfixbuf(win)
then
return win
end
end
end
end
return nil
end
return M
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-darwin-arm64"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-darwin-x64"
}
}
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-arm64-gnu"
},
"libc": ["glibc"]
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-arm64-musl"
},
"libc": ["musl"]
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-x64-gnu"
},
"libc": ["glibc"]
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-x64-musl"
},
"libc": ["musl"]
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-win32-arm64"
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-win32-x64"
}
}
+3 -3
View File
@@ -39,7 +39,7 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff"
},
"keywords": [
@@ -58,9 +58,9 @@
"access": "public"
},
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
"homepage": "https://github.com/dmtrKovalenko/fff#readme",
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
+3 -3
View File
@@ -34,7 +34,7 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-node"
},
"keywords": [
@@ -53,9 +53,9 @@
"access": "public"
},
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
"homepage": "https://github.com/dmtrKovalenko/fff#readme",
"dependencies": {
"ffi-rs": "^1.0.0"
},
+3 -3
View File
@@ -7,12 +7,12 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/pi-fff"
},
"homepage": "https://github.com/dmtrKovalenko/fff.nvim/tree/main/packages/pi-fff",
"homepage": "https://github.com/dmtrKovalenko/fff/tree/main/packages/pi-fff",
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"keywords": [
"pi",
+4
View File
@@ -131,6 +131,10 @@ describe('picker find_files_in_dir path resolution (issue #389)', function()
picker_ui.select('edit')
-- select('edit') defers the actual :edit via vim.schedule (see picker_ui.lua)
-- to let picker float teardown finish before opening the file. Flush here.
vim.wait(2000, function() return vim.api.nvim_buf_get_name(0) ~= '' end)
local bufname = vim.api.nvim_buf_get_name(0)
assert.is_true(bufname ~= '', 'expected :edit to open a buffer with a non-empty name')
+215
View File
@@ -0,0 +1,215 @@
---@diagnostic disable: undefined-field, missing-fields
local plugin_dir = vim.fn.fnamemodify(vim.fn.resolve(debug.getinfo(1, 'S').source:sub(2)), ':h:h')
local log_file = vim.fs.normalize(plugin_dir .. '/fff-test.log')
pcall(vim.fn.delete, log_file)
-- init_tracing uses OnceLock — first caller wins. Direct rust call BEFORE any
-- fff.* require, otherwise core.ensure_initialized() locks tracing to the
-- default config path and our trace dump on CI failure stays empty.
pcall(require('fff.rust').init_tracing, log_file, 'trace')
local fff = require('fff')
local fff_rust = require('fff.rust')
local file_picker = require('fff.file_picker')
local function init_picker_at_plugin_dir(timeout_ms)
fff_rust.init_file_picker(plugin_dir)
vim.wait(100, function() return false end)
fff_rust.wait_for_initial_scan(timeout_ms or 30000)
end
local function find_result_by_name(items, name)
for _, item in ipairs(items) do
if item.name == name then return item end
end
return nil
end
describe('programmatic search APIs', function()
describe('against the actual fff.nvim repo', function()
before_each(function()
pcall(vim.api.nvim_del_augroup_by_name, 'fff_file_tracking')
vim.g.fff = {}
file_picker.setup()
init_picker_at_plugin_dir()
end)
after_each(function()
pcall(fff_rust.stop_background_monitor)
pcall(fff_rust.cleanup_file_picker)
vim.g.fff = nil
end)
describe('file_search', function()
it('defaults to mode=files and finds this very test file', function()
local result = fff.file_search('programmatic_search_spec')
assert.is_table(result)
assert.is_table(result.items)
assert.is_true(#result.items > 0, 'expected at least one match')
assert.is_number(result.total_matched)
assert.is_number(result.total_files)
local hit = find_result_by_name(result.items, 'programmatic_search_spec.lua')
assert.is_not_nil(hit, 'this spec file should appear in its own search results')
---@cast hit -nil
assert.are.equal('file', hit.type)
assert.is_string(hit.relative_path)
local normalized = vim.fs.normalize(hit.relative_path)
assert.is_true(
normalized:find('tests/', 1, true) ~= nil,
'expected relative_path under tests/, got ' .. tostring(hit.relative_path)
)
assert.is_number(hit.size)
end)
it('mode=directories finds the lua/fff/file_picker directory', function()
local result = fff.file_search('file_picker', { mode = 'directories' })
assert.is_true(#result.items > 0, 'expected at least one directory match')
local lua_dir
for _, item in ipairs(result.items) do
if item.name == 'file_picker' and vim.fs.normalize(item.relative_path):find('lua/fff/', 1, true) then
lua_dir = item
break
end
end
assert.is_not_nil(lua_dir, 'lua/fff/file_picker/ missing from directory results')
assert.are.equal('directory', lua_dir.type)
assert.is_nil(lua_dir.size, 'DirItem must not carry file-only fields')
assert.is_nil(lua_dir.is_binary)
end)
it('mode=mixed returns both files and directories with type tags', function()
local result = fff.file_search('file_picker', { mode = 'mixed' })
assert.is_true(#result.items > 0, 'mixed search returned nothing')
assert.is_number(result.total_files)
assert.is_number(result.total_dirs)
local seen_file, seen_dir = false, false
for _, item in ipairs(result.items) do
if item.type == 'file' then seen_file = true end
if item.type == 'directory' then seen_dir = true end
end
assert.is_true(seen_file, 'mixed search did not return any files for "file_picker"')
assert.is_true(seen_dir, 'mixed search did not return any directories for "file_picker"')
end)
it('rejects invalid mode', function()
assert.has_error(function() fff.file_search('main', { mode = 'bogus' }) end)
end)
end)
describe('content_search', function()
-- Grep for an identifier that we own and is unlikely to disappear:
-- `canonicalize_fff_path` is exported on `fff.utils` and consumed
-- by both `picker_ui.lua` and `main.lua`, so it should appear in at
-- least 2 different files.
local marker = 'canonicalize_fff_path'
it('defaults to plain mode and finds the marker', function()
local result = fff.content_search(marker)
assert.is_table(result)
assert.is_true(#result.items > 0, 'plain content_search returned no matches for ' .. marker)
local seen_files = {}
for _, item in ipairs(result.items) do
assert.is_string(item.relative_path)
assert.is_number(item.line_number)
assert.is_string(item.line_content)
assert.is_true(item.line_content:find(marker, 1, true) ~= nil, 'matched line missing the marker')
seen_files[item.relative_path] = true
end
local count = 0
for _ in pairs(seen_files) do
count = count + 1
end
assert.is_true(count >= 2, 'expected the marker in at least 2 files, got ' .. count)
end)
it('regex mode matches a pattern', function()
local result = fff.content_search('canonicalize_\\w+', { mode = 'regex' })
assert.is_true(#result.items > 0, 'regex content_search returned no matches')
assert.is_nil(result.regex_fallback_error, 'regex compilation should not have fallen back')
end)
it('fuzzy mode tolerates query typos', function()
-- Drop a letter from the marker; fuzzy mode should still match.
local result = fff.content_search('canonicalize_pickr_path', { mode = 'fuzzy' })
assert.is_true(#result.items > 0, 'fuzzy content_search returned no matches')
for _, item in ipairs(result.items) do
assert.is_number(item.fuzzy_score)
end
end)
it('rejects invalid mode', function()
assert.has_error(function() fff.content_search('foo', { mode = 'bogus' }) end)
end)
end)
end)
describe('cwd switching', function()
-- These specifically prove the picker can swap to a directory it has
-- never indexed, so they need an isolated throwaway sandbox by design.
local sandbox_root
before_each(function()
pcall(vim.api.nvim_del_augroup_by_name, 'fff_file_tracking')
vim.g.fff = {}
file_picker.setup()
init_picker_at_plugin_dir()
end)
after_each(function()
pcall(fff_rust.stop_background_monitor)
pcall(fff_rust.cleanup_file_picker)
if sandbox_root then vim.fn.delete(sandbox_root, 'rf') end
sandbox_root = nil
vim.g.fff = nil
end)
it('file_search switches the indexed root and waits for the new scan', function()
sandbox_root = vim.fn.tempname() .. '_other'
local other_filename = 'totally_unique_other.lua'
vim.fn.mkdir(sandbox_root, 'p')
local fd = assert(io.open(sandbox_root .. '/' .. other_filename, 'w'))
fd:write('-- only lives in the other sandbox\n')
fd:close()
-- Sanity: the file does not exist in the primary (fff.nvim) index.
local before = fff.file_search(other_filename)
assert.are.equal(0, #before.items, 'sandbox file leaked into primary fff.nvim index')
local result = fff.file_search(other_filename, { cwd = sandbox_root })
assert.is_true(#result.items > 0, 'cwd switch did not surface file from the new root')
local hit = find_result_by_name(result.items, other_filename)
assert.is_not_nil(hit, 'expected file from the new cwd missing from results')
end)
it('file_search returns an empty result for a non-existent cwd', function()
local missing = vim.fn.tempname() .. '_does_not_exist'
local result = fff.file_search('main', { cwd = missing })
assert.are.equal(0, #result.items)
assert.are.equal(0, result.total_matched)
end)
it('content_search switches indexed root before grepping', function()
sandbox_root = vim.fn.tempname() .. '_other_grep'
vim.fn.mkdir(sandbox_root, 'p')
-- Build the marker by concatenation so the literal string doesn't
-- appear anywhere in the fff.nvim tree (otherwise the "before" grep
-- would find this very test file via its own marker constant).
local marker = 'isolated_grep' .. '_marker_xyzzy'
local fd = assert(io.open(sandbox_root .. '/grep_target.lua', 'w'))
fd:write('-- ' .. marker .. '\n')
fd:close()
-- Marker must not exist anywhere in the primary fff.nvim tree.
local before = fff.content_search(marker)
assert.are.equal(0, #before.items, 'marker leaked into primary fff.nvim tree')
local result = fff.content_search(marker, { cwd = sandbox_root })
assert.is_true(#result.items > 0, 'cwd switch did not surface match from the new root')
end)
end)
end)