Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b667de49fb | |||
| ba8907f683 | |||
| a0c66d9bb1 | |||
| 785464eb9e | |||
| ae6d9e7bfe | |||
| 1055c05a89 | |||
| 5e53b6e8cf | |||
| 2853edf3f2 | |||
| 030a583b37 | |||
| ca6eae5f3c | |||
| 21a3078f69 | |||
| c23ccb39d7 | |||
| f26fe14fed | |||
| 5c3a615e76 | |||
| cf3dcf6d0b | |||
| 76dbd5fd47 | |||
| c84db53454 | |||
| 99f5160e1c | |||
| b5a7967fd2 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,3 +27,6 @@ scripts/benchmark-results/
|
||||
|
||||
# Instruments traces
|
||||
*.trace/
|
||||
|
||||
# Test logs
|
||||
fff-test.log
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
// }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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
@@ -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'
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user