Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 434344f6e9 | |||
| 6a3e481175 | |||
| 29e13ac3d4 | |||
| c9137b19b6 |
@@ -49,6 +49,23 @@ jobs:
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
|
||||
|
||||
- name: Verify Windows DLL has no unexpected dependencies
|
||||
if: matrix.target
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
|
||||
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
|
||||
$dumpbin = Get-ChildItem "$vsPath" -Recurse -Filter "dumpbin.exe" | Select-Object -First 1
|
||||
if (-not $dumpbin) { Write-Error "dumpbin.exe not found"; exit 1 }
|
||||
|
||||
$deps = & $dumpbin.FullName /DEPENDENTS target\release\fff_nvim.dll | Out-String
|
||||
Write-Host $deps
|
||||
# zlob must be statically linked - fail if zlob.dll appears as a dependency
|
||||
if ($deps -match 'zlob\.dll') {
|
||||
Write-Error "fff_nvim.dll has unexpected dynamic dependency on zlob.dll - zlob should be statically linked"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, feat/binaries]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -110,39 +110,55 @@ jobs:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-gnu
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-gnu
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-x64-musl
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
|
||||
npm_package: fff-bun-linux-arm64-musl
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-x64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
|
||||
npm_package: fff-bun-darwin-arm64
|
||||
lib_filename: libfff_c.dylib
|
||||
ext: dylib
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-x64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
|
||||
npm_package: fff-bun-win32-arm64
|
||||
lib_filename: fff_c.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
@@ -181,12 +197,24 @@ jobs:
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: Prepare npm package
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
cp "c-lib-${{ matrix.target }}.${{ matrix.ext }}" "packages/${{ matrix.npm_package }}/${{ matrix.lib_filename }}"
|
||||
|
||||
- name: Upload C library artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: c-lib-${{ matrix.target }}
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
path: packages/${{ matrix.npm_package }}/
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
@@ -231,6 +259,11 @@ jobs:
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Remove npm package artifacts from release binaries
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
rm -rf npm-*
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
@@ -267,6 +300,97 @@ jobs:
|
||||
## C FFI Library (for Bun/Node/Python)
|
||||
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
|
||||
|
||||
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/feat/binaries'))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/binaries'))
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
# Read the base version from fff-core Cargo.toml (single source of truth)
|
||||
base_version=$(grep '^version' crates/fff-core/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
short_sha=$(git rev-parse --short HEAD)
|
||||
|
||||
# Always publish as nightly prerelease: X.Y.Z-nightly.<short-sha>
|
||||
echo "version=${base_version}-nightly.${short_sha}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tag=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download npm package artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: ./npm-packages
|
||||
|
||||
- name: Publish platform packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
for pkg_dir in ./npm-packages/npm-*/; do
|
||||
if [ -d "$pkg_dir" ]; then
|
||||
pkg_name=$(node -p "require('${pkg_dir}package.json').name")
|
||||
echo "Publishing ${pkg_name}@${VERSION} with tag ${TAG}..."
|
||||
|
||||
# Update version in package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('${pkg_dir}package.json', 'utf8'));
|
||||
pkg.version = '${VERSION}';
|
||||
fs.writeFileSync('${pkg_dir}package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
cd "$pkg_dir"
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish ${pkg_name} (may already exist)"
|
||||
cd -
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish main package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
echo "Publishing @ff-labs/fff-bun@${VERSION} with tag ${TAG}..."
|
||||
|
||||
# Update version and optionalDependencies versions in the main package
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('./packages/fff-bun/package.json', 'utf8'));
|
||||
pkg.version = '${VERSION}';
|
||||
if (pkg.optionalDependencies) {
|
||||
for (const dep of Object.keys(pkg.optionalDependencies)) {
|
||||
pkg.optionalDependencies[dep] = '${VERSION}';
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('./packages/fff-bun/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
cd packages/fff-bun
|
||||
npm publish --tag "$TAG" --access public || echo "Failed to publish @ff-labs/fff-bun (may already exist)"
|
||||
|
||||
comment-on-pr:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c]
|
||||
|
||||
Generated
+50
-4
@@ -78,6 +78,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindet"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5afee99ef5f7527f3944f2bf4f5d443749fa47d43eb1d4f83a36e839be7900a3"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.70.1"
|
||||
@@ -421,6 +427,12 @@ dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -462,15 +474,21 @@ name = "fff-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bindet",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"grep-matcher",
|
||||
"grep-searcher",
|
||||
"heed",
|
||||
"ignore",
|
||||
"memchr",
|
||||
"memmap2",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full 0.7.0",
|
||||
@@ -480,6 +498,7 @@ dependencies = [
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
@@ -640,6 +659,24 @@ dependencies = [
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-matcher"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-searcher"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"grep-matcher",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -1048,6 +1085,15 @@ version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.47"
|
||||
@@ -1137,9 +1183,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d53e16653662cf456e2b8048bc99db6ca15d971f3541f679269a23ed8a42acf"
|
||||
checksum = "7e3f70b45907246d13fbe88cee200ebba81c9b77476028133a527c88bd875bc7"
|
||||
dependencies = [
|
||||
"multiversion",
|
||||
"rayon",
|
||||
@@ -2529,9 +2575,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.2.8"
|
||||
version = "1.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d3608be1d193aac6f33fd38cd47c2bca4938ab41006195e8dcd291d87a5428f"
|
||||
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.9.1",
|
||||
|
||||
+9
-1
@@ -4,33 +4,41 @@ members = [
|
||||
"crates/fff-core",
|
||||
"crates/fff-nvim",
|
||||
"crates/fff-query-parser",
|
||||
"crates/fff-searcher",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
bindet = "0.3"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
dunce = "1.0"
|
||||
# git2 - base config without TLS (each crate adds platform-specific TLS)
|
||||
git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.2.9"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.7.2" }
|
||||
neo_frizbee = "0.8.1"
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
parking_lot = "0.12"
|
||||
pathdiff = "0.2.1"
|
||||
rayon = "1.8.0"
|
||||
regex = "1.11"
|
||||
smallvec = { version = "1.13", features = ["const_generics", "union"] }
|
||||
thiserror = "2.0.10"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -69,6 +69,20 @@ FFF.nvim requires:
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +175,8 @@ require('fff').setup({
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -199,6 +215,13 @@ require('fff').setup({
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -225,6 +248,14 @@ require('fff').setup({
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
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
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -273,11 +304,63 @@ Select multiple files and send them to Neovim's quickfix list (keymaps are confi
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
#### Live Grep Search Modes
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
|
||||
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
|
||||
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
|
||||
|
||||
The current mode is shown on the right side of the input field (e.g., `plain`, `regex`, `fuzzy`) with color-coded highlighting.
|
||||
|
||||
You can customize which modes are available and their cycling order globally in your configuration, or per-call when invoking `live_grep()`.
|
||||
|
||||
**Global configuration:**
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
```lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
#### Cross-Mode Suggestions
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search mode and displays the results as suggestions:
|
||||
|
||||
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
|
||||
- **Grep search with no matches** → shows suggested **file name matches** for the same query
|
||||
|
||||
Suggestions are clearly labeled with a "No results found. Suggested ..." banner (highlighted with `hl.suggestion_header`). You can navigate and select suggestion items just like normal results — selecting a grep suggestion will open the file at the matching line.
|
||||
|
||||
#### Git Status Highlighting
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
|
||||
|
||||
```lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
@@ -292,6 +375,7 @@ hl = {
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
@@ -311,6 +395,7 @@ require('fff').setup({
|
||||
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
|
||||
```lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::ffi::{CString, c_char};
|
||||
use std::ptr;
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use fff_core::{FileItem, GrepMatch, GrepResult, Location, Score, SearchResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Result type returned by all FFI functions
|
||||
@@ -63,6 +63,10 @@ pub struct InitOptions {
|
||||
/// Use unsafe no-lock mode for databases (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub use_unsafe_no_lock: bool,
|
||||
/// Pre-populate mmap caches for all files after initial scan so the first
|
||||
/// grep search is as fast as subsequent ones (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub warmup_mmap_cache: bool,
|
||||
}
|
||||
|
||||
/// Search options (JSON-deserializable)
|
||||
@@ -101,6 +105,7 @@ pub struct FileItemJson {
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl FileItemJson {
|
||||
@@ -115,6 +120,7 @@ impl FileItemJson {
|
||||
modification_frecency_score: item.modification_frecency_score,
|
||||
total_frecency_score: item.total_frecency_score,
|
||||
git_status: format_git_status(item.git_status).to_string(),
|
||||
is_binary: item.is_binary,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,3 +225,113 @@ impl SearchResultJson {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live search) types
|
||||
// ============================================================================
|
||||
|
||||
/// Grep search options (JSON-deserializable)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct GrepSearchOptionsJson {
|
||||
/// Maximum file size to search (bytes, default: 10MB)
|
||||
pub max_file_size: Option<u64>,
|
||||
/// Maximum matches per file (default: 200)
|
||||
pub max_matches_per_file: Option<usize>,
|
||||
/// Smart case: case-insensitive if query is lowercase (default: true)
|
||||
pub smart_case: Option<bool>,
|
||||
/// File-based pagination offset (default: 0)
|
||||
pub file_offset: Option<usize>,
|
||||
/// Maximum matches to return (default: 50)
|
||||
pub page_limit: Option<usize>,
|
||||
/// Search mode: "plain", "regex", or "fuzzy" (default: "plain")
|
||||
pub mode: Option<String>,
|
||||
/// Time budget in milliseconds, 0 = unlimited (default: 0)
|
||||
pub time_budget_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// A single grep match for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GrepMatchJson {
|
||||
/// File metadata
|
||||
pub path: String,
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub git_status: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub is_binary: bool,
|
||||
pub total_frecency_score: i64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
/// Match metadata
|
||||
pub line_number: u64,
|
||||
pub col: usize,
|
||||
pub byte_offset: u64,
|
||||
pub line_content: String,
|
||||
/// Byte offset pairs (start, end) within line_content for highlighting
|
||||
pub match_ranges: Vec<[u32; 2]>,
|
||||
/// Fuzzy match score (only in fuzzy mode)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fuzzy_score: Option<u16>,
|
||||
}
|
||||
|
||||
impl GrepMatchJson {
|
||||
pub fn from_grep_match(m: &GrepMatch, file: &FileItem) -> Self {
|
||||
GrepMatchJson {
|
||||
path: file.path.to_string_lossy().to_string(),
|
||||
relative_path: file.relative_path.clone(),
|
||||
file_name: file.file_name.clone(),
|
||||
git_status: format_git_status(file.git_status).to_string(),
|
||||
size: file.size,
|
||||
modified: file.modified,
|
||||
is_binary: file.is_binary,
|
||||
total_frecency_score: file.total_frecency_score,
|
||||
access_frecency_score: file.access_frecency_score,
|
||||
modification_frecency_score: file.modification_frecency_score,
|
||||
line_number: m.line_number,
|
||||
col: m.col,
|
||||
byte_offset: m.byte_offset,
|
||||
line_content: m.line_content.clone(),
|
||||
match_ranges: m
|
||||
.match_byte_offsets
|
||||
.iter()
|
||||
.map(|&(start, end)| [start, end])
|
||||
.collect(),
|
||||
fuzzy_score: m.fuzzy_score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grep result for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GrepResultJson {
|
||||
pub items: Vec<GrepMatchJson>,
|
||||
pub total_matched: usize,
|
||||
pub total_files_searched: usize,
|
||||
pub total_files: usize,
|
||||
pub filtered_file_count: usize,
|
||||
pub next_file_offset: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regex_fallback_error: Option<String>,
|
||||
}
|
||||
|
||||
impl GrepResultJson {
|
||||
pub fn from_grep_result(result: &GrepResult) -> Self {
|
||||
GrepResultJson {
|
||||
items: result
|
||||
.matches
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let file = result.files[m.file_index];
|
||||
GrepMatchJson::from_grep_match(m, file)
|
||||
})
|
||||
.collect(),
|
||||
total_matched: result.total_match_count,
|
||||
total_files_searched: result.total_files_searched,
|
||||
total_files: result.total_files,
|
||||
filtered_file_count: result.filtered_file_count,
|
||||
next_file_offset: result.next_file_offset,
|
||||
regex_fallback_error: result.regex_fallback_error.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
-8
@@ -18,7 +18,7 @@ use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use ffi_types::{FffResult, InitOptions, ScanProgress, SearchOptions};
|
||||
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
@@ -107,7 +107,7 @@ pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
match FilePicker::new(opts.base_path) {
|
||||
match FilePicker::with_options(opts.base_path, opts.warmup_mmap_cache) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
@@ -223,6 +223,74 @@ pub unsafe extern "C" fn fff_search(
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform content search (grep) across indexed files
|
||||
///
|
||||
/// Searches file contents using the specified mode:
|
||||
/// - "plain" (default): SIMD-accelerated literal text matching
|
||||
/// - "regex": Regular expression matching
|
||||
/// - "fuzzy": Smith-Waterman fuzzy matching per line
|
||||
///
|
||||
/// Results include file metadata and match locations with byte offsets
|
||||
/// for highlighting. Supports file-based pagination via `file_offset`
|
||||
/// and `next_file_offset` in the result.
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_live_grep(
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let opts: GrepSearchOptionsJson = if opts_json.is_null() {
|
||||
GrepSearchOptionsJson::default()
|
||||
} else {
|
||||
unsafe { cstr_to_str(opts_json) }
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let file_picker_guard = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_init first."),
|
||||
};
|
||||
|
||||
let mode = match opts.mode.as_deref() {
|
||||
Some("regex") => fff_core::GrepMode::Regex,
|
||||
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
|
||||
_ => fff_core::GrepMode::PlainText,
|
||||
};
|
||||
|
||||
let parsed = fff_core::grep::parse_grep_query(query_str);
|
||||
|
||||
let options = fff_core::GrepSearchOptions {
|
||||
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
|
||||
max_matches_per_file: opts.max_matches_per_file.unwrap_or(200),
|
||||
smart_case: opts.smart_case.unwrap_or(true),
|
||||
file_offset: opts.file_offset.unwrap_or(0),
|
||||
page_limit: opts.page_limit.unwrap_or(50),
|
||||
mode,
|
||||
time_budget_ms: opts.time_budget_ms.unwrap_or(0),
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), query_str, parsed, &options);
|
||||
|
||||
let json_result = ffi_types::GrepResultJson::from_grep_result(&result);
|
||||
match serde_json::to_string(&json_result) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
Err(e) => FffResult::err(&format!("Failed to serialize grep results: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Index Functions
|
||||
// ============================================================================
|
||||
@@ -325,7 +393,7 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
|
||||
return FffResult::err(&format!("Path does not exist: {}", path_str));
|
||||
}
|
||||
|
||||
let canonical_path = match path.canonicalize() {
|
||||
let canonical_path = match fff_core::path_utils::canonicalize(&path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
|
||||
};
|
||||
@@ -335,13 +403,17 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
// Stop existing picker
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
// Stop existing picker, preserving warmup setting
|
||||
let warmup = if let Some(mut picker) = file_picker.take() {
|
||||
let warmup = picker.warmup_mmap_cache();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
warmup
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Create new picker
|
||||
match FilePicker::new(canonical_path.to_string_lossy().to_string()) {
|
||||
match FilePicker::with_options(canonical_path.to_string_lossy().to_string(), warmup) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
@@ -442,7 +514,7 @@ pub unsafe extern "C" fn fff_track_query(
|
||||
None => return FffResult::err("File path is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let file_path = match PathBuf::from(&path_str).canonicalize() {
|
||||
let file_path = match fff_core::path_utils::canonicalize(path_str) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
|
||||
};
|
||||
|
||||
@@ -26,29 +26,39 @@ tracing = { workspace = true }
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
memchr = "2"
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
memmap2 = { workspace = true }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { version = "1.2.8" }
|
||||
zlob = { workspace = true }
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct BackgroundWatcher {
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
|
||||
@@ -64,8 +65,39 @@ impl BackgroundWatcher {
|
||||
config,
|
||||
)?;
|
||||
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
info!("File watcher initizlieed for path: {}", base_path.display());
|
||||
// Watch only non-ignored directories to avoid flooding the OS event buffer.
|
||||
// On macOS, FSEvents has a fixed-size kernel buffer — watching huge gitignored
|
||||
// directories like `target/` in rust causes buffer overflow, which drops real source file
|
||||
// events. Instead we watch the root non-recursively (for top-level file changes
|
||||
// and new directory detection) and each non-ignored subdirectory recursively.
|
||||
let watch_dirs = collect_non_ignored_dirs(&base_path);
|
||||
|
||||
if watch_dirs.len() > MAX_SELECTIVE_WATCH_DIRS {
|
||||
tracing::warn!(
|
||||
"Too many non-ignored directories ({}/{}) can't efficiently watch them",
|
||||
watch_dirs.len(),
|
||||
MAX_SELECTIVE_WATCH_DIRS
|
||||
);
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
} else {
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::NonRecursive)?;
|
||||
|
||||
for dir in &watch_dirs {
|
||||
match debouncer.watch(dir.as_path(), RecursiveMode::Recursive) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// Non-fatal: directory may have been removed between discovery and watch
|
||||
warn!("Failed to watch directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"File watcher initialized for {} directories under {}",
|
||||
watch_dirs.len(),
|
||||
base_path.display()
|
||||
);
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
@@ -116,6 +148,18 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
continue;
|
||||
}
|
||||
|
||||
// When macOS FSEvents (or other backends) overflow their event buffer, the kernel
|
||||
// drops individual events and emits a Rescan flag telling us to re-scan the subtree.
|
||||
// Without handling this, modified source files can be silently missed.
|
||||
if debounced_event.event.need_rescan() {
|
||||
warn!(
|
||||
"Received rescan event for paths {:?}, triggering full rescan",
|
||||
debounced_event.event.paths
|
||||
);
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
|
||||
for path in &debounced_event.event.paths {
|
||||
if is_ignore_definition_path(path) {
|
||||
@@ -209,11 +253,13 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
// Apply file removals
|
||||
for path in paths_to_remove {
|
||||
picker.remove_file_by_path(path);
|
||||
// No need to invalidate mmap — the FileItem (and its mmap) is dropped
|
||||
}
|
||||
|
||||
// Apply file additions/modifications and collect paths for git status update
|
||||
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in paths_to_add_or_modify {
|
||||
// on_create_or_modify clears the mmap internally when modified time changes
|
||||
if let Some(file) = picker.on_create_or_modify(path) {
|
||||
files_to_update_git_status.push(file.path.clone());
|
||||
}
|
||||
@@ -252,6 +298,10 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
fn trigger_full_rescan() {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
// Note: no need to clear mmaps — they are backed by the kernel page cache
|
||||
// and automatically reflect file changes. Old FileItems (and their mmaps)
|
||||
// are dropped when the picker rebuilds its file list.
|
||||
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
return;
|
||||
@@ -324,3 +374,38 @@ fn is_ignore_definition_path(path: &Path) -> bool {
|
||||
Some(".ignore") | Some(".gitignore")
|
||||
)
|
||||
}
|
||||
|
||||
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
|
||||
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
|
||||
/// selective file watching — only non-ignored directories get a recursive watcher,
|
||||
/// preventing gitignored directories like `target/` from flooding the OS event buffer.
|
||||
fn collect_non_ignored_dirs(base_path: &Path) -> Vec<PathBuf> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let walker = WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
for entry in walker {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
|
||||
// Skip the root directory itself
|
||||
if path == base_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() && !is_git_file(path) {
|
||||
dirs.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use fff_query_parser::FFFQuery;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
use std::fmt::Debug;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
@@ -19,6 +20,25 @@ use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
use crate::{FILE_PICKER, FRECENCY};
|
||||
|
||||
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
|
||||
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
|
||||
#[inline]
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
// Empty files are not binary
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FuzzySearchOptions<'a> {
|
||||
pub max_threads: usize,
|
||||
@@ -77,19 +97,17 @@ impl FileItem {
|
||||
Err(_) => (0, 0),
|
||||
};
|
||||
|
||||
Self {
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
Self::new_raw(
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
file_name_lower: name.to_lowercase(),
|
||||
file_name: name,
|
||||
name,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
}
|
||||
is_binary,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> {
|
||||
@@ -118,6 +136,7 @@ pub struct FilePicker {
|
||||
is_scanning: Arc<AtomicBool>,
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -139,6 +158,10 @@ impl FilePicker {
|
||||
&self.base_path
|
||||
}
|
||||
|
||||
pub fn warmup_mmap_cache(&self) -> bool {
|
||||
self.warmup_mmap_cache
|
||||
}
|
||||
|
||||
pub fn git_root(&self) -> Option<&Path> {
|
||||
self.sync_data.git_workdir.as_deref()
|
||||
}
|
||||
@@ -148,7 +171,20 @@ impl FilePicker {
|
||||
}
|
||||
|
||||
pub fn new(base_path: String) -> Result<Self, Error> {
|
||||
info!("Initializing FilePicker with base_path: {}", base_path);
|
||||
Self::with_options(base_path, false)
|
||||
}
|
||||
|
||||
/// Create a new FilePicker with explicit options.
|
||||
///
|
||||
/// When `warmup_mmap_cache` is `true`, all non-binary files will be mmap'd
|
||||
/// and their pages paged in immediately after the initial scan completes.
|
||||
/// This makes the first grep search as fast as subsequent ones at the cost
|
||||
/// of a longer startup time and higher initial memory pressure.
|
||||
pub fn with_options(base_path: String, warmup_mmap_cache: bool) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}",
|
||||
base_path, warmup_mmap_cache
|
||||
);
|
||||
let path = PathBuf::from(&base_path);
|
||||
if !path.exists() {
|
||||
error!("Base path does not exist: {}", base_path);
|
||||
@@ -164,12 +200,14 @@ impl FilePicker {
|
||||
is_scanning: Arc::clone(&scan_signal),
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
};
|
||||
|
||||
spawn_scan_and_watcher(
|
||||
path.clone(),
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
);
|
||||
|
||||
Ok(picker)
|
||||
@@ -409,6 +447,12 @@ impl FilePicker {
|
||||
let modified = modified.as_secs();
|
||||
if file.modified < modified {
|
||||
file.modified = modified;
|
||||
|
||||
// TODO figure out if we actually need to remap the memory or invalidate
|
||||
// mapping here because on linux and macos with the shared map opening it
|
||||
// should be automatically available everywhere automatically which saves
|
||||
// some time from doing extra remapping on every search
|
||||
file.invalidate_mmap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +512,11 @@ impl FilePicker {
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
self.sync_data = sync
|
||||
self.sync_data = sync;
|
||||
|
||||
if self.warmup_mmap_cache {
|
||||
warmup_mmaps(&self.sync_data.files);
|
||||
}
|
||||
}
|
||||
Err(error) => error!(?error, "Failed to scan file system"),
|
||||
}
|
||||
@@ -493,6 +541,7 @@ fn spawn_scan_and_watcher(
|
||||
base_path: PathBuf,
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
synced_files_count: Arc<AtomicUsize>,
|
||||
warmup_mmap_cache: bool,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
scan_signal.store(true, Ordering::Relaxed);
|
||||
@@ -511,6 +560,10 @@ fn spawn_scan_and_watcher(
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
picker.sync_data = sync;
|
||||
|
||||
if warmup_mmap_cache {
|
||||
warmup_mmaps(&picker.sync_data.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -538,6 +591,38 @@ fn spawn_scan_and_watcher(
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populate mmap caches for all eligible files so the first grep search
|
||||
/// doesn't pay the mmap creation + page fault cost.
|
||||
///
|
||||
/// Each file is mmap'd and a single byte is read to trigger the page fault.
|
||||
/// This runs in parallel using rayon.
|
||||
fn warmup_mmaps(files: &[FileItem]) {
|
||||
let warmup_start = std::time::Instant::now();
|
||||
let warmed = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
files.par_iter().for_each(|file| {
|
||||
if file.is_binary || file.size == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mmap) = file.get_mmap() {
|
||||
// Read the first byte to trigger the initial page fault, which
|
||||
// causes the kernel to start readahead for subsequent pages.
|
||||
// This is cheaper than madvise and portable across all platforms.
|
||||
let _ = std::hint::black_box(mmap.first());
|
||||
|
||||
warmed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
let warmed_count = warmed.load(Ordering::Relaxed);
|
||||
info!(
|
||||
"Mmap warmup completed: {warmed_count}/{} files in {:?}",
|
||||
files.len(),
|
||||
warmup_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
fn scan_filesystem(
|
||||
base_path: &Path,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ mod error;
|
||||
pub mod file_picker;
|
||||
pub mod frecency;
|
||||
pub mod git;
|
||||
pub mod grep;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
@@ -33,6 +34,9 @@ pub use error::{Error, Result};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
// Re-export grep types
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
|
||||
// Re-export query parser types (including Location which moved there)
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
//! Path utility functions for file picker scoring
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Canonicalize a path, resolving symlinks and producing an absolute path.
|
||||
///
|
||||
/// On Windows, uses `dunce::canonicalize` to avoid the `\\?\` extended-length path prefix
|
||||
/// that `std::fs::canonicalize` produces. Neovim cannot open paths with this prefix.
|
||||
/// On other platforms, delegates directly to `std::fs::canonicalize`.
|
||||
#[cfg(windows)]
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
dunce::canonicalize(path)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// Calculate distance penalty based on directory proximity
|
||||
/// Returns a negative penalty score based on how far the candidate is from the current file
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
|
||||
|
||||
@@ -31,8 +31,10 @@ pub struct QueryTracker {
|
||||
env: Env,
|
||||
// Database for (project_path, query) -> QueryMatchEntry mappings
|
||||
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings (file picker)
|
||||
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings (grep)
|
||||
grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
}
|
||||
|
||||
impl DbHealthChecker for QueryTracker {
|
||||
@@ -45,10 +47,15 @@ impl DbHealthChecker for QueryTracker {
|
||||
|
||||
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_grep_histories = self
|
||||
.grep_query_history_db
|
||||
.len(&rtxn)
|
||||
.map_err(Error::DbRead)?;
|
||||
|
||||
Ok(vec![
|
||||
("query_file_entries", count_queries),
|
||||
("query_history_entries", count_histories),
|
||||
("grep_query_history_entries", count_grep_histories),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -77,6 +84,9 @@ impl QueryTracker {
|
||||
let query_history_db = env
|
||||
.create_database(&mut wtxn, Some("query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let grep_query_history_db = env
|
||||
.create_database(&mut wtxn, Some("grep_query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
@@ -84,6 +94,7 @@ impl QueryTracker {
|
||||
env,
|
||||
query_file_db,
|
||||
query_history_db,
|
||||
grep_query_history_db,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +126,57 @@ impl QueryTracker {
|
||||
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
/// Append a query to a history database within an existing write transaction.
|
||||
fn append_to_history(
|
||||
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
wtxn: &mut heed::RwTxn,
|
||||
project_key: &[u8; 32],
|
||||
query: &str,
|
||||
now: u64,
|
||||
) -> Result<(), Error> {
|
||||
let mut history = db
|
||||
.get(wtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
history.push_back(HistoryEntry {
|
||||
query: query.to_string(),
|
||||
timestamp: now,
|
||||
});
|
||||
while history.len() > MAX_HISTORY_ENTRIES {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
db.put(wtxn, project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a query from a history database at a specific offset.
|
||||
/// offset=0 returns most recent, offset=1 returns 2nd most recent, etc.
|
||||
fn read_history_at_offset(
|
||||
db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
env: &Env,
|
||||
project_key: &[u8; 32],
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let mut history = db
|
||||
.get(&rtxn, project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
if history.len() > offset {
|
||||
let index = history.len() - 1 - offset;
|
||||
let record = history.remove(index);
|
||||
Ok(record.map(|r| r.query))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_query_completion(
|
||||
&mut self,
|
||||
query: &str,
|
||||
@@ -166,24 +228,7 @@ impl QueryTracker {
|
||||
|
||||
// Update query history database
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&wtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let history_entry = HistoryEntry {
|
||||
query: query.to_string(),
|
||||
timestamp: now,
|
||||
};
|
||||
history.push_back(history_entry);
|
||||
while history.len() > MAX_HISTORY_ENTRIES {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
self.query_history_db
|
||||
.put(&mut wtxn, &project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
@@ -237,32 +282,47 @@ impl QueryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get query from history at a specific offset
|
||||
/// Get query from file picker history at a specific offset.
|
||||
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
|
||||
/// Returns None if offset exceeds history length
|
||||
pub fn get_historical_query(
|
||||
&self,
|
||||
project_path: &Path,
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
|
||||
}
|
||||
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&rtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
/// Track a grep query in the grep-specific history.
|
||||
/// Only records query history (no file association tracking needed for grep).
|
||||
pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
|
||||
let now = self.get_now();
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
if history.len() > offset {
|
||||
let index = history.len() - 1 - offset;
|
||||
let record = history.remove(index);
|
||||
Self::append_to_history(
|
||||
&self.grep_query_history_db,
|
||||
&mut wtxn,
|
||||
&project_key,
|
||||
query,
|
||||
now,
|
||||
)?;
|
||||
|
||||
Ok(record.map(|r| r.query))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
tracing::debug!(?query, "Tracked grep query");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get grep query from history at a specific offset.
|
||||
/// offset=0 returns most recent grep query, offset=1 returns 2nd most recent, etc.
|
||||
pub fn get_historical_grep_query(
|
||||
&self,
|
||||
project_path: &Path,
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use rayon::prelude::*;
|
||||
use std::path::MAIN_SEPARATOR;
|
||||
|
||||
// like cow but better
|
||||
enum FileItems<'a> {
|
||||
pub(crate) enum FileItems<'a> {
|
||||
/// All files — borrows the original owned slice, zero allocation.
|
||||
All(&'a [FileItem]),
|
||||
/// Filtered subset — owns references produced by constraint filtering.
|
||||
@@ -355,7 +355,7 @@ fn is_special_entry_point_file(filename: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Score files by frecency when we have a filtered list (prefiltered by constraints)
|
||||
fn score_filtered_by_frecency<'a>(
|
||||
pub(crate) fn score_filtered_by_frecency<'a>(
|
||||
files: &FileItems<'a>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
@@ -485,19 +485,16 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file = FileItem {
|
||||
path: PathBuf::from(path),
|
||||
relative_path: path.to_string(),
|
||||
relative_path_lower: path.to_lowercase(),
|
||||
file_name: path.split('/').last().unwrap_or(path).to_string(),
|
||||
file_name_lower: path.split('/').last().unwrap_or(path).to_lowercase(),
|
||||
size: 0,
|
||||
let file_name = path.split('/').last().unwrap_or(path).to_string();
|
||||
let file = FileItem::new_raw(
|
||||
PathBuf::from(path),
|
||||
path.to_string(),
|
||||
file_name,
|
||||
0,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
};
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let score_obj = Score {
|
||||
total: score,
|
||||
base_score: score,
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use memmap2::Mmap;
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// A single indexed file with metadata, frecency scores, and lazy mmap.
|
||||
///
|
||||
/// The `mmap` field holds the memory-mapped file contents, initialized lazily
|
||||
/// on the first grep access and cached for subsequent searches. The mmap is
|
||||
/// backed by the kernel page cache and automatically reflects file modifications
|
||||
/// — no manual invalidation is needed.
|
||||
///
|
||||
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
|
||||
/// Each file is only searched by one rayon worker at a time via `par_iter`.
|
||||
#[derive(Debug)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
@@ -17,6 +29,101 @@ pub struct FileItem {
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_binary: bool,
|
||||
/// Lazily-initialized memory-mapped file contents for grep.
|
||||
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
|
||||
/// Automatically reflects file changes via the kernel page cache.
|
||||
mmap: OnceLock<Mmap>,
|
||||
}
|
||||
|
||||
impl Clone for FileItem {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
relative_path: self.relative_path.clone(),
|
||||
relative_path_lower: self.relative_path_lower.clone(),
|
||||
file_name: self.file_name.clone(),
|
||||
file_name_lower: self.file_name_lower.clone(),
|
||||
size: self.size,
|
||||
modified: self.modified,
|
||||
access_frecency_score: self.access_frecency_score,
|
||||
modification_frecency_score: self.modification_frecency_score,
|
||||
total_frecency_score: self.total_frecency_score,
|
||||
git_status: self.git_status,
|
||||
is_binary: self.is_binary,
|
||||
// Don't clone the mmap — the clone lazily re-creates it on demand
|
||||
mmap: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MMAP_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
impl FileItem {
|
||||
/// Create a new `FileItem` with all fields specified and an empty (not yet loaded) mmap.
|
||||
pub fn new_raw(
|
||||
path: PathBuf,
|
||||
relative_path: String,
|
||||
file_name: String,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
is_binary: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
size,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status,
|
||||
is_binary,
|
||||
mmap: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
|
||||
///
|
||||
/// Call this when the background watcher detects that the file has been modified.
|
||||
/// While the kernel page cache reflects content changes automatically, a file
|
||||
/// that is truncated (made smaller) while mapped can cause SIGBUS if the search
|
||||
/// accesses pages beyond the new file size. Invalidating the mmap ensures a
|
||||
/// fresh mapping with the correct size is created on the next access.
|
||||
pub fn invalidate_mmap(&mut self) {
|
||||
self.mmap = OnceLock::new();
|
||||
}
|
||||
|
||||
/// Get the cached mmap or lazily create it. Returns `None` if the file
|
||||
/// is too large, empty, or can't be opened/mapped.
|
||||
///
|
||||
/// After the first call, this is lock-free (just an atomic load + pointer deref).
|
||||
/// The mmap is backed by the kernel page cache and automatically reflects
|
||||
/// file modifications — no manual invalidation is needed.
|
||||
#[inline]
|
||||
pub fn get_mmap(&self) -> Option<&Mmap> {
|
||||
if let Some(mmap) = self.mmap.get() {
|
||||
return Some(mmap);
|
||||
}
|
||||
|
||||
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&self.path).ok()?;
|
||||
// SAFETY: The mmap is backed by the kernel page cache and automatically
|
||||
// reflects file modifications. The only risk is SIGBUS if the file is
|
||||
// truncated while mapped
|
||||
let mmap = unsafe { Mmap::map(&file) }.ok()?;
|
||||
|
||||
// If another thread raced us, OnceLock discards our mmap and returns theirs.
|
||||
// This is fine — the duplicate mmap is just dropped.
|
||||
Some(self.mmap.get_or_init(|| mmap))
|
||||
}
|
||||
}
|
||||
|
||||
impl Constrainable for FileItem {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
|
||||
[[bin]]
|
||||
@@ -23,6 +23,14 @@ path = "src/bin/search_profiler.rs"
|
||||
name = "bench_search_only"
|
||||
path = "src/bin/bench_search_only.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "grep_profiler"
|
||||
path = "src/bin/grep_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "grep_vs_rg"
|
||||
path = "src/bin/grep_vs_rg.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
@@ -46,7 +54,7 @@ heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.7.2" }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.6"
|
||||
once_cell = "1.20.2"
|
||||
@@ -55,7 +63,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = "1.2.8"
|
||||
zlob = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -121,8 +121,7 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
@@ -166,7 +165,7 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = match big_repo_path.canonicalize() {
|
||||
let canonical_path = match fff_core::path_utils::canonicalize(&big_repo_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Failed to canonicalize path: {}", e);
|
||||
|
||||
@@ -13,9 +13,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Loading files from: {:?}", canonical_path);
|
||||
|
||||
@@ -38,19 +37,15 @@ fn main() {
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
files.push(FileItem {
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
file_name,
|
||||
size: entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
modified: 0,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
});
|
||||
entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
use fff_core::FileItem;
|
||||
/// Fuzzy grep quality test against ~/dev/lightsource
|
||||
///
|
||||
/// Runs queries through the fuzzy grep pipeline and prints results
|
||||
/// so we can verify match quality.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo run --release --bin fuzzy_grep_test # runs default test queries
|
||||
/// cargo run --release --bin fuzzy_grep_test -- "query" # runs a single user query
|
||||
use fff_core::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 100, // Get plenty of results
|
||||
mode: GrepMode::Fuzzy,
|
||||
time_budget_ms: 0, // No time limit — search all files
|
||||
};
|
||||
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
eprintln!(" Query: \"{}\" ({})", query, label);
|
||||
eprintln!(
|
||||
" Results: {} matches in {} files ({:.2}ms)",
|
||||
result.matches.len(),
|
||||
result.total_files_searched,
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
);
|
||||
eprintln!("══════════════════════════════════════════════════════════════");
|
||||
|
||||
if result.matches.is_empty() {
|
||||
eprintln!(" (no matches)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by file for readability
|
||||
let mut current_file_idx = usize::MAX;
|
||||
for (i, m) in result.matches.iter().enumerate() {
|
||||
if m.file_index != current_file_idx {
|
||||
current_file_idx = m.file_index;
|
||||
let file = &result.files[m.file_index];
|
||||
eprintln!("\n ┌─ {}", file.relative_path);
|
||||
}
|
||||
|
||||
// Truncate long lines for display
|
||||
let display_line = if m.line_content.len() > 100 {
|
||||
format!("{}...", &m.line_content[..100])
|
||||
} else {
|
||||
m.line_content.clone()
|
||||
};
|
||||
|
||||
let score_str = m
|
||||
.fuzzy_score
|
||||
.map(|s| format!("score={}", s))
|
||||
.unwrap_or_else(|| "no-score".to_string());
|
||||
|
||||
let offsets_str = if m.match_byte_offsets.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
// Show what text fragments are highlighted
|
||||
let fragments: Vec<String> = m
|
||||
.match_byte_offsets
|
||||
.iter()
|
||||
.filter_map(|&(s, e)| {
|
||||
m.line_content
|
||||
.get(s as usize..e as usize)
|
||||
.map(|frag| format!("\"{}\"", frag))
|
||||
})
|
||||
.collect();
|
||||
format!(" hl=[{}]", fragments.join(","))
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
" │ L{:<5} [{}{}] {}",
|
||||
m.line_number,
|
||||
score_str,
|
||||
offsets_str,
|
||||
display_line.trim(),
|
||||
);
|
||||
|
||||
// Cap output at 50 lines
|
||||
if i >= 49 {
|
||||
let remaining = result.matches.len() - 50;
|
||||
if remaining > 0 {
|
||||
eprintln!(" │ ... and {} more matches", remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
let repo_path = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/Users/neogoose".to_string()),
|
||||
)
|
||||
.join("dev/lightsource");
|
||||
|
||||
if !repo_path.exists() {
|
||||
eprintln!("Repository not found at: {:?}", repo_path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical =
|
||||
fff_core::path_utils::canonicalize(&repo_path).expect("Failed to canonicalize path");
|
||||
eprintln!("=== Fuzzy Grep Quality Test ===");
|
||||
eprintln!("Repository: {:?}\n", canonical);
|
||||
|
||||
eprintln!("Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
eprintln!(
|
||||
"Loaded {} files ({} non-binary) in {:.2}s\n",
|
||||
files.len(),
|
||||
non_binary,
|
||||
load_start.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
if args.is_empty() {
|
||||
// Run default test queries
|
||||
run_fuzzy_query(&files, "shcema", "transposition of 'schema'");
|
||||
run_fuzzy_query(&files, "SortedMap", "should match SortedArrayMap");
|
||||
run_fuzzy_query(
|
||||
&files,
|
||||
"struct SortedMap",
|
||||
"should NOT match SourcingProjectMetadataParts",
|
||||
);
|
||||
} else {
|
||||
// Run user-provided queries
|
||||
for query in &args {
|
||||
run_fuzzy_query(&files, query, "user query");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("=== Done ===");
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use fff_core::FileItem;
|
||||
/// Live grep benchmark profiler for fff.nvim
|
||||
///
|
||||
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
|
||||
/// Measures cold-cache, warm-cache, and incremental typing latencies to simulate
|
||||
/// real user interaction patterns.
|
||||
///
|
||||
/// Uses direct WalkBuilder scanning (no background thread) for faster startup.
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_profiler
|
||||
/// ./target/release/grep_profiler [--path /path/to/repo]
|
||||
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
struct BenchStats {
|
||||
times: Vec<Duration>,
|
||||
}
|
||||
|
||||
impl BenchStats {
|
||||
fn new() -> Self {
|
||||
Self { times: Vec::new() }
|
||||
}
|
||||
|
||||
fn push(&mut self, d: Duration) {
|
||||
self.times.push(d);
|
||||
}
|
||||
|
||||
fn mean(&self) -> Duration {
|
||||
let total: Duration = self.times.iter().sum();
|
||||
total / self.times.len() as u32
|
||||
}
|
||||
|
||||
fn median(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
sorted[sorted.len() / 2]
|
||||
}
|
||||
|
||||
fn p95(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
let idx = ((sorted.len() as f64) * 0.95) as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
fn p99(&self) -> Duration {
|
||||
let mut sorted = self.times.clone();
|
||||
sorted.sort();
|
||||
let idx = ((sorted.len() as f64) * 0.99) as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
fn min(&self) -> Duration {
|
||||
*self.times.iter().min().unwrap()
|
||||
}
|
||||
|
||||
fn max(&self) -> Duration {
|
||||
*self.times.iter().max().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
struct GrepBench<'a> {
|
||||
files: &'a [FileItem],
|
||||
options: GrepSearchOptions,
|
||||
}
|
||||
|
||||
impl<'a> GrepBench<'a> {
|
||||
fn new(files: &'a [FileItem]) -> Self {
|
||||
Self {
|
||||
files,
|
||||
options: GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single grep search, return (duration, match_count, files_searched)
|
||||
fn run_once(&self, query: &str) -> (Duration, usize, usize) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed, &self.options);
|
||||
let elapsed = start.elapsed();
|
||||
(
|
||||
elapsed,
|
||||
result.total_match_count,
|
||||
result.total_files_searched,
|
||||
)
|
||||
}
|
||||
|
||||
/// Benchmark a query with multiple iterations
|
||||
fn bench_query(&self, query: &str, iterations: usize) -> (BenchStats, usize, usize) {
|
||||
let mut stats = BenchStats::new();
|
||||
let mut last_matches = 0;
|
||||
let mut last_files_searched = 0;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let (elapsed, matches, files_searched) = self.run_once(query);
|
||||
stats.push(elapsed);
|
||||
last_matches = matches;
|
||||
last_files_searched = files_searched;
|
||||
}
|
||||
|
||||
(stats, last_matches, last_files_searched)
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", d.as_secs_f64())
|
||||
} else if us > 1000 {
|
||||
format!("{:.2}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}us", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn print_row(name: &str, stats: &BenchStats, matches: usize, files_searched: usize, iters: usize) {
|
||||
eprintln!(
|
||||
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
|
||||
name,
|
||||
fmt_dur(stats.mean()),
|
||||
fmt_dur(stats.median()),
|
||||
fmt_dur(stats.p95()),
|
||||
fmt_dur(stats.p99()),
|
||||
fmt_dur(stats.min()),
|
||||
fmt_dur(stats.max()),
|
||||
matches,
|
||||
files_searched,
|
||||
iters,
|
||||
);
|
||||
}
|
||||
|
||||
fn print_header() {
|
||||
eprintln!(
|
||||
" {:<24} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>6} | {:>6} | {:>4}",
|
||||
"Name", "Mean", "Median", "P95", "P99", "Min", "Max", "Match", "Files", "Iter"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<24}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<8}-+-{:-<6}-+-{:-<6}-+-{:-<4}",
|
||||
"", "", "", "", "", "", "", "", "", ""
|
||||
);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Parse args
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
|
||||
args.get(idx + 1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("./big-repo")
|
||||
} else {
|
||||
"./big-repo"
|
||||
};
|
||||
|
||||
let repo = std::path::PathBuf::from(repo_path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Repository not found at: {}", repo_path);
|
||||
eprintln!("Usage: grep_profiler [--path /path/to/large/repo]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
eprintln!("=== FFF Live Grep Profiler ===");
|
||||
eprintln!("Repository: {:?}", canonical);
|
||||
|
||||
// Direct file loading (no background thread)
|
||||
eprintln!("\n[1/5] Loading files...");
|
||||
let load_start = Instant::now();
|
||||
let files = load_files(&canonical);
|
||||
let load_time = load_start.elapsed();
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
|
||||
eprintln!(
|
||||
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
|
||||
files.len(),
|
||||
load_time.as_secs_f64(),
|
||||
non_binary,
|
||||
large_files,
|
||||
);
|
||||
|
||||
let bench = GrepBench::new(&files);
|
||||
|
||||
eprintln!("[2/5] Cold cache benchmarks (first search, mmap not yet loaded)");
|
||||
eprintln!(" Each query runs once with fresh FileItem mmaps.\n");
|
||||
print_header();
|
||||
|
||||
let cold_queries: Vec<(&str, &str)> = vec![
|
||||
("cold_common_2char", "if"),
|
||||
("cold_common_word", "return"),
|
||||
("cold_specific_func", "mutex_lock"),
|
||||
("cold_struct_name", "inode_operations"),
|
||||
("cold_define", "MODULE_LICENSE"),
|
||||
("cold_rare_string", "phylink_ethtool"),
|
||||
("cold_path_filter", "printk *.c"),
|
||||
("cold_long_query", "static int __init"),
|
||||
];
|
||||
|
||||
for (name, query) in &cold_queries {
|
||||
// Re-load files to get fresh FileItems with no cached mmaps
|
||||
let fresh_files = load_files(&canonical);
|
||||
let fresh_bench = GrepBench::new(&fresh_files);
|
||||
let (stats, matches, files_searched) = fresh_bench.bench_query(query, 1);
|
||||
print_row(name, &stats, matches, files_searched, 1);
|
||||
}
|
||||
|
||||
eprintln!("\n[3/5] Warm cache benchmarks (mmap cache populated)");
|
||||
eprintln!(" Running 3 warmup iterations, then measuring.\n");
|
||||
print_header();
|
||||
|
||||
let warm_queries: Vec<(&str, &str, usize)> = vec![
|
||||
("warm_2char", "if", 10),
|
||||
("warm_common_word", "return", 10),
|
||||
("warm_function_call", "mutex_lock", 15),
|
||||
("warm_struct_name", "inode_operations", 15),
|
||||
("warm_define", "MODULE_LICENSE", 15),
|
||||
("warm_rare_string", "phylink_ethtool", 20),
|
||||
("warm_include", "#include", 10),
|
||||
("warm_comment", "TODO", 15),
|
||||
("warm_type_decl", "struct file", 15),
|
||||
("warm_error_path", "err = -EINVAL", 15),
|
||||
("warm_long_pattern", "static int __init", 15),
|
||||
("warm_very_common", "int", 10),
|
||||
("warm_single_char", "x", 10),
|
||||
("warm_path_constraint", "printk *.c", 15),
|
||||
("warm_dir_constraint", "mutex /kernel/", 15),
|
||||
];
|
||||
|
||||
// Warmup pass - populate mmap cache
|
||||
for (_, query, _) in &warm_queries {
|
||||
for _ in 0..3 {
|
||||
bench.run_once(query);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, query, iters) in &warm_queries {
|
||||
let (stats, matches, files_searched) = bench.bench_query(query, *iters);
|
||||
print_row(name, &stats, matches, files_searched, *iters);
|
||||
}
|
||||
|
||||
eprintln!("\n[4/5] Incremental typing simulation");
|
||||
eprintln!(" Simulates user typing character by character.\n");
|
||||
|
||||
let typing_sequences: Vec<(&str, Vec<&str>)> = vec![
|
||||
(
|
||||
"mutex_lock",
|
||||
vec![
|
||||
"m",
|
||||
"mu",
|
||||
"mut",
|
||||
"mute",
|
||||
"mutex",
|
||||
"mutex_",
|
||||
"mutex_l",
|
||||
"mutex_lo",
|
||||
"mutex_loc",
|
||||
"mutex_lock",
|
||||
],
|
||||
),
|
||||
("printk", vec!["p", "pr", "pri", "prin", "print", "printk"]),
|
||||
("inode", vec!["i", "in", "ino", "inod", "inode"]),
|
||||
("kfree", vec!["k", "kf", "kfr", "kfre", "kfree"]),
|
||||
];
|
||||
|
||||
for (name, sequence) in &typing_sequences {
|
||||
eprintln!(" Typing '{}' ({} keystrokes):", name, sequence.len());
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
"Query", "Latency", "Match", "Files"
|
||||
);
|
||||
eprintln!(" {:-<16}-+-{:-<8}-+-{:-<6}-+-{:-<6}", "", "", "", "");
|
||||
|
||||
for prefix in sequence {
|
||||
let (elapsed, matches, files_searched) = bench.run_once(prefix);
|
||||
eprintln!(
|
||||
" {:>16} | {:>8} | {:>6} | {:>6}",
|
||||
format!("\"{}\"", prefix),
|
||||
fmt_dur(elapsed),
|
||||
matches,
|
||||
files_searched,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!("[5/5] Pagination benchmark");
|
||||
eprintln!(" Testing page_offset performance for common query.\n");
|
||||
|
||||
let pagination_query = "return";
|
||||
eprintln!(" Query: \"{}\"", pagination_query);
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
"Page", "File offset", "Latency", "Matches", "Next offset"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<6}-+-{:-<12}-+-{:-<8}-+-{:-<6}-+-{:-<12}",
|
||||
"", "", "", "", ""
|
||||
);
|
||||
|
||||
let mut file_offset = 0usize;
|
||||
for page in 0..10 {
|
||||
let parsed = parse_grep_query(pagination_query);
|
||||
let opts = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(&files, pagination_query, parsed, &opts);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
|
||||
page,
|
||||
file_offset,
|
||||
fmt_dur(elapsed),
|
||||
result.matches.len(),
|
||||
result.next_file_offset,
|
||||
);
|
||||
|
||||
if result.next_file_offset == 0 || result.matches.is_empty() {
|
||||
eprintln!(" (no more results)");
|
||||
break;
|
||||
}
|
||||
file_offset = result.next_file_offset;
|
||||
}
|
||||
|
||||
eprintln!("\n=== Summary ===");
|
||||
let mmap_count = files.iter().filter(|f| f.get_mmap().is_some()).count();
|
||||
eprintln!(" Files with cached mmap: {}", mmap_count);
|
||||
eprintln!(" Total indexed files: {}", files.len());
|
||||
eprintln!(" Non-binary files: {}", non_binary);
|
||||
eprintln!(" Files > 10MB (skipped): {}", large_files);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
eprintln!("\nDone. For perf profiling:");
|
||||
eprintln!(" perf record -g --call-graph dwarf -F 999 ./target/release/grep_profiler");
|
||||
eprintln!(" perf report --no-children");
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
use fff_core::FileItem;
|
||||
/// FFF vs ripgrep comparison benchmark
|
||||
///
|
||||
/// Demonstrates why a persistent in-process search engine (fff) is fundamentally
|
||||
/// faster than shelling out to ripgrep on every keystroke (telescope/fzf-lua).
|
||||
///
|
||||
/// Each query is run N iterations to show the real-world advantage:
|
||||
/// - fff: pre-indexed files + cached mmaps = near-zero overhead per search
|
||||
/// - rg: fork/exec + directory traversal + gitignore parsing + file opens per invocation
|
||||
///
|
||||
/// Sections:
|
||||
/// 1. Raw engine speed — fff count-only vs rg --count-matches (N iterations)
|
||||
/// 2. Full results — fff collect-all vs rg full line output (N iterations)
|
||||
/// 3. First-page — fff paginated (50 results) vs rg telescope-style
|
||||
/// (spawn, stream 50 lines, kill) — the real UI scenario (N iterations)
|
||||
///
|
||||
/// The rg commands use telescope's default vimgrep_arguments:
|
||||
/// rg --color=never --no-heading --with-filename --line-number --column --smart-case
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_vs_rg
|
||||
/// ./target/release/grep_vs_rg [--path /path/to/repo] [--iters 5]
|
||||
use fff_core::grep::{GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Number of times each query is repeated (overridable with --iters).
|
||||
const DEFAULT_ITERS: usize = 5;
|
||||
|
||||
fn load_files(base_path: &Path) -> Vec<FileItem> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let mut files = Vec::new();
|
||||
WalkBuilder::new(base_path)
|
||||
.hidden(false)
|
||||
.git_ignore(true)
|
||||
.git_exclude(true)
|
||||
.git_global(true)
|
||||
.ignore(true)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||
let is_binary = detect_binary(&path, size);
|
||||
|
||||
files.push(FileItem::new_raw(
|
||||
path,
|
||||
relative_path,
|
||||
file_name,
|
||||
size,
|
||||
0,
|
||||
None,
|
||||
is_binary,
|
||||
));
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
fn detect_binary(path: &Path, size: u64) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut reader = std::io::BufReader::with_capacity(1024, file);
|
||||
let mut buf = [0u8; 512];
|
||||
let n = reader.read(&mut buf).unwrap_or(0);
|
||||
buf[..n].contains(&0)
|
||||
}
|
||||
|
||||
/// Telescope's default vimgrep_arguments applied to any rg command.
|
||||
/// Also limits rg's thread count to match rayon's pool (fair comparison).
|
||||
fn apply_telescope_args(cmd: &mut Command, threads: usize) {
|
||||
cmd.arg("--color=never")
|
||||
.arg("--no-heading")
|
||||
.arg("--with-filename")
|
||||
.arg("--line-number")
|
||||
.arg("--column")
|
||||
.arg("--smart-case")
|
||||
.arg("--fixed-strings")
|
||||
.arg("--max-filesize")
|
||||
.arg("10M")
|
||||
.arg("--threads")
|
||||
.arg(threads.to_string());
|
||||
}
|
||||
|
||||
/// Run ripgrep counting matches via --count-matches.
|
||||
fn run_rg_count(
|
||||
repo_path: &Path,
|
||||
pattern: &str,
|
||||
case_insensitive: bool,
|
||||
threads: usize,
|
||||
) -> (usize, Duration) {
|
||||
let start = Instant::now();
|
||||
let mut cmd = Command::new("rg");
|
||||
cmd.arg("--count-matches").arg("--no-filename");
|
||||
apply_telescope_args(&mut cmd, threads);
|
||||
if case_insensitive {
|
||||
cmd.arg("--ignore-case");
|
||||
}
|
||||
cmd.arg(pattern).current_dir(repo_path);
|
||||
|
||||
let output = cmd.output().expect("Failed to run rg");
|
||||
let elapsed = start.elapsed();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let count: usize = stdout
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().parse::<usize>().ok())
|
||||
.sum();
|
||||
(count, elapsed)
|
||||
}
|
||||
|
||||
/// Run ripgrep collecting full line output.
|
||||
fn run_rg_lines(
|
||||
repo_path: &Path,
|
||||
pattern: &str,
|
||||
case_insensitive: bool,
|
||||
threads: usize,
|
||||
) -> (usize, Duration) {
|
||||
let start = Instant::now();
|
||||
let mut cmd = Command::new("rg");
|
||||
apply_telescope_args(&mut cmd, threads);
|
||||
if case_insensitive {
|
||||
cmd.arg("--ignore-case");
|
||||
}
|
||||
cmd.arg(pattern).current_dir(repo_path);
|
||||
|
||||
let output = cmd.output().expect("Failed to run rg");
|
||||
let elapsed = start.elapsed();
|
||||
let count = bytecount(&output.stdout, b'\n');
|
||||
(count, elapsed)
|
||||
}
|
||||
|
||||
/// Run ripgrep the way telescope/fzf-lua actually do it: spawn rg as a
|
||||
/// streaming subprocess, read stdout line-by-line, and kill the process
|
||||
/// after `limit` lines. This is the realistic "first page" scenario.
|
||||
fn run_rg_page(
|
||||
repo_path: &Path,
|
||||
pattern: &str,
|
||||
case_insensitive: bool,
|
||||
limit: usize,
|
||||
threads: usize,
|
||||
) -> (usize, Duration) {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut rg_cmd = Command::new("rg");
|
||||
apply_telescope_args(&mut rg_cmd, threads);
|
||||
if case_insensitive {
|
||||
rg_cmd.arg("--ignore-case");
|
||||
}
|
||||
rg_cmd
|
||||
.arg(pattern)
|
||||
.current_dir(repo_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = rg_cmd.spawn().expect("Failed to spawn rg");
|
||||
let stdout = child.stdout.take().expect("Failed to get rg stdout");
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let mut count = 0;
|
||||
for _line in reader.lines() {
|
||||
if _line.is_err() {
|
||||
break;
|
||||
}
|
||||
count += 1;
|
||||
if count >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Kill rg immediately — this is what telescope does when the picker
|
||||
// closes or the query changes (plenary.job:shutdown).
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
(count, elapsed)
|
||||
}
|
||||
|
||||
fn bytecount(bytes: &[u8], needle: u8) -> usize {
|
||||
bytes.iter().filter(|&&b| b == needle).count()
|
||||
}
|
||||
|
||||
/// fff full: collects all GrepMatch structs (what the UI uses).
|
||||
fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: usize::MAX,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: usize::MAX,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.total_match_count, elapsed)
|
||||
}
|
||||
|
||||
/// fff paginated: first 50 results only (real UI scenario).
|
||||
fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let parsed = parse_grep_query(query);
|
||||
let options = GrepSearchOptions {
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
max_matches_per_file: 200,
|
||||
smart_case: true,
|
||||
file_offset: 0,
|
||||
page_limit: 50,
|
||||
mode: Default::default(),
|
||||
time_budget_ms: 0,
|
||||
};
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
// Use matches.len() — the actual truncated page the UI would display,
|
||||
// not total_match_count which includes overshoot from parallel batches.
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct IterStats {
|
||||
min: Duration,
|
||||
avg: Duration,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn run_n<F: Fn() -> (usize, Duration)>(f: F, n: usize) -> IterStats {
|
||||
let mut times = Vec::with_capacity(n);
|
||||
let mut count = 0;
|
||||
for _ in 0..n {
|
||||
let (c, d) = f();
|
||||
count = c;
|
||||
times.push(d);
|
||||
}
|
||||
times.sort();
|
||||
let min = times[0];
|
||||
let avg = times.iter().sum::<Duration>() / n as u32;
|
||||
IterStats { min, avg, count }
|
||||
}
|
||||
|
||||
fn fmt_dur(d: Duration) -> String {
|
||||
let us = d.as_micros();
|
||||
if us > 1_000_000 {
|
||||
format!("{:.2}s", d.as_secs_f64())
|
||||
} else if us > 1000 {
|
||||
format!("{:.1}ms", us as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}us", us)
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio_str(a: Duration, b: Duration) -> String {
|
||||
if a.is_zero() || b.is_zero() {
|
||||
return "-".to_string();
|
||||
}
|
||||
let r = b.as_secs_f64() / a.as_secs_f64();
|
||||
format!("{:.1}x", r)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let repo_path = if let Some(idx) = args.iter().position(|a| a == "--path") {
|
||||
args.get(idx + 1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("./big-repo")
|
||||
} else {
|
||||
"./big-repo"
|
||||
};
|
||||
let iters = if let Some(idx) = args.iter().position(|a| a == "--iters") {
|
||||
args.get(idx + 1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_ITERS)
|
||||
} else {
|
||||
DEFAULT_ITERS
|
||||
};
|
||||
|
||||
let repo = std::path::PathBuf::from(repo_path);
|
||||
if !repo.exists() {
|
||||
eprintln!("Repository not found at: {}", repo_path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let canonical = fff_core::path_utils::canonicalize(&repo).expect("Failed to canonicalize path");
|
||||
|
||||
let rg_version = Command::new("rg")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.expect("ripgrep (rg) not found in PATH");
|
||||
let rg_ver = String::from_utf8_lossy(&rg_version.stdout);
|
||||
|
||||
// Match rg's thread count to rayon's (both default to logical CPU count).
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4);
|
||||
|
||||
eprintln!("=== FFF vs ripgrep (telescope-style) ===");
|
||||
eprintln!("Repo: {:?}", canonical);
|
||||
eprintln!("rg: {}", rg_ver.lines().next().unwrap_or("?"));
|
||||
eprintln!("Threads: {} (rg -j{} = rayon default)", threads, threads);
|
||||
eprintln!("Iterations: {} per query", iters);
|
||||
eprintln!();
|
||||
|
||||
eprintln!("[1/5] Indexing files...");
|
||||
let files = load_files(&canonical);
|
||||
let non_binary = files.iter().filter(|f| !f.is_binary).count();
|
||||
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
|
||||
|
||||
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
|
||||
for q in &["return", "mutex", "struct", "include", "if", "int"] {
|
||||
let _ = run_fff_page(&files, q);
|
||||
let _ = run_rg_count(&canonical, q, true, threads);
|
||||
}
|
||||
eprintln!(" mmap cache: warmed\n");
|
||||
|
||||
// (name, query, case_insensitive_for_rg)
|
||||
let queries: Vec<(&str, &str, bool)> = vec![
|
||||
("single_char", "x", true),
|
||||
("short_common", "if", true),
|
||||
("very_common", "int", true),
|
||||
("common_keyword", "return", true),
|
||||
("preprocessor", "#include", true),
|
||||
("function_call", "mutex_lock", true),
|
||||
("multi_word", "static int __init", true),
|
||||
("type_decl", "struct file", true),
|
||||
("macro_define", "MODULE_LICENSE", false),
|
||||
("kernel_api", "EXPORT_SYMBOL", false),
|
||||
("error_path", "err = -EINVAL", false),
|
||||
("comment_tag", "TODO", false),
|
||||
("struct_name", "inode_operations", true),
|
||||
("rare_symbol", "phylink_ethtool", true),
|
||||
("long_literal", "This program is free software", true),
|
||||
];
|
||||
|
||||
eprintln!(
|
||||
"\n[4/5] Full results: fff (collect all) vs rg (full line output) ({} iters, showing min)\n",
|
||||
iters
|
||||
);
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
"Query", "fff min", "count", "rg min", "count", "fff/rg"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
|
||||
"", "", "", "", "", ""
|
||||
);
|
||||
|
||||
let mut fff_full_total = Duration::ZERO;
|
||||
let mut rg_full_total = Duration::ZERO;
|
||||
|
||||
for (name, query, ci) in &queries {
|
||||
let q = *query;
|
||||
let ci = *ci;
|
||||
let fs = run_n(|| run_fff_full(&files, q), iters);
|
||||
let rs = run_n(|| run_rg_lines(&canonical, q, ci, threads), iters);
|
||||
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
name,
|
||||
fmt_dur(fs.min),
|
||||
fs.count,
|
||||
fmt_dur(rs.min),
|
||||
rs.count,
|
||||
ratio_str(fs.min, rs.min),
|
||||
);
|
||||
|
||||
fff_full_total += fs.min;
|
||||
rg_full_total += rs.min;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
"TOTAL",
|
||||
fmt_dur(fff_full_total),
|
||||
"",
|
||||
fmt_dur(rg_full_total),
|
||||
"",
|
||||
ratio_str(fff_full_total, rg_full_total),
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"\n[5/5] First-page latency — the real UI scenario ({} iters, showing min)",
|
||||
iters
|
||||
);
|
||||
eprintln!(" fff: paginated search (50 matches) from warm mmap cache");
|
||||
eprintln!(" rg: telescope-style (spawn, stream 50 lines, kill) — per-keystroke cost\n");
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
"Query", "fff min", "matches", "rg min", "matches", "fff/rg"
|
||||
);
|
||||
eprintln!(
|
||||
" {:-<22}-+-{:-<9}-{:-<10}-+-{:-<9}-{:-<10}-+-{:-<7}",
|
||||
"", "", "", "", "", ""
|
||||
);
|
||||
|
||||
let mut fff_page_total = Duration::ZERO;
|
||||
let mut rg_page_total = Duration::ZERO;
|
||||
|
||||
for (name, query, ci) in &queries {
|
||||
let q = *query;
|
||||
let ci = *ci;
|
||||
let fs = run_n(|| run_fff_page(&files, q), iters);
|
||||
let rs = run_n(|| run_rg_page(&canonical, q, ci, 50, threads), iters);
|
||||
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
name,
|
||||
fmt_dur(fs.min),
|
||||
fs.count,
|
||||
fmt_dur(rs.min),
|
||||
rs.count,
|
||||
ratio_str(fs.min, rs.min),
|
||||
);
|
||||
|
||||
fff_page_total += fs.min;
|
||||
rg_page_total += rs.min;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
" {:<22} | {:>9} {:>10} | {:>9} {:>10} | {:>7}",
|
||||
"TOTAL",
|
||||
fmt_dur(fff_page_total),
|
||||
"",
|
||||
fmt_dur(rg_page_total),
|
||||
"",
|
||||
ratio_str(fff_page_total, rg_page_total),
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"\n=== Summary (total min across all queries, {} iterations) ===\n",
|
||||
iters
|
||||
);
|
||||
eprintln!(
|
||||
" {:>25} | {:>12} | {:>12} | {:>7}",
|
||||
"", "fff", "rg", "speedup"
|
||||
);
|
||||
eprintln!(" {:->25}-+-{:->12}-+-{:->12}-+-{:->7}", "", "", "", "");
|
||||
eprintln!(
|
||||
" {:>25} | {:>12} | {:>12} | {:>7}",
|
||||
"full results (collect)",
|
||||
fmt_dur(fff_full_total),
|
||||
fmt_dur(rg_full_total),
|
||||
ratio_str(fff_full_total, rg_full_total),
|
||||
);
|
||||
eprintln!(
|
||||
" {:>25} | {:>12} | {:>12} | {:>7}",
|
||||
"first-page (UI latency)",
|
||||
fmt_dur(fff_page_total),
|
||||
fmt_dur(rg_page_total),
|
||||
ratio_str(fff_page_total, rg_page_total),
|
||||
);
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Note: rg cost includes fork/exec + directory traversal + gitignore parsing");
|
||||
eprintln!(" on EVERY invocation (= every keystroke in telescope/fzf-lua).");
|
||||
eprintln!(" fff pays this cost once at startup, then searches from warm cached mmaps.");
|
||||
eprintln!();
|
||||
}
|
||||
@@ -79,9 +79,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
|
||||
|
||||
+114
-2
@@ -106,7 +106,7 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = path.canonicalize().map_err(|e| {
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
@@ -226,6 +226,60 @@ pub fn fuzzy_search_files(
|
||||
lua_types::SearchResultLua::from(results).into_lua(lua)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn live_grep(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
file_offset,
|
||||
page_size,
|
||||
max_file_size,
|
||||
max_matches_per_file,
|
||||
smart_case,
|
||||
grep_mode,
|
||||
time_budget_ms,
|
||||
): (
|
||||
String,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
Option<u64>,
|
||||
Option<usize>,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let parsed = fff_core::grep::parse_grep_query(&query);
|
||||
|
||||
let mode = match grep_mode.as_deref() {
|
||||
Some("regex") => fff_core::GrepMode::Regex,
|
||||
Some("fuzzy") => fff_core::GrepMode::Fuzzy,
|
||||
_ => fff_core::GrepMode::PlainText, // "plain" or nil or unknown
|
||||
};
|
||||
|
||||
let options = fff_core::GrepSearchOptions {
|
||||
max_file_size: max_file_size.unwrap_or(10 * 1024 * 1024),
|
||||
max_matches_per_file: max_matches_per_file.unwrap_or(200),
|
||||
smart_case: smart_case.unwrap_or(true),
|
||||
file_offset: file_offset.unwrap_or(0),
|
||||
page_limit: page_size.unwrap_or(50),
|
||||
mode,
|
||||
time_budget_ms: time_budget_ms.unwrap_or(0),
|
||||
};
|
||||
|
||||
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed, &options);
|
||||
|
||||
lua_types::GrepResultLua::from(result).into_lua(lua)
|
||||
}
|
||||
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_path = PathBuf::from(&file_path);
|
||||
|
||||
@@ -381,7 +435,7 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match PathBuf::from(&file_path).canonicalize() {
|
||||
let file_path = match fff_core::path_utils::canonicalize(&file_path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
|
||||
@@ -431,6 +485,58 @@ pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>>
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
{
|
||||
tracing::error!(
|
||||
query = %query,
|
||||
error = ?e,
|
||||
"Failed to track grep query"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_grep_query(&project_path, offset)
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
@@ -628,6 +734,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("live_grep", lua.create_function(live_grep)?)?;
|
||||
exports.set("track_access", lua.create_function(track_access)?)?;
|
||||
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
|
||||
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
|
||||
@@ -658,6 +765,11 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
exports.set("track_grep_query", lua.create_function(track_grep_query)?)?;
|
||||
exports.set(
|
||||
"get_historical_grep_query",
|
||||
lua.create_function(get_historical_grep_query)?,
|
||||
)?;
|
||||
exports.set("health_check", lua.create_function(health_check)?)?;
|
||||
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! This module provides IntoLua implementations for core types.
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
|
||||
use mlua::prelude::*;
|
||||
|
||||
/// Wrapper for SearchResult that implements IntoLua
|
||||
@@ -17,6 +17,17 @@ impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for GrepResult that implements IntoLua
|
||||
pub struct GrepResultLua<'a> {
|
||||
inner: GrepResult<'a>,
|
||||
}
|
||||
|
||||
impl<'a> From<GrepResult<'a>> for GrepResultLua<'a> {
|
||||
fn from(inner: GrepResult<'a>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaPosition((i32, i32));
|
||||
|
||||
impl IntoLua for LuaPosition {
|
||||
@@ -42,6 +53,7 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score)?;
|
||||
table.set("git_status", format_git_status(item.git_status))?;
|
||||
table.set("is_binary", item.is_binary)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
@@ -104,3 +116,68 @@ impl IntoLua for SearchResultLua<'_> {
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoLua for GrepResultLua<'_> {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
|
||||
// Convert grep match items — each includes file metadata + match metadata
|
||||
let items_table = lua.create_table()?;
|
||||
for (i, m) in self.inner.matches.iter().enumerate() {
|
||||
let item = lua.create_table()?;
|
||||
|
||||
// File metadata from the deduplicated files vec
|
||||
let file = self.inner.files[m.file_index];
|
||||
item.set("path", file.path.to_string_lossy().to_string())?;
|
||||
item.set("relative_path", file.relative_path.as_str())?;
|
||||
item.set("name", file.file_name.as_str())?;
|
||||
item.set("is_binary", file.is_binary)?;
|
||||
item.set("git_status", format_git_status(file.git_status))?;
|
||||
item.set("size", file.size)?;
|
||||
item.set("modified", file.modified)?;
|
||||
item.set("total_frecency_score", file.total_frecency_score)?;
|
||||
item.set("access_frecency_score", file.access_frecency_score)?;
|
||||
item.set(
|
||||
"modification_frecency_score",
|
||||
file.modification_frecency_score,
|
||||
)?;
|
||||
|
||||
// Match metadata
|
||||
item.set("line_number", m.line_number)?;
|
||||
item.set("col", m.col)?;
|
||||
item.set("byte_offset", m.byte_offset)?;
|
||||
item.set("line_content", m.line_content.as_str())?;
|
||||
|
||||
// Match byte ranges within line_content
|
||||
let ranges = lua.create_table()?;
|
||||
for (j, &(start, end)) in m.match_byte_offsets.iter().enumerate() {
|
||||
let range = lua.create_table()?;
|
||||
range.set(1, start)?;
|
||||
range.set(2, end)?;
|
||||
ranges.set(j + 1, range)?;
|
||||
}
|
||||
item.set("match_ranges", ranges)?;
|
||||
|
||||
// Fuzzy match score (only set in fuzzy grep mode, nil otherwise)
|
||||
if let Some(score) = m.fuzzy_score {
|
||||
item.set("fuzzy_score", score)?;
|
||||
}
|
||||
|
||||
items_table.set(i + 1, item)?;
|
||||
}
|
||||
table.set("items", items_table)?;
|
||||
|
||||
table.set("total_matched", self.inner.total_match_count)?;
|
||||
table.set("total_files_searched", self.inner.total_files_searched)?;
|
||||
table.set("total_files", self.inner.total_files)?;
|
||||
table.set("filtered_file_count", self.inner.filtered_file_count)?;
|
||||
table.set("next_file_offset", self.inner.next_file_offset)?;
|
||||
|
||||
// Pass regex fallback error to Lua (nil if no error)
|
||||
if let Some(ref err) = self.inner.regex_fallback_error {
|
||||
table.set("regex_fallback_error", err.as_str())?;
|
||||
}
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::constraints::Constraint;
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
@@ -31,6 +32,17 @@ pub trait ParserConfig {
|
||||
true
|
||||
}
|
||||
|
||||
/// Determine whether a token should be treated as a glob constraint.
|
||||
///
|
||||
/// The default implementation delegates to `zlob::has_wildcards` with
|
||||
/// `RECOMMENDED` flags, which recognises `*`, `?`, `[`, `{…}` etc.
|
||||
///
|
||||
/// Override this in configs where some wildcard characters are common
|
||||
/// in search text (e.g. grep mode where `?` and `[` appear in code).
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
has_wildcards(token, ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
|
||||
None
|
||||
@@ -45,19 +57,17 @@ impl ParserConfig for FilePickerConfig {
|
||||
// All defaults enabled
|
||||
}
|
||||
|
||||
/// Configuration for full-text search (grep) - limited constraints
|
||||
/// Configuration for full-text search (grep) - file constraints enabled for
|
||||
/// filtering which files to search, git status disabled since it's not useful
|
||||
/// when searching file contents.
|
||||
///
|
||||
/// Glob detection is narrowed: only patterns containing a path separator (`/`)
|
||||
/// or brace expansion (`{…}`) are treated as globs. Characters like `?` and
|
||||
/// `[` are extremely common in source code and must remain literal search text.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct GrepConfig;
|
||||
|
||||
impl ParserConfig for GrepConfig {
|
||||
fn enable_extension(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_glob(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -65,4 +75,35 @@ impl ParserConfig for GrepConfig {
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Only recognise globs that are clearly directory/path oriented.
|
||||
///
|
||||
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
|
||||
/// common in source code (`foo?`, `arr[0]`, `*ptr`) and must NOT be
|
||||
/// consumed as glob constraints. We only treat a token as a glob when
|
||||
/// it contains path-oriented patterns:
|
||||
///
|
||||
/// - Contains `/` → path glob (e.g. `src/**/*.rs`, `*/tests/*`)
|
||||
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// Must contain at least one glob wildcard character
|
||||
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let bytes = token.as_bytes();
|
||||
|
||||
// Contains path separator → clearly a path glob
|
||||
if bytes.contains(&b'/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brace expansion → useful for directory alternatives
|
||||
if bytes.contains(&b'{') && bytes.contains(&b'}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Everything else (?, [, bare * without /) → treat as literal text
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,12 @@ impl Default for QueryParser<crate::FilePickerConfig> {
|
||||
|
||||
#[inline]
|
||||
fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
|
||||
// Backslash escape: \token → treat as literal text, skip all constraint parsing.
|
||||
// The leading \ is stripped by the caller when building the search text.
|
||||
if token.starts_with('\\') && token.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first_byte = token.as_bytes().first()?;
|
||||
|
||||
match first_byte {
|
||||
@@ -149,8 +155,8 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Has wildcards -> use config-specific glob detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
@@ -162,8 +168,8 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Check for glob patterns using config-specific detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
@@ -232,6 +238,11 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
token: &'a str,
|
||||
config: &C,
|
||||
) -> Option<Constraint<'a>> {
|
||||
// Backslash escape applies here too
|
||||
if token.starts_with('\\') && token.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first_byte = token.as_bytes().first()?;
|
||||
|
||||
match first_byte {
|
||||
@@ -243,8 +254,8 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Has wildcards -> use config-specific glob detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
@@ -255,8 +266,8 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
// Check for glob patterns using config-specific detection
|
||||
if config.enable_glob() && config.is_glob_pattern(token) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
@@ -491,4 +502,170 @@ mod tests {
|
||||
_ => panic!("Expected Not(GitStatus) constraint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backslash_escape_extension() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("\\*.rs foo")
|
||||
.expect("Should parse multi-token query");
|
||||
// \*.rs should NOT be parsed as an Extension constraint
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
// Both tokens should be text
|
||||
match result.fuzzy_query {
|
||||
FuzzyQuery::Parts(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0], "\\*.rs");
|
||||
assert_eq!(parts[1], "foo");
|
||||
}
|
||||
_ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backslash_escape_path_segment() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("\\/src/ foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
match result.fuzzy_query {
|
||||
FuzzyQuery::Parts(parts) => {
|
||||
assert_eq!(parts[0], "\\/src/");
|
||||
assert_eq!(parts[1], "foo");
|
||||
}
|
||||
_ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backslash_escape_negation() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("\\!test foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_question_mark_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// Single token "foo?" should return None (treated as plain text by caller)
|
||||
let result = parser.parse("foo?");
|
||||
assert!(result.is_none(), "foo? should be plain text in grep mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_bracket_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser.parse("arr[0] something");
|
||||
let result = result.expect("Should parse multi-token query");
|
||||
// arr[0] should NOT be a glob in grep mode
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_path_glob_is_constraint() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern src/**/*.rs")
|
||||
.expect("Should parse with path glob");
|
||||
// src/**/*.rs contains / so it should be treated as a glob
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::Glob("src/**/*.rs")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_brace_is_constraint() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern {src,lib}")
|
||||
.expect("Should parse with brace expansion");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::Glob("{src,lib}")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_bare_star_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// "a*b" contains * but no / or {} — should be text in grep mode
|
||||
let result = parser.parse("a*b something");
|
||||
let result = result.expect("Should parse");
|
||||
assert_eq!(
|
||||
result.constraints.len(),
|
||||
0,
|
||||
"bare * without / should be text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !test")
|
||||
.expect("Should parse negated text in grep mode");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(
|
||||
matches!(**inner, Constraint::Text("test")),
|
||||
"Expected Not(Text(\"test\")), got Not({:?})",
|
||||
inner
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_path_segment() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !/src/")
|
||||
.expect("Should parse negated path segment in grep mode");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(
|
||||
matches!(**inner, Constraint::PathSegment("src")),
|
||||
"Expected Not(PathSegment(\"src\")), got Not({:?})",
|
||||
inner
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_extension() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !*.rs")
|
||||
.expect("Should parse negated extension in grep mode");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(
|
||||
matches!(**inner, Constraint::Extension("rs")),
|
||||
"Expected Not(Extension(\"rs\")), got Not({:?})",
|
||||
inner
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Not constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "grep-searcher"
|
||||
version = "0.1.16"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
|
||||
grep-matcher = { workspace = true }
|
||||
memchr = "2.6.3"
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
Simplified grep-searcher for fff.nvim.
|
||||
|
||||
Provides line-oriented search over byte slices with optional multi-line support.
|
||||
Only `search_slice` is supported -- no file/reader/mmap search.
|
||||
*/
|
||||
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub use crate::{
|
||||
searcher::{Searcher, SearcherBuilder},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
pub mod lines;
|
||||
mod searcher;
|
||||
mod sink;
|
||||
@@ -0,0 +1,234 @@
|
||||
/*!
|
||||
A collection of routines for performing operations on lines.
|
||||
*/
|
||||
|
||||
use {
|
||||
bstr::ByteSlice,
|
||||
grep_matcher::{LineTerminator, Match},
|
||||
};
|
||||
|
||||
/// An explicit iterator over lines in a particular slice of bytes.
|
||||
///
|
||||
/// This iterator avoids borrowing the bytes themselves, and instead requires
|
||||
/// callers to explicitly provide the bytes when moving through the iterator.
|
||||
///
|
||||
/// Line terminators are considered part of the line they terminate. All lines
|
||||
/// yielded by the iterator are guaranteed to be non-empty.
|
||||
#[derive(Debug)]
|
||||
pub struct LineStep {
|
||||
line_term: u8,
|
||||
pos: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl LineStep {
|
||||
/// Create a new line iterator over the given range of bytes using the
|
||||
/// given line terminator.
|
||||
pub fn new(line_term: u8, start: usize, end: usize) -> LineStep {
|
||||
LineStep {
|
||||
line_term,
|
||||
pos: start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like next, but returns a `Match` instead of a tuple.
|
||||
#[inline(always)]
|
||||
pub fn next_match(&mut self, bytes: &[u8]) -> Option<Match> {
|
||||
self.next_impl(bytes).map(|(s, e)| Match::new(s, e))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn next_impl(&mut self, mut bytes: &[u8]) -> Option<(usize, usize)> {
|
||||
bytes = &bytes[..self.end];
|
||||
match bytes[self.pos..].find_byte(self.line_term) {
|
||||
None => {
|
||||
if self.pos < bytes.len() {
|
||||
let m = (self.pos, bytes.len());
|
||||
assert!(m.0 <= m.1);
|
||||
|
||||
self.pos = m.1;
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Some(line_end) => {
|
||||
let m = (self.pos, self.pos + line_end + 1);
|
||||
assert!(m.0 <= m.1);
|
||||
|
||||
self.pos = m.1;
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the number of occurrences of `line_term` in `bytes`.
|
||||
pub fn count(bytes: &[u8], line_term: u8) -> u64 {
|
||||
memchr::memchr_iter(line_term, bytes).count() as u64
|
||||
}
|
||||
|
||||
/// Given a line that possibly ends with a terminator, return that line without
|
||||
/// the terminator.
|
||||
#[inline(always)]
|
||||
pub fn without_terminator(bytes: &[u8], line_term: LineTerminator) -> &[u8] {
|
||||
let line_term = line_term.as_bytes();
|
||||
let start = bytes.len().saturating_sub(line_term.len());
|
||||
if bytes.get(start..) == Some(line_term) {
|
||||
return &bytes[..bytes.len() - line_term.len()];
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Return the start and end offsets of the lines containing the given range
|
||||
/// of bytes.
|
||||
///
|
||||
/// Line terminators are considered part of the line they terminate.
|
||||
#[inline(always)]
|
||||
pub fn locate(bytes: &[u8], line_term: u8, range: Match) -> Match {
|
||||
let line_start = bytes[..range.start()]
|
||||
.rfind_byte(line_term)
|
||||
.map_or(0, |i| i + 1);
|
||||
let line_end = if range.end() > line_start && bytes[range.end() - 1] == line_term {
|
||||
range.end()
|
||||
} else {
|
||||
bytes[range.end()..]
|
||||
.find_byte(line_term)
|
||||
.map_or(bytes.len(), |i| range.end() + i + 1)
|
||||
};
|
||||
Match::new(line_start, line_end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SHERLOCK: &'static str = "\
|
||||
For the Doctor Watsons of this world, as opposed to the Sherlock
|
||||
Holmeses, success in the province of detective work must always
|
||||
be, to a very large extent, the result of luck. Sherlock Holmes
|
||||
can extract a clew from a wisp of straw or a flake of cigar ash;
|
||||
but Doctor Watson has to have it taken out for him and dusted,
|
||||
and exhibited clearly, with a label attached.\
|
||||
";
|
||||
|
||||
fn m(start: usize, end: usize) -> Match {
|
||||
Match::new(start, end)
|
||||
}
|
||||
|
||||
fn lines(text: &str) -> Vec<&str> {
|
||||
let mut results = vec![];
|
||||
let mut it = LineStep::new(b'\n', 0, text.len());
|
||||
while let Some(m) = it.next_match(text.as_bytes()) {
|
||||
results.push(&text[m]);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn line_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||
let mut results = vec![];
|
||||
let mut it = LineStep::new(b'\n', 0, text.len());
|
||||
while let Some(m) = it.next_match(text.as_bytes()) {
|
||||
results.push(m.start()..m.end());
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn loc(text: &str, start: usize, end: usize) -> Match {
|
||||
locate(text.as_bytes(), b'\n', Match::new(start, end))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count() {
|
||||
assert_eq!(0, count(b"", b'\n'));
|
||||
assert_eq!(1, count(b"\n", b'\n'));
|
||||
assert_eq!(2, count(b"\n\n", b'\n'));
|
||||
assert_eq!(2, count(b"a\nb\nc", b'\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_locate() {
|
||||
let t = SHERLOCK;
|
||||
let lines = line_ranges(t);
|
||||
|
||||
assert_eq!(
|
||||
loc(t, lines[0].start, lines[0].end),
|
||||
m(lines[0].start, lines[0].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[0].start + 1, lines[0].end),
|
||||
m(lines[0].start, lines[0].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[0].end - 1, lines[0].end),
|
||||
m(lines[0].start, lines[0].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[0].end, lines[0].end),
|
||||
m(lines[1].start, lines[1].end)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
loc(t, lines[5].start, lines[5].end),
|
||||
m(lines[5].start, lines[5].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[5].start + 1, lines[5].end),
|
||||
m(lines[5].start, lines[5].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[5].end - 1, lines[5].end),
|
||||
m(lines[5].start, lines[5].end)
|
||||
);
|
||||
assert_eq!(
|
||||
loc(t, lines[5].end, lines[5].end),
|
||||
m(lines[5].start, lines[5].end)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_locate_weird() {
|
||||
assert_eq!(loc("", 0, 0), m(0, 0));
|
||||
|
||||
assert_eq!(loc("\n", 0, 1), m(0, 1));
|
||||
assert_eq!(loc("\n", 1, 1), m(1, 1));
|
||||
|
||||
assert_eq!(loc("\n\n", 0, 0), m(0, 1));
|
||||
assert_eq!(loc("\n\n", 0, 1), m(0, 1));
|
||||
assert_eq!(loc("\n\n", 1, 1), m(1, 2));
|
||||
assert_eq!(loc("\n\n", 1, 2), m(1, 2));
|
||||
assert_eq!(loc("\n\n", 2, 2), m(2, 2));
|
||||
|
||||
assert_eq!(loc("a\nb\nc", 0, 1), m(0, 2));
|
||||
assert_eq!(loc("a\nb\nc", 1, 2), m(0, 2));
|
||||
assert_eq!(loc("a\nb\nc", 2, 3), m(2, 4));
|
||||
assert_eq!(loc("a\nb\nc", 3, 4), m(2, 4));
|
||||
assert_eq!(loc("a\nb\nc", 4, 5), m(4, 5));
|
||||
assert_eq!(loc("a\nb\nc", 5, 5), m(4, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_iter() {
|
||||
assert_eq!(lines("abc"), vec!["abc"]);
|
||||
|
||||
assert_eq!(lines("abc\n"), vec!["abc\n"]);
|
||||
assert_eq!(lines("abc\nxyz"), vec!["abc\n", "xyz"]);
|
||||
assert_eq!(lines("abc\nxyz\n"), vec!["abc\n", "xyz\n"]);
|
||||
|
||||
assert_eq!(lines("abc\n\n"), vec!["abc\n", "\n"]);
|
||||
assert_eq!(lines("abc\n\n\n"), vec!["abc\n", "\n", "\n"]);
|
||||
assert_eq!(lines("abc\n\nxyz"), vec!["abc\n", "\n", "xyz"]);
|
||||
assert_eq!(lines("abc\n\nxyz\n"), vec!["abc\n", "\n", "xyz\n"]);
|
||||
assert_eq!(lines("abc\nxyz\n\n"), vec!["abc\n", "xyz\n", "\n"]);
|
||||
|
||||
assert_eq!(lines("\n"), vec!["\n"]);
|
||||
assert_eq!(lines(""), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_iter_empty() {
|
||||
let mut it = LineStep::new(b'\n', 0, 0);
|
||||
assert_eq!(it.next_match(b"abc"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use grep_matcher::{LineMatchKind, Matcher};
|
||||
|
||||
use crate::{
|
||||
lines::{self, LineStep},
|
||||
searcher::{Config, Range, Searcher},
|
||||
sink::{Sink, SinkError, SinkFinish, SinkMatch},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Core<'s, M: 's, S> {
|
||||
config: &'s Config,
|
||||
matcher: M,
|
||||
searcher: &'s Searcher,
|
||||
sink: S,
|
||||
pos: usize,
|
||||
absolute_byte_offset: u64,
|
||||
line_number: Option<u64>,
|
||||
last_line_counted: usize,
|
||||
last_line_visited: usize,
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> Core<'s, M, S> {
|
||||
pub(crate) fn new(searcher: &'s Searcher, matcher: M, sink: S) -> Core<'s, M, S> {
|
||||
let line_number = if searcher.config.line_number {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Core {
|
||||
config: &searcher.config,
|
||||
matcher,
|
||||
searcher,
|
||||
sink,
|
||||
pos: 0,
|
||||
absolute_byte_offset: 0,
|
||||
line_number,
|
||||
last_line_counted: 0,
|
||||
last_line_visited: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub(crate) fn set_pos(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
pub(crate) fn matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.sink_matched(buf, range)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&mut self, slice: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
match self.matcher.find(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn shortest_match(&mut self, slice: &[u8]) -> Result<Option<usize>, S::Error> {
|
||||
match self.matcher.shortest_match(slice) {
|
||||
Err(err) => Err(S::Error::error_message(err)),
|
||||
Ok(m) => Ok(m),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self) -> Result<bool, S::Error> {
|
||||
self.sink.begin(self.searcher)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self, byte_count: u64) -> Result<(), S::Error> {
|
||||
self.sink.finish(self.searcher, &SinkFinish { byte_count })
|
||||
}
|
||||
|
||||
pub(crate) fn match_by_line(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
if self.is_line_by_line_fast() {
|
||||
self.match_by_line_fast(buf)
|
||||
} else {
|
||||
self.match_by_line_slow(buf)
|
||||
}
|
||||
}
|
||||
|
||||
fn match_by_line_slow(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
|
||||
let range = Range::new(self.pos(), buf.len());
|
||||
let mut stepper =
|
||||
LineStep::new(self.config.line_term.as_byte(), range.start(), range.end());
|
||||
while let Some(line) = stepper.next_match(buf) {
|
||||
let matched = {
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
self.shortest_match(slice)?.is_some()
|
||||
};
|
||||
self.set_pos(line.end());
|
||||
if matched && !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn match_by_line_fast(&mut self, buf: &[u8]) -> Result<bool, S::Error> {
|
||||
while !buf[self.pos()..].is_empty() {
|
||||
if let Some(line) = self.find_by_line_fast(buf)? {
|
||||
self.set_pos(line.end());
|
||||
if !self.sink_matched(buf, &line)? {
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.set_pos(buf.len());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn find_by_line_fast(&mut self, buf: &[u8]) -> Result<Option<Range>, S::Error> {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
debug_assert!(self.is_line_by_line_fast());
|
||||
|
||||
let mut pos = self.pos();
|
||||
while !buf[pos..].is_empty() {
|
||||
match self.matcher.find_candidate_line(&buf[pos..]) {
|
||||
Err(err) => return Err(S::Error::error_message(err)),
|
||||
Ok(None) => return Ok(None),
|
||||
Ok(Some(LineMatchKind::Confirmed(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
if line.start() == buf.len() {
|
||||
pos = buf.len();
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(line));
|
||||
}
|
||||
Ok(Some(LineMatchKind::Candidate(i))) => {
|
||||
let line = lines::locate(
|
||||
buf,
|
||||
self.config.line_term.as_byte(),
|
||||
Range::zero(i).offset(pos),
|
||||
);
|
||||
let slice = lines::without_terminator(&buf[line], self.config.line_term);
|
||||
if self
|
||||
.matcher
|
||||
.is_match(slice)
|
||||
.map_err(S::Error::error_message)?
|
||||
{
|
||||
return Ok(Some(line));
|
||||
}
|
||||
pos = line.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sink_matched(&mut self, buf: &[u8], range: &Range) -> Result<bool, S::Error> {
|
||||
self.count_lines(buf, range.start());
|
||||
let offset = self.absolute_byte_offset + range.start() as u64;
|
||||
let linebuf = &buf[*range];
|
||||
let keepgoing = self.sink.matched(
|
||||
self.searcher,
|
||||
&SinkMatch {
|
||||
bytes: linebuf,
|
||||
absolute_byte_offset: offset,
|
||||
line_number: self.line_number,
|
||||
buffer: buf,
|
||||
bytes_range_in_buffer: range.start()..range.end(),
|
||||
},
|
||||
)?;
|
||||
if !keepgoing {
|
||||
return Ok(false);
|
||||
}
|
||||
self.last_line_visited = range.end();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn count_lines(&mut self, buf: &[u8], upto: usize) {
|
||||
if let Some(ref mut line_number) = self.line_number {
|
||||
if self.last_line_counted >= upto {
|
||||
return;
|
||||
}
|
||||
let slice = &buf[self.last_line_counted..upto];
|
||||
let count = lines::count(slice, self.config.line_term.as_byte());
|
||||
*line_number += count;
|
||||
self.last_line_counted = upto;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line_by_line_fast(&self) -> bool {
|
||||
debug_assert!(!self.searcher.multi_line_with_matcher(&self.matcher));
|
||||
if let Some(line_term) = self.matcher.line_terminator() {
|
||||
if line_term.as_byte() == b'\x00' {
|
||||
return false;
|
||||
}
|
||||
if line_term == self.config.line_term {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(non_matching) = self.matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.config.line_term.as_byte())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use grep_matcher::Matcher;
|
||||
|
||||
use crate::{
|
||||
lines,
|
||||
searcher::{Config, Range, Searcher, core::Core},
|
||||
sink::Sink,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SliceByLine<'s, M, S> {
|
||||
core: Core<'s, M, S>,
|
||||
slice: &'s [u8],
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> SliceByLine<'s, M, S> {
|
||||
pub(crate) fn new(
|
||||
searcher: &'s Searcher,
|
||||
matcher: M,
|
||||
slice: &'s [u8],
|
||||
write_to: S,
|
||||
) -> SliceByLine<'s, M, S> {
|
||||
debug_assert!(!searcher.multi_line_with_matcher(&matcher));
|
||||
|
||||
SliceByLine {
|
||||
core: Core::new(searcher, matcher, write_to),
|
||||
slice,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut self) -> Result<(), S::Error> {
|
||||
if self.core.begin()? {
|
||||
while !self.slice[self.core.pos()..].is_empty()
|
||||
&& self.core.match_by_line(self.slice)?
|
||||
{}
|
||||
}
|
||||
let byte_count = self.slice.len() as u64;
|
||||
self.core.finish(byte_count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MultiLine<'s, M, S> {
|
||||
config: &'s Config,
|
||||
core: Core<'s, M, S>,
|
||||
slice: &'s [u8],
|
||||
last_match: Option<Range>,
|
||||
}
|
||||
|
||||
impl<'s, M: Matcher, S: Sink> MultiLine<'s, M, S> {
|
||||
pub(crate) fn new(
|
||||
searcher: &'s Searcher,
|
||||
matcher: M,
|
||||
slice: &'s [u8],
|
||||
write_to: S,
|
||||
) -> MultiLine<'s, M, S> {
|
||||
debug_assert!(searcher.multi_line_with_matcher(&matcher));
|
||||
|
||||
MultiLine {
|
||||
config: &searcher.config,
|
||||
core: Core::new(searcher, matcher, write_to),
|
||||
slice,
|
||||
last_match: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut self) -> Result<(), S::Error> {
|
||||
if self.core.begin()? {
|
||||
let mut keepgoing = true;
|
||||
while !self.slice[self.core.pos()..].is_empty() && keepgoing {
|
||||
keepgoing = self.sink()?;
|
||||
}
|
||||
if keepgoing && let Some(last_match) = self.last_match.take() {
|
||||
self.sink_matched(&last_match)?;
|
||||
}
|
||||
}
|
||||
let byte_count = self.slice.len() as u64;
|
||||
self.core.finish(byte_count)
|
||||
}
|
||||
|
||||
fn sink(&mut self) -> Result<bool, S::Error> {
|
||||
let mat = match self.find()? {
|
||||
Some(range) => range,
|
||||
None => {
|
||||
self.core.set_pos(self.slice.len());
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
self.advance(&mat);
|
||||
|
||||
let line = lines::locate(self.slice, self.config.line_term.as_byte(), mat);
|
||||
match self.last_match.take() {
|
||||
None => {
|
||||
self.last_match = Some(line);
|
||||
Ok(true)
|
||||
}
|
||||
Some(last_match) => {
|
||||
if last_match.end() >= line.start() {
|
||||
self.last_match = Some(last_match.with_end(line.end()));
|
||||
Ok(true)
|
||||
} else {
|
||||
self.last_match = Some(line);
|
||||
self.sink_matched(&last_match)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sink_matched(&mut self, range: &Range) -> Result<bool, S::Error> {
|
||||
if range.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.core.matched(self.slice, range)
|
||||
}
|
||||
|
||||
fn find(&mut self) -> Result<Option<Range>, S::Error> {
|
||||
self.core
|
||||
.find(&self.slice[self.core.pos()..])
|
||||
.map(|m| m.map(|m| m.offset(self.core.pos())))
|
||||
}
|
||||
|
||||
fn advance(&mut self, range: &Range) {
|
||||
self.core.set_pos(range.end());
|
||||
if range.is_empty() && self.core.pos() < self.slice.len() {
|
||||
let newpos = self.core.pos() + 1;
|
||||
self.core.set_pos(newpos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use grep_matcher::{LineTerminator, Match, Matcher};
|
||||
|
||||
use crate::{
|
||||
searcher::glue::{MultiLine, SliceByLine},
|
||||
sink::{Sink, SinkError},
|
||||
};
|
||||
|
||||
mod core;
|
||||
mod glue;
|
||||
|
||||
/// We use this type alias since we want the ergonomics of a matcher's `Match`
|
||||
/// type, but in practice, we use it for arbitrary ranges, so give it a more
|
||||
/// accurate name. This is only used in the searcher's internals.
|
||||
type Range = Match;
|
||||
|
||||
/// An error that can occur when building a searcher.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub(crate) enum ConfigError {
|
||||
/// Occurs when a matcher reports a line terminator that is different than
|
||||
/// the one configured in the searcher.
|
||||
MismatchedLineTerminators {
|
||||
/// The matcher's line terminator.
|
||||
matcher: LineTerminator,
|
||||
/// The searcher's line terminator.
|
||||
searcher: LineTerminator,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
ConfigError::MismatchedLineTerminators { matcher, searcher } => {
|
||||
write!(
|
||||
f,
|
||||
"grep config error: mismatched line terminators, \
|
||||
matcher has {:?} but searcher has {:?}",
|
||||
matcher, searcher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The internal configuration of a searcher.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Config {
|
||||
/// The line terminator to use.
|
||||
pub(crate) line_term: LineTerminator,
|
||||
/// Whether to count line numbers.
|
||||
pub(crate) line_number: bool,
|
||||
/// Whether to enable matching across multiple lines.
|
||||
multi_line: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
line_term: LineTerminator::default(),
|
||||
line_number: true,
|
||||
multi_line: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A builder for configuring a searcher.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SearcherBuilder {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Default for SearcherBuilder {
|
||||
fn default() -> SearcherBuilder {
|
||||
SearcherBuilder::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearcherBuilder {
|
||||
/// Create a new searcher builder with a default configuration.
|
||||
pub fn new() -> SearcherBuilder {
|
||||
SearcherBuilder {
|
||||
config: Config::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a searcher.
|
||||
pub fn build(&self) -> Searcher {
|
||||
Searcher {
|
||||
config: self.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to count and include line numbers with matching lines.
|
||||
pub fn line_number(&mut self, yes: bool) -> &mut SearcherBuilder {
|
||||
self.config.line_number = yes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to enable multi line search or not.
|
||||
pub fn multi_line(&mut self, yes: bool) -> &mut SearcherBuilder {
|
||||
self.config.multi_line = yes;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A searcher executes searches over a haystack and writes results to a caller
|
||||
/// provided sink.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Searcher {
|
||||
pub(crate) config: Config,
|
||||
}
|
||||
|
||||
impl Searcher {
|
||||
/// Create a new searcher with a default configuration.
|
||||
pub fn new() -> Searcher {
|
||||
SearcherBuilder::new().build()
|
||||
}
|
||||
|
||||
/// Execute a search over the given slice and write the results to the
|
||||
/// given sink.
|
||||
pub fn search_slice<M, S>(
|
||||
&mut self,
|
||||
matcher: M,
|
||||
slice: &[u8],
|
||||
write_to: S,
|
||||
) -> Result<(), S::Error>
|
||||
where
|
||||
M: Matcher,
|
||||
S: Sink,
|
||||
{
|
||||
self.check_config(&matcher)
|
||||
.map_err(S::Error::error_message)?;
|
||||
|
||||
if self.multi_line_with_matcher(&matcher) {
|
||||
MultiLine::new(self, matcher, slice, write_to).run()
|
||||
} else {
|
||||
SliceByLine::new(self, matcher, slice, write_to).run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the searcher's configuration and the matcher are consistent.
|
||||
fn check_config<M: Matcher>(&self, matcher: M) -> Result<(), ConfigError> {
|
||||
let matcher_line_term = match matcher.line_terminator() {
|
||||
None => return Ok(()),
|
||||
Some(line_term) => line_term,
|
||||
};
|
||||
if matcher_line_term != self.config.line_term {
|
||||
return Err(ConfigError::MismatchedLineTerminators {
|
||||
matcher: matcher_line_term,
|
||||
searcher: self.config.line_term,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Searcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration query methods used by the sink and internal search core.
|
||||
impl Searcher {
|
||||
/// Returns the line terminator used by this searcher.
|
||||
#[inline]
|
||||
pub fn line_terminator(&self) -> LineTerminator {
|
||||
self.config.line_term
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher is configured to count line
|
||||
/// numbers.
|
||||
#[inline]
|
||||
pub fn line_number(&self) -> bool {
|
||||
self.config.line_number
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher is configured to perform
|
||||
/// multi line search.
|
||||
#[inline]
|
||||
pub fn multi_line(&self) -> bool {
|
||||
self.config.multi_line
|
||||
}
|
||||
|
||||
/// Returns true if and only if this searcher will choose a multi-line
|
||||
/// strategy given the provided matcher.
|
||||
pub fn multi_line_with_matcher<M: Matcher>(&self, matcher: M) -> bool {
|
||||
if !self.multi_line() {
|
||||
return false;
|
||||
}
|
||||
if let Some(line_term) = matcher.line_terminator()
|
||||
&& line_term == self.line_terminator()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(non_matching) = matcher.non_matching_bytes()
|
||||
&& non_matching.contains(self.line_terminator().as_byte())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::io;
|
||||
|
||||
use crate::searcher::Searcher;
|
||||
|
||||
/// A trait that describes errors that can be reported by searchers and
|
||||
/// implementations of `Sink`.
|
||||
pub trait SinkError: Sized {
|
||||
/// A constructor for converting any value that satisfies the
|
||||
/// `std::fmt::Display` trait into an error.
|
||||
fn error_message<T: std::fmt::Display>(message: T) -> Self;
|
||||
|
||||
/// A constructor for converting I/O errors that occur while searching into
|
||||
/// an error of this type.
|
||||
fn error_io(err: io::Error) -> Self {
|
||||
Self::error_message(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl SinkError for io::Error {
|
||||
fn error_message<T: std::fmt::Display>(message: T) -> io::Error {
|
||||
io::Error::other(message.to_string())
|
||||
}
|
||||
|
||||
fn error_io(err: io::Error) -> io::Error {
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait that defines how results from searchers are handled.
|
||||
///
|
||||
/// The searcher follows the "push" model: the searcher drives execution and
|
||||
/// pushes results back to the caller via this trait.
|
||||
pub trait Sink {
|
||||
/// The type of an error that should be reported by a searcher.
|
||||
type Error: SinkError;
|
||||
|
||||
/// This method is called whenever a match is found.
|
||||
///
|
||||
/// If this returns `true`, then searching continues. If this returns
|
||||
/// `false`, then searching is stopped immediately and `finish` is called.
|
||||
fn matched(&mut self, _searcher: &Searcher, _mat: &SinkMatch<'_>) -> Result<bool, Self::Error>;
|
||||
|
||||
/// This method is called when a search has begun, before any search is
|
||||
/// executed. By default, this does nothing.
|
||||
#[inline]
|
||||
fn begin(&mut self, _searcher: &Searcher) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// This method is called when a search has completed. By default, this
|
||||
/// does nothing.
|
||||
#[inline]
|
||||
fn finish(&mut self, _searcher: &Searcher, _: &SinkFinish) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Sink> Sink for &mut S {
|
||||
type Error = S::Error;
|
||||
|
||||
#[inline]
|
||||
fn matched(&mut self, searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, S::Error> {
|
||||
(**self).matched(searcher, mat)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn begin(&mut self, searcher: &Searcher) -> Result<bool, S::Error> {
|
||||
(**self).begin(searcher)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn finish(&mut self, searcher: &Searcher, sink_finish: &SinkFinish) -> Result<(), S::Error> {
|
||||
(**self).finish(searcher, sink_finish)
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary data reported at the end of a search.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SinkFinish {
|
||||
pub(crate) byte_count: u64,
|
||||
}
|
||||
|
||||
impl SinkFinish {
|
||||
/// Return the total number of bytes searched.
|
||||
#[inline]
|
||||
pub fn byte_count(&self) -> u64 {
|
||||
self.byte_count
|
||||
}
|
||||
}
|
||||
|
||||
/// A type that describes a match reported by a searcher.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SinkMatch<'b> {
|
||||
pub(crate) bytes: &'b [u8],
|
||||
pub(crate) absolute_byte_offset: u64,
|
||||
pub(crate) line_number: Option<u64>,
|
||||
pub(crate) buffer: &'b [u8],
|
||||
pub(crate) bytes_range_in_buffer: std::ops::Range<usize>,
|
||||
}
|
||||
|
||||
impl<'b> SinkMatch<'b> {
|
||||
/// Returns the bytes for all matching lines, including the line
|
||||
/// terminators, if they exist.
|
||||
#[inline]
|
||||
pub fn bytes(&self) -> &'b [u8] {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
/// Returns the absolute byte offset of the start of this match. This
|
||||
/// offset is absolute in that it is relative to the very beginning of the
|
||||
/// input in a search.
|
||||
#[inline]
|
||||
pub fn absolute_byte_offset(&self) -> u64 {
|
||||
self.absolute_byte_offset
|
||||
}
|
||||
|
||||
/// Returns the line number of the first line in this match, if available.
|
||||
///
|
||||
/// Line numbers are only available when the search builder is instructed
|
||||
/// to compute them.
|
||||
#[inline]
|
||||
pub fn line_number(&self) -> Option<u64> {
|
||||
self.line_number
|
||||
}
|
||||
|
||||
/// Exposes as much of the underlying buffer that was searched as possible.
|
||||
#[inline]
|
||||
pub fn buffer(&self) -> &'b [u8] {
|
||||
self.buffer
|
||||
}
|
||||
|
||||
/// Returns a range that corresponds to where [`SinkMatch::bytes`] appears
|
||||
/// in [`SinkMatch::buffer`].
|
||||
#[inline]
|
||||
pub fn bytes_range_in_buffer(&self) -> std::ops::Range<usize> {
|
||||
self.bytes_range_in_buffer.clone()
|
||||
}
|
||||
}
|
||||
+92
-1
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 12
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 17
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -74,6 +74,20 @@ LAZY.NVIM
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
},
|
||||
{
|
||||
"fg",
|
||||
function() require('fff').live_grep() end,
|
||||
desc = 'LiFFFe grep',
|
||||
},
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +183,8 @@ all available options:
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -207,6 +223,13 @@ all available options:
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -233,6 +256,14 @@ all available options:
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
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
|
||||
}
|
||||
})
|
||||
<
|
||||
@@ -291,6 +322,66 @@ configurable):
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
|
||||
LIVE GREP SEARCH MODES
|
||||
|
||||
Live grep supports three search modes, cycled with `<S-Tab>`:
|
||||
|
||||
- **Plain text** (default) - The query is matched literally. Special regex characters like `.`, `*`, `(`, `)`, `$` have no special meaning. This is the safest mode for searching code containing regex metacharacters.
|
||||
- **Regex** - The query is interpreted as a regular expression. Supports character classes (`[a-z]`), quantifiers (`+`, `*`, `{n}`), alternation (`foo|bar`), anchors (`^`, `$`), word boundaries (`\b`), and more.
|
||||
- **Fuzzy** - The query is fuzzy matched using Smith-Waterman scoring. Accommodates typos and scattered characters (e.g., "mtxlk" matches "mutex_lock"). Results are filtered by a quality threshold to avoid overly fuzzy matches.
|
||||
|
||||
The current mode is shown on the right side of the input field (e.g., `plain`,
|
||||
`regex`, `fuzzy`) with color-coded highlighting.
|
||||
|
||||
You can customize which modes are available and their cycling order globally in
|
||||
your configuration, or per-call when invoking `live_grep()`.
|
||||
|
||||
**Global configuration:**
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
grep = {
|
||||
modes = { 'plain', 'regex' }, -- Only plain and regex, no fuzzy
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
**Per-call configuration:**
|
||||
|
||||
>lua
|
||||
-- Only fuzzy and plain modes for this specific grep
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Single mode (hides mode indicator completely)
|
||||
require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and
|
||||
the cycle keybind does nothing.
|
||||
|
||||
|
||||
CROSS-MODE SUGGESTIONS
|
||||
|
||||
When a search returns no results, FFF automatically queries the opposite search
|
||||
mode and displays the results as suggestions:
|
||||
|
||||
- **File search with no matches** → shows suggested **content matches** (grep results) for the same query
|
||||
- **Grep search with no matches** → shows suggested **file name matches** for the same query
|
||||
|
||||
Suggestions are clearly labeled with a "No results found. Suggested …" banner
|
||||
(highlighted with `hl.suggestion_header`). You can navigate and select
|
||||
suggestion items just like normal results — selecting a grep suggestion will
|
||||
open the file at the matching line.
|
||||
|
||||
|
||||
GIT STATUS HIGHLIGHTING
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
commonArgs
|
||||
// {
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
doCheck = false;
|
||||
}
|
||||
);
|
||||
# Copies the dynamic library into the target/release folder
|
||||
|
||||
@@ -146,6 +146,7 @@ local function init()
|
||||
send_to_quickfix = '<C-q>',
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -184,6 +185,14 @@ local function init()
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location in grep results
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
@@ -208,6 +217,13 @@ local function init()
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
local migrated_user_config = handle_deprecated_config(config)
|
||||
|
||||
+180
-220
@@ -71,6 +71,32 @@ local function cleanup_file_operation()
|
||||
end
|
||||
end
|
||||
|
||||
--- Process raw chunk data into complete lines, joining any leftover bytes
|
||||
--- from the previous chunk and storing any trailing partial line for the next.
|
||||
--- @param data string Raw chunk data
|
||||
--- @return string[] Complete lines (may be empty if the entire chunk is a partial line)
|
||||
local function split_chunk_with_remainder(data)
|
||||
if not data or data == '' then return {} end
|
||||
|
||||
local fo = M.state.file_operation
|
||||
local prefix = fo and fo.remainder or ''
|
||||
local combined = prefix .. data
|
||||
|
||||
local lines = vim.split(combined, '\n', { plain = true })
|
||||
|
||||
if combined:sub(-1) ~= '\n' then
|
||||
-- Data doesn't end on a line boundary: last element is a partial line
|
||||
local partial = table.remove(lines) or ''
|
||||
if fo then fo.remainder = partial end
|
||||
else
|
||||
-- Data ends on a line boundary: remove the trailing empty element
|
||||
if #lines > 0 and lines[#lines] == '' then table.remove(lines) end
|
||||
if fo then fo.remainder = '' end
|
||||
end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
local function init_dynamic_loading_async(file_path, callback)
|
||||
cleanup_file_operation()
|
||||
|
||||
@@ -79,7 +105,15 @@ local function init_dynamic_loading_async(file_path, callback)
|
||||
M.state.has_more_content = true
|
||||
M.state.is_loading = false
|
||||
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
-- Stale callback: preview moved on to a different file
|
||||
if M.state.preview_generation ~= generation then
|
||||
if fd then pcall(vim.uv.fs_close, fd) end
|
||||
return
|
||||
end
|
||||
|
||||
if err or not fd then
|
||||
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
|
||||
return
|
||||
@@ -89,6 +123,7 @@ local function init_dynamic_loading_async(file_path, callback)
|
||||
fd = fd,
|
||||
file_path = file_path,
|
||||
position = 0,
|
||||
remainder = '',
|
||||
}
|
||||
|
||||
callback(true)
|
||||
@@ -103,9 +138,13 @@ local function load_forward_chunk_async(target_size, callback)
|
||||
|
||||
M.state.is_loading = true
|
||||
local chunk_size = target_size or (M.config.chunk_size or 16384)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
|
||||
vim.schedule(function()
|
||||
-- Stale callback: a newer preview has started, discard this result
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
M.state.is_loading = false
|
||||
|
||||
if err then
|
||||
@@ -115,8 +154,14 @@ local function load_forward_chunk_async(target_size, callback)
|
||||
|
||||
if not data or #data == 0 then
|
||||
M.state.has_more_content = false
|
||||
-- Flush any remaining partial line as the final piece of data
|
||||
local final_remainder = M.state.file_operation and M.state.file_operation.remainder or ''
|
||||
cleanup_file_operation()
|
||||
callback('', nil)
|
||||
if final_remainder ~= '' then
|
||||
callback(final_remainder .. '\n', nil)
|
||||
else
|
||||
callback('', nil)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -135,8 +180,15 @@ local function load_next_chunk_async(chunk_size, callback)
|
||||
load_forward_chunk_async(chunk_size, callback)
|
||||
end
|
||||
|
||||
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
|
||||
local ensure_content_loaded_async
|
||||
|
||||
local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
init_dynamic_loading_async(file_path, function(success, error_msg)
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
if not success then
|
||||
callback(nil, error_msg)
|
||||
return
|
||||
@@ -159,22 +211,26 @@ local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
end
|
||||
|
||||
load_next_chunk_async(initial_chunk_size, function(data, err)
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
|
||||
if data and data ~= '' then
|
||||
-- there seems to be no other way to append the buffer other than the lines :(
|
||||
local lines = vim.split(data, '\n', { plain = true })
|
||||
local lines = split_chunk_with_remainder(data)
|
||||
M.state.loaded_lines = #lines
|
||||
M.state.content_height = #lines
|
||||
|
||||
-- If we have a location and didn't load enough lines, try to load more
|
||||
local loading_more = false
|
||||
if M.state.location then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line and #lines < target_line and M.state.has_more_content then
|
||||
-- Schedule additional loading after the initial callback
|
||||
vim.schedule(function() ensure_content_loaded_async(target_line) end)
|
||||
loading_more = true
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
callback(lines, err)
|
||||
callback(lines, err, loading_more)
|
||||
else
|
||||
callback(nil, err)
|
||||
end
|
||||
@@ -182,7 +238,7 @@ local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
end)
|
||||
end
|
||||
|
||||
local function ensure_content_loaded_async(target_line)
|
||||
ensure_content_loaded_async = function(target_line)
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
if not M.state.has_more_content or M.state.is_loading then return end
|
||||
|
||||
@@ -191,46 +247,39 @@ local function ensure_content_loaded_async(target_line)
|
||||
|
||||
if current_buffer_lines >= buffer_needed then return end
|
||||
|
||||
if current_buffer_lines < buffer_needed then
|
||||
local loading_line = string.format('Loading more content... (%d lines loaded)', M.state.loaded_lines)
|
||||
append_buffer_lines(M.state.bufnr, { '', loading_line })
|
||||
end
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
load_next_chunk_async(M.config.chunk_size, function(data, err)
|
||||
if err then
|
||||
vim.notify('Error loading file content: ' .. err, vim.log.levels.ERROR)
|
||||
-- Remove loading message on error
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Use a larger chunk to reach the target faster instead of many small 8KB reads
|
||||
local lines_needed = buffer_needed - current_buffer_lines
|
||||
local estimated_bytes = math.max(M.config.chunk_size, lines_needed * 120)
|
||||
|
||||
load_next_chunk_async(estimated_bytes, function(data, err)
|
||||
-- Stale callback: preview moved on to a different file
|
||||
if M.state.preview_generation ~= generation then return end
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
|
||||
if err then return end
|
||||
|
||||
if data and data ~= '' then
|
||||
local chunk_lines = vim.split(data, '\n', { plain = true })
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
local new_content = vim.list_extend(existing_lines, chunk_lines)
|
||||
set_buffer_lines(M.state.bufnr, new_content)
|
||||
else
|
||||
append_buffer_lines(M.state.bufnr, chunk_lines)
|
||||
end
|
||||
local chunk_lines = split_chunk_with_remainder(data)
|
||||
if #chunk_lines > 0 then append_buffer_lines(M.state.bufnr, chunk_lines) end
|
||||
|
||||
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
else
|
||||
-- No more data available - remove the loading message
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
M.state.content_height = #existing_lines
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
|
||||
-- If we still haven't loaded enough, schedule another chunk
|
||||
if M.state.loaded_lines < buffer_needed and M.state.has_more_content then
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
||||
end)
|
||||
else
|
||||
-- Enough content loaded — re-apply location highlighting so the
|
||||
-- preview scrolls to the correct line now that it exists in the buffer
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
else
|
||||
-- EOF with no additional data — apply highlighting with whatever we have
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -267,6 +316,7 @@ M.state = {
|
||||
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
|
||||
location = nil, -- Current location data for highlighting
|
||||
location_namespace = nil, -- Namespace for location highlighting
|
||||
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
|
||||
}
|
||||
|
||||
--- Setup preview configuration
|
||||
@@ -291,166 +341,6 @@ function M.is_big_file(file_path, bufnr)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if file is binary (async version)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param callback function Callback with (is_binary: boolean)
|
||||
function M.is_binary_file_async(file_path, callback)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'webp',
|
||||
'ico',
|
||||
'pdf',
|
||||
'ps',
|
||||
'eps',
|
||||
'heic',
|
||||
'avif',
|
||||
-- Archives
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
-- Executables
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
-- Audio/Video
|
||||
'mp3',
|
||||
'mp4',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
-- Other binary formats
|
||||
'db',
|
||||
'sqlite',
|
||||
'dat',
|
||||
'bin',
|
||||
'iso',
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if M.config.binary_file_threshold <= 0 then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
if err or not fd then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_read(fd, M.config.binary_file_threshold, 0, function(read_err, chunk)
|
||||
vim.uv.fs_close(fd)
|
||||
|
||||
vim.schedule(function()
|
||||
if read_err or not chunk then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
if chunk:find('\0') then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
|
||||
local printable_count = 0
|
||||
local total_count = #chunk
|
||||
|
||||
for i = 1, total_count do
|
||||
local byte = chunk:byte(i)
|
||||
-- Printable ASCII range + common control chars (tab, newline, carriage return)
|
||||
if (byte >= 32 and byte <= 126) or byte == 9 or byte == 10 or byte == 13 then
|
||||
printable_count = printable_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
local printable_ratio = printable_count / total_count
|
||||
callback(printable_ratio < 0.8) -- More aggressive: If less than 80% printable, consider binary
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check if file is binary (sync version kept for compatibility)
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file appears to be binary
|
||||
function M.is_binary_file(file_path)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'webp',
|
||||
'ico',
|
||||
'pdf',
|
||||
'ps',
|
||||
'eps',
|
||||
'heic',
|
||||
'avif',
|
||||
-- Archives
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
-- Executables
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
-- Audio/Video
|
||||
'mp3',
|
||||
'mp4',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
'aac',
|
||||
-- Other binary formats
|
||||
'db',
|
||||
'sqlite',
|
||||
'dat',
|
||||
'bin',
|
||||
'iso',
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then return true end
|
||||
end
|
||||
|
||||
-- For sync version, just return false for unknown extensions to avoid blocking
|
||||
-- The main preview logic will handle this with async detection
|
||||
return false
|
||||
end
|
||||
|
||||
--- Get file information
|
||||
--- @param file_path string Path to the file
|
||||
--- @return table | nil File information
|
||||
@@ -536,6 +426,48 @@ function M.create_file_info_content(file, info, file_index)
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Create file info content for grep mode items.
|
||||
--- Shows grep-specific metadata: match location, frecency, file info.
|
||||
---@param item table Grep match item with file + match metadata
|
||||
---@param info table File system information from get_file_info
|
||||
---@return table Lines for the file info content
|
||||
function M.create_grep_file_info_content(item, info)
|
||||
local lines = {}
|
||||
|
||||
-- Match location info
|
||||
local match_count = item.match_ranges and #item.match_ranges or 0
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('Match: line %d, col %d │ Ranges: %d', item.line_number or 0, (item.col or 0) + 1, match_count)
|
||||
)
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('Byte Offset: %-12d │ Size: %s', item.byte_offset or 0, info.size_formatted or 'N/A')
|
||||
)
|
||||
table.insert(lines, string.format('Type: %-8s │ Git: %s', info.filetype or 'text', item.git_status or 'clean'))
|
||||
|
||||
-- Fuzzy match score (only available in fuzzy grep mode)
|
||||
if item.fuzzy_score then table.insert(lines, string.format('Fuzzy Score: %d', item.fuzzy_score)) end
|
||||
|
||||
-- Frecency info
|
||||
local total = item.total_frecency_score or 0
|
||||
local acc = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
table.insert(lines, string.format('Frecency: total=%d, access=%d, modification=%d', total, acc, mod))
|
||||
|
||||
-- Ordering explanation
|
||||
table.insert(lines, 'Order: files sorted by frecency desc, matches by line asc')
|
||||
table.insert(lines, '')
|
||||
|
||||
-- Time information section
|
||||
table.insert(lines, 'TIMINGS')
|
||||
table.insert(lines, string.rep('─', 50))
|
||||
table.insert(lines, string.format('Modified: %s', info.modified_formatted or 'N/A'))
|
||||
table.insert(lines, string.format('Last Access: %s', info.accessed_formatted or 'N/A'))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Preview a regular file
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number Buffer number for preview
|
||||
@@ -573,12 +505,14 @@ function M.preview_file(file_path, bufnr)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
local gen = M.state.preview_generation
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == gen then M.apply_location_highlighting(bufnr) end
|
||||
end)
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -586,10 +520,11 @@ function M.preview_file(file_path, bufnr)
|
||||
|
||||
M.state.current_file = file_path
|
||||
M.state.bufnr = bufnr
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err)
|
||||
if M.state.current_file ~= file_path then
|
||||
-- User has moved to a different file, ignore this result
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err, loading_more)
|
||||
if M.state.preview_generation ~= generation then
|
||||
-- Preview moved on to a different file, discard
|
||||
cleanup_file_operation()
|
||||
return
|
||||
end
|
||||
@@ -602,6 +537,9 @@ function M.preview_file(file_path, bufnr)
|
||||
end
|
||||
|
||||
if M.state.current_file == file_path then
|
||||
-- Guard against buffer being destroyed while async read was in-flight
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
M.clear_preview_visual_state(bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
@@ -611,13 +549,18 @@ function M.preview_file(file_path, bufnr)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready).
|
||||
-- Skip when more content is being loaded asynchronously to reach the target line —
|
||||
-- ensure_content_loaded_async will re-apply highlighting once the target is in the buffer.
|
||||
if not loading_more then
|
||||
vim.schedule(function()
|
||||
if M.state.preview_generation == generation then M.apply_location_highlighting(bufnr) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -693,15 +636,13 @@ end
|
||||
--- @param file_path string Path to the file or directory
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @param location table|nil Optional location data for highlighting
|
||||
--- @param is_binary boolean|nil Whether the file is binary (from Rust indexer)
|
||||
--- @return boolean if the preview was successful
|
||||
function M.preview(file_path, bufnr, location)
|
||||
if not file_path or file_path == '' then
|
||||
-- Don't immediately clear - let the previous content stay visible
|
||||
-- Only clear if we really need to show "No file selected"
|
||||
-- M.clear_buffer(bufnr)
|
||||
-- set_buffer_lines(bufnr, { 'No file selected' })
|
||||
return false
|
||||
end
|
||||
function M.preview(file_path, bufnr, location, is_binary)
|
||||
if not file_path or file_path == '' then return false end
|
||||
|
||||
-- Bump generation to invalidate any in-flight async callbacks from previous previews
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
if M.state.file_handle then
|
||||
M.state.file_handle:close()
|
||||
@@ -726,7 +667,7 @@ function M.preview(file_path, bufnr, location)
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
|
||||
|
||||
return image.display_image(file_path, bufnr, win_width, win_height)
|
||||
elseif M.is_binary_file(file_path) then
|
||||
elseif is_binary then
|
||||
return M.preview_binary_file(file_path, bufnr)
|
||||
else
|
||||
return M.preview_file(file_path, bufnr)
|
||||
@@ -778,8 +719,9 @@ end
|
||||
function M.set_preview_window(winid) M.state.winid = winid end
|
||||
|
||||
--- Update file info buffer
|
||||
--- @param file table File information from search results
|
||||
--- @param file table File information from search results (or grep match item)
|
||||
--- @param bufnr number Buffer number for file info
|
||||
--- @param file_index number|nil Index of the file in search results (for score lookup, file mode only)
|
||||
--- @return boolean Success status
|
||||
function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
if not file then
|
||||
@@ -793,7 +735,13 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
return false
|
||||
end
|
||||
|
||||
local file_info_lines = M.create_file_info_content(file, info, file_index)
|
||||
-- Detect grep mode items by the presence of line_number (grep-specific field)
|
||||
local file_info_lines
|
||||
if file.line_number ~= nil then
|
||||
file_info_lines = M.create_grep_file_info_content(file, info)
|
||||
else
|
||||
file_info_lines = M.create_file_info_content(file, info, file_index)
|
||||
end
|
||||
set_buffer_lines(bufnr, file_info_lines)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
@@ -848,6 +796,9 @@ function M.clear_buffer(bufnr)
|
||||
end
|
||||
|
||||
function M.clear()
|
||||
-- Bump generation to invalidate any in-flight async callbacks
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
cleanup_file_operation()
|
||||
|
||||
M.state.loaded_lines = 0
|
||||
@@ -883,7 +834,16 @@ function M.apply_location_highlighting(bufnr)
|
||||
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line then M.scroll_to_line(target_line) end
|
||||
if target_line then
|
||||
local buffer_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
if target_line > buffer_lines and M.state.has_more_content then
|
||||
-- Target line is beyond loaded content — load more first.
|
||||
-- ensure_content_loaded_async will re-apply highlighting when done.
|
||||
ensure_content_loaded_async(target_line)
|
||||
return
|
||||
end
|
||||
M.scroll_to_line(target_line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -38,10 +38,15 @@ M.init_query_db = rust_module.init_query_db
|
||||
M.destroy_query_db = rust_module.destroy_query_db
|
||||
M.track_query_completion = rust_module.track_query_completion
|
||||
M.get_historical_query = rust_module.get_historical_query
|
||||
M.track_grep_query = rust_module.track_grep_query
|
||||
M.get_historical_grep_query = rust_module.get_historical_grep_query
|
||||
|
||||
-- Git functions
|
||||
M.get_git_root = rust_module.get_git_root
|
||||
|
||||
-- Grep functions
|
||||
M.live_grep = rust_module.live_grep
|
||||
|
||||
-- Utility functions
|
||||
M.health_check = rust_module.health_check
|
||||
M.shorten_path = rust_module.shorten_path
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
--- Grep Renderer
|
||||
--- Custom renderer for live grep results with file grouping.
|
||||
--- Consecutive matches from the same file are grouped under a file header line.
|
||||
--- The header reuses the same rendering as the file picker list (file_renderer)
|
||||
--- for visual consistency — same icon, filename, directory path, git highlights.
|
||||
local M = {}
|
||||
|
||||
local file_renderer = require('fff.file_renderer')
|
||||
|
||||
--- Build the file group header line using the same layout as file_renderer.
|
||||
--- Delegates to file_renderer.render_line (with combo disabled).
|
||||
---@param item table Grep match item (used for file metadata)
|
||||
---@param ctx table Render context
|
||||
---@return string The header line string
|
||||
local function build_group_header(item, ctx)
|
||||
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
|
||||
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
|
||||
local saved_has_combo = ctx.has_combo
|
||||
ctx.has_combo = false
|
||||
local lines = file_renderer.render_line(item, ctx, 0)
|
||||
ctx.has_combo = saved_has_combo
|
||||
return lines[1]
|
||||
end
|
||||
|
||||
--- Apply highlights for a file group header line using file_renderer.
|
||||
--- Delegates to file_renderer.apply_highlights so all highlight groups
|
||||
--- (icon, filename, git text color, directory path, git sign) match exactly.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param row number 0-based row in buffer (header line)
|
||||
local function apply_group_header_highlights(item, ctx, buf, ns_id, row)
|
||||
local line_content = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] or ''
|
||||
-- file_renderer.apply_highlights uses 1-based line_idx and checks (cursor == item_idx).
|
||||
-- Pass item_idx=0 so the header is never treated as the cursor item.
|
||||
local saved_cursor = ctx.cursor
|
||||
ctx.cursor = -1
|
||||
file_renderer.apply_highlights(item, ctx, 0, buf, ns_id, row + 1, line_content)
|
||||
ctx.cursor = saved_cursor
|
||||
end
|
||||
|
||||
--- Render a grep match line (grouped: no filename, just location + content).
|
||||
--- Format: " :line:col matched line content"
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@return string The match line string
|
||||
local function render_match_line(item, ctx)
|
||||
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
|
||||
local separator = ' '
|
||||
local raw_content = item.line_content or ''
|
||||
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
|
||||
local content = vim.trim(raw_content)
|
||||
|
||||
-- Indent + location + separator + content
|
||||
local indent = ' '
|
||||
-- Prefix is always ASCII so byte length == display width
|
||||
local prefix_display_w = #indent + #location + #separator
|
||||
local available = ctx.win_width - prefix_display_w - 2
|
||||
local was_truncated = false
|
||||
local content_display_w = vim.fn.strdisplaywidth(content)
|
||||
if content_display_w > available and available > 3 then
|
||||
-- UTF-8 aware truncation: binary search for the character count that
|
||||
-- fits within the available display width (handles multi-byte and wide chars)
|
||||
local nchars = vim.fn.strchars(content)
|
||||
local lo, hi = 0, nchars
|
||||
while lo < hi do
|
||||
local mid = math.floor((lo + hi + 1) / 2)
|
||||
if vim.fn.strdisplaywidth(vim.fn.strcharpart(content, 0, mid)) <= available - 1 then
|
||||
lo = mid
|
||||
else
|
||||
hi = mid - 1
|
||||
end
|
||||
end
|
||||
content = vim.fn.strcharpart(content, 0, lo) .. '…'
|
||||
was_truncated = true
|
||||
end
|
||||
|
||||
local line = indent .. location .. separator .. content
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
|
||||
-- Store transient data on item for highlight pass
|
||||
item._leading_ws = leading_ws
|
||||
item._was_truncated = was_truncated
|
||||
item._match_indent = #indent
|
||||
item._content_offset = prefix_display_w -- byte offset where content starts in the line
|
||||
item._trimmed_content = content -- trimmed content string for treesitter parsing
|
||||
|
||||
return line .. string.rep(' ', padding)
|
||||
end
|
||||
|
||||
--- Apply highlights for a grouped match line.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param row number 0-based row in buffer
|
||||
---@param line_content string The rendered line text
|
||||
local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line_content)
|
||||
local config = ctx.config
|
||||
local is_cursor = item_idx == ctx.cursor
|
||||
local indent = item._match_indent or 1
|
||||
|
||||
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
|
||||
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
|
||||
-- cleanly override both fg and bg on the cursor line.
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
||||
end_col = 0,
|
||||
end_row = row + 1,
|
||||
hl_group = config.hl.cursor,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 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 loc_start = indent
|
||||
local loc_end = loc_start + #location_str
|
||||
if loc_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, loc_start, {
|
||||
end_col = loc_end,
|
||||
hl_group = config.hl.grep_line_number or 'LineNr',
|
||||
priority = 150,
|
||||
})
|
||||
end
|
||||
|
||||
-- 3. Separator dimmed
|
||||
local sep_start = loc_end
|
||||
local sep_end = sep_start + 2
|
||||
if sep_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, sep_start, {
|
||||
end_col = sep_end,
|
||||
hl_group = 'Comment',
|
||||
priority = 150,
|
||||
})
|
||||
end
|
||||
|
||||
-- 4. Treesitter syntax highlighting for the content portion.
|
||||
-- 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
|
||||
local ts_hl = require('fff.treesitter_hl')
|
||||
-- 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]
|
||||
if lang == nil then
|
||||
lang = ts_hl.lang_from_filename(item.name) or false
|
||||
ctx._ts_lang_cache[item.name] = lang
|
||||
end
|
||||
if lang then
|
||||
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
|
||||
for _, hl in ipairs(highlights) do
|
||||
local hl_start = content_start + hl.col
|
||||
local hl_end = content_start + hl.end_col
|
||||
if hl_start < #line_content and hl_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
||||
end_col = hl_end,
|
||||
hl_group = hl.hl_group,
|
||||
priority = 120,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 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
|
||||
local leading_ws = item._leading_ws or 0
|
||||
for _, range in ipairs(item.match_ranges) do
|
||||
local raw_start = range[1] or 0
|
||||
local raw_end = range[2] or 0
|
||||
local adj_start = raw_start - leading_ws
|
||||
local adj_end = raw_end - leading_ws
|
||||
if adj_end > 0 then
|
||||
adj_start = math.max(0, adj_start)
|
||||
local hl_start = content_start + adj_start
|
||||
local hl_end = content_start + adj_end
|
||||
if hl_start < #line_content and hl_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
||||
end_col = hl_end,
|
||||
hl_group = config.hl.grep_match or 'IncSearch',
|
||||
priority = 200,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 6. Selection marker (per-occurrence in grep mode)
|
||||
if ctx.selected_items then
|
||||
local key = string.format('%s:%d:%d', item.path, item.line_number or 0, item.col or 0)
|
||||
if ctx.selected_items[key] then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = config.hl.selected or 'FFFSelected',
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render a single item's lines (called by list_renderer's generate_item_lines).
|
||||
--- Returns 2 lines [header, match] for the first match of a file group,
|
||||
--- or 1 line [match] for subsequent matches in the same file.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@return string[]
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
-- Track file grouping across the render pass via ctx
|
||||
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
|
||||
local is_new_group = (item.path ~= ctx._grep_last_file)
|
||||
ctx._grep_last_file = item.path
|
||||
|
||||
local match_line = render_match_line(item, ctx)
|
||||
|
||||
if is_new_group then
|
||||
local header_line = build_group_header(item, ctx)
|
||||
item._has_group_header = true
|
||||
return { header_line, match_line }
|
||||
else
|
||||
item._has_group_header = false
|
||||
return { match_line }
|
||||
end
|
||||
end
|
||||
|
||||
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
|
||||
--- line_idx is the 1-based index of the item's LAST line (the match line).
|
||||
--- If the item has a group header, it's at line_idx - 1.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param line_idx number 1-based line index of the match line
|
||||
---@param line_content string The rendered match line text
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local row = line_idx - 1 -- 0-based for nvim API
|
||||
|
||||
-- Apply match line highlights
|
||||
apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line_content)
|
||||
|
||||
-- If this item has a group header, highlight it (the line above)
|
||||
-- using file_renderer for identical appearance to the file picker list.
|
||||
if item._has_group_header then apply_group_header_highlights(item, ctx, buf, ns_id, row - 1) end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,54 @@
|
||||
--- Grep search bridge — wraps the Rust `live_grep` FFI function
|
||||
--- with file-based pagination state tracking.
|
||||
---@class fff.grep
|
||||
local M = {}
|
||||
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
|
||||
---@class fff.grep.SearchResult
|
||||
---@field items table[] Array of grep match items
|
||||
---@field total_matched number Total matches found in this call
|
||||
---@field total_files_searched number Files actually searched in this call
|
||||
---@field total_files number Total indexed files
|
||||
---@field filtered_file_count number Total searchable files after filtering
|
||||
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
|
||||
|
||||
local last_result = nil
|
||||
|
||||
--- Perform a grep search.
|
||||
---@param query string The search query (may contain file constraints like *.rs)
|
||||
---@param file_offset? number Index into sorted file list to start from (default 0)
|
||||
---@param page_size? number Max matches to collect (default 50)
|
||||
---@param config? table Grep configuration overrides
|
||||
---@param grep_mode? string Search mode: "plain" (default), "regex", or "fuzzy"
|
||||
---@return fff.grep.SearchResult
|
||||
function M.search(query, file_offset, page_size, config, grep_mode)
|
||||
local conf = config or {}
|
||||
last_result = fuzzy.live_grep(
|
||||
query or '',
|
||||
file_offset or 0,
|
||||
page_size or 50,
|
||||
conf.max_file_size,
|
||||
conf.max_matches_per_file,
|
||||
conf.smart_case,
|
||||
grep_mode or 'plain',
|
||||
conf.time_budget_ms
|
||||
)
|
||||
return last_result
|
||||
end
|
||||
|
||||
--- Get metadata from the last search result.
|
||||
---@return { total_matched: number, total_files_searched: number, total_files: number, next_file_offset: number }
|
||||
function M.get_search_metadata()
|
||||
if not last_result then
|
||||
return { total_matched = 0, total_files_searched = 0, total_files = 0, next_file_offset = 0 }
|
||||
end
|
||||
return {
|
||||
total_matched = last_result.total_matched or 0,
|
||||
total_files_searched = last_result.total_files_searched or 0,
|
||||
total_files = last_result.total_files or 0,
|
||||
next_file_offset = last_result.next_file_offset or 0,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,278 @@
|
||||
--- List Renderer
|
||||
--- Handles all list rendering: line generation, virtual rows, bottom padding,
|
||||
--- buffer writes, cursor positioning, and highlight application.
|
||||
---
|
||||
--- Virtual rows (combo headers, grep file group headers) are decorations that
|
||||
--- belong to buffer rendering, NOT to the data model. The cursor and selection
|
||||
--- always operate on the items array (1-based indices), never on buffer lines.
|
||||
---
|
||||
--- Pagination is unaffected: Rust returns N items per page. The renderer may
|
||||
--- produce N + K buffer lines (where K = number of virtual header rows), but
|
||||
--- the page_size contract with Rust stays item-based.
|
||||
---
|
||||
--- Selection always operates on item.path keys. Virtual rows have no identity
|
||||
--- of their own — they derive from the item they belong to.
|
||||
local M = {}
|
||||
|
||||
--- @class ListRenderContext
|
||||
--- @field config table User configuration
|
||||
--- @field items table[] Array of data items to render
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Actual text area width (excluding signcolumn)
|
||||
--- @field debug_enabled boolean Whether debug mode shows scores
|
||||
--- @field prompt_position string 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string|nil Formatted combo header line
|
||||
--- @field combo_header_text_len number|nil Length of combo header text
|
||||
--- @field combo_item_index number|nil Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items (1)
|
||||
--- @field display_end number End index for displayed items (#items)
|
||||
--- @field iter_start number Iteration start
|
||||
--- @field iter_end number Iteration end
|
||||
--- @field iter_step number Iteration step (1 or -1)
|
||||
--- @field renderer table|nil Custom renderer with render_line/apply_highlights
|
||||
--- @field query string Current search query
|
||||
--- @field selected_files table<string, boolean> Selected file paths set
|
||||
--- @field mode string|nil Current mode (nil or 'grep')
|
||||
--- @field format_file_display function Helper for formatting file display
|
||||
|
||||
--- @class ItemLineMapping
|
||||
--- @field first number First buffer line (1-based) this item occupies
|
||||
--- @field last number Last buffer line (1-based) — the selectable content line
|
||||
--- @field virtual_count number Number of virtual (header) lines before the content line
|
||||
|
||||
--- @class ListRenderResult
|
||||
--- @field lines string[] All buffer lines (including virtual rows and padding)
|
||||
--- @field item_to_lines table<number, ItemLineMapping> Maps item index -> line range
|
||||
--- @field padding_offset number Number of empty lines prepended for bottom prompt
|
||||
--- @field total_content_lines number Lines before padding was applied
|
||||
|
||||
--- Generate all display lines from items using the renderer.
|
||||
--- Each item may produce 1 or more lines (virtual header + content).
|
||||
--- When cross-mode suggestions are active, a suggestion banner is prepended
|
||||
--- (for top prompt) or appended (for bottom prompt) so it always appears
|
||||
--- above the suggestion items visually.
|
||||
--- @param ctx ListRenderContext
|
||||
--- @return string[] lines Array of line strings
|
||||
--- @return table<number, ItemLineMapping> item_to_lines
|
||||
local function generate_item_lines(ctx)
|
||||
local lines = {}
|
||||
local item_to_lines = {}
|
||||
|
||||
-- Cross-mode suggestion header: rendered above items visually.
|
||||
-- For top prompt that means before items; for bottom prompt after items
|
||||
-- (because bottom prompt iterates in reverse).
|
||||
local suggestion_header_lines = {}
|
||||
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
|
||||
if has_suggestion_header then
|
||||
table.insert(suggestion_header_lines, '')
|
||||
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
|
||||
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
|
||||
table.insert(suggestion_header_lines, '')
|
||||
end
|
||||
|
||||
-- For top prompt: suggestion header goes before items
|
||||
if has_suggestion_header and ctx.prompt_position ~= 'bottom' then
|
||||
for _, hline in ipairs(suggestion_header_lines) do
|
||||
table.insert(lines, hline)
|
||||
end
|
||||
end
|
||||
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
|
||||
local item = ctx.items[i]
|
||||
local item_start_line = #lines + 1
|
||||
|
||||
-- Renderer returns 1+ lines: virtual headers first, content line last.
|
||||
-- This contract is shared by file_renderer (combo header) and
|
||||
-- grep_renderer (file group header).
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
|
||||
for _, line in ipairs(item_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
local item_end_line = #lines
|
||||
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
|
||||
|
||||
item_to_lines[i] = {
|
||||
first = item_start_line,
|
||||
last = item_end_line,
|
||||
virtual_count = virtual_count,
|
||||
}
|
||||
end
|
||||
|
||||
-- For bottom prompt: suggestion header goes after items (appears above visually)
|
||||
if has_suggestion_header and ctx.prompt_position == 'bottom' then
|
||||
for _, hline in ipairs(suggestion_header_lines) do
|
||||
table.insert(lines, hline)
|
||||
end
|
||||
end
|
||||
|
||||
return lines, item_to_lines
|
||||
end
|
||||
|
||||
--- Apply bottom padding: prepend empty lines so content sits at the bottom.
|
||||
--- Adjusts all line indices in item_to_lines accordingly.
|
||||
--- @param lines string[] Lines array (mutated)
|
||||
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
|
||||
--- @param ctx ListRenderContext
|
||||
--- @return number padding_offset Number of empty lines prepended
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return 0 end
|
||||
|
||||
local total_content_lines = #lines
|
||||
local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines)
|
||||
|
||||
if empty_lines_needed > 0 then
|
||||
-- Prepend empty lines
|
||||
for _ = empty_lines_needed, 1, -1 do
|
||||
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
|
||||
end
|
||||
|
||||
-- Shift all line indices
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
if item_to_lines[i] then
|
||||
item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed
|
||||
item_to_lines[i].last = item_to_lines[i].last + empty_lines_needed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return empty_lines_needed
|
||||
end
|
||||
|
||||
--- Write lines to the buffer and position the cursor on the correct line.
|
||||
--- The cursor always targets the content line (last) of the current item,
|
||||
--- never a virtual header line.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param list_buf number Buffer handle
|
||||
--- @param list_win number Window handle
|
||||
--- @param ns_id number Namespace id
|
||||
local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
|
||||
-- Resolve cursor to a buffer line — always the content line (last), not virtual rows
|
||||
local cursor_line = 0
|
||||
if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then
|
||||
local cursor_item = item_to_lines[ctx.cursor]
|
||||
if cursor_item then cursor_line = cursor_item.last end
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
|
||||
|
||||
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
|
||||
|
||||
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
|
||||
vim.api.nvim_win_set_cursor(list_win, { cursor_line, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
--- Apply highlights for all items using the renderer's apply_highlights.
|
||||
--- For each item, we pass the content line (last) to the renderer.
|
||||
--- Renderers that emit virtual rows (grep_renderer) handle their own
|
||||
--- header highlights internally via the item._has_group_header flag.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param list_buf number
|
||||
--- @param ns_id number
|
||||
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
--- Render the full item list into the buffer.
|
||||
--- This is the main entry point — replaces the inline rendering in picker_ui.
|
||||
---
|
||||
--- @param ctx ListRenderContext Render context built by picker_ui
|
||||
--- @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 for combo/scrollbar use
|
||||
function M.render(ctx, list_buf, list_win, ns_id)
|
||||
local lines, item_to_lines = generate_item_lines(ctx)
|
||||
|
||||
apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
|
||||
|
||||
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) end
|
||||
|
||||
-- Highlight the suggestion header lines (if present)
|
||||
if ctx.suggestion_source and #ctx.items > 0 then
|
||||
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
|
||||
for i = 0, #lines - 1 do
|
||||
local line = lines[i + 1]
|
||||
if line and line:match('^%s+No results found') then
|
||||
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return item_to_lines
|
||||
end
|
||||
|
||||
--- Get the buffer line for an item's content (selectable) line.
|
||||
--- Used by picker_ui for cursor positioning after navigation.
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param item_index number 1-based item index
|
||||
--- @return number|nil line 1-based buffer line, or nil if item not mapped
|
||||
function M.get_content_line(item_to_lines, item_index)
|
||||
local mapping = item_to_lines[item_index]
|
||||
if not mapping then return nil end
|
||||
return mapping.last
|
||||
end
|
||||
|
||||
--- Get the buffer line for an item's first line (may be a virtual header).
|
||||
--- Used by combo_renderer for overlay positioning.
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param item_index number 1-based item index
|
||||
--- @return number|nil line 1-based buffer line, or nil if item not mapped
|
||||
function M.get_first_line(item_to_lines, item_index)
|
||||
local mapping = item_to_lines[item_index]
|
||||
if not mapping then return nil end
|
||||
return mapping.first
|
||||
end
|
||||
|
||||
--- Check if an item has virtual (header) rows.
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param item_index number 1-based item index
|
||||
--- @return boolean
|
||||
function M.has_virtual_rows(item_to_lines, item_index)
|
||||
local mapping = item_to_lines[item_index]
|
||||
if not mapping then return false end
|
||||
return mapping.virtual_count > 0
|
||||
end
|
||||
|
||||
--- Count total buffer lines an item occupies (content + virtual).
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param item_index number 1-based item index
|
||||
--- @return number
|
||||
function M.get_line_count(item_to_lines, item_index)
|
||||
local mapping = item_to_lines[item_index]
|
||||
if not mapping then return 0 end
|
||||
return mapping.last - mapping.first + 1
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -38,6 +38,11 @@ function M.highlight_location(bufnr, location, namespace)
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local extmarks = {}
|
||||
|
||||
-- Grep mode: highlight all occurrences of the search pattern across visible lines
|
||||
if location.grep_query and location.grep_query ~= '' then
|
||||
return M.highlight_grep_matches(bufnr, location, namespace)
|
||||
end
|
||||
|
||||
if location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
|
||||
@@ -129,6 +134,98 @@ function M.highlight_location(bufnr, location, namespace)
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
--- Highlight all occurrences of a grep pattern in the preview buffer.
|
||||
--- For plain text and regex modes: highlights every match on all loaded lines
|
||||
--- using Lua string.find with the query text.
|
||||
--- For fuzzy mode: uses the pre-computed match byte offsets from Rust on the
|
||||
--- target line only, since the fuzzy needle (e.g. "shcema") won't match via
|
||||
--- literal search against the actual content (e.g. "schema").
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param location table Location with .grep_query, .line, optional .col, optional .fuzzy_match_ranges
|
||||
--- @param namespace number Namespace for extmarks
|
||||
--- @return table|nil Highlight extmark details for cleanup
|
||||
function M.highlight_grep_matches(bufnr, location, namespace)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return nil end
|
||||
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local extmarks = {}
|
||||
|
||||
-- Target line highlighting is handled by the native `cursorline` window
|
||||
-- option, which is enabled on the preview window in grep mode (picker_ui.lua).
|
||||
-- The cursor is positioned on the target line by preview.scroll_to_line(),
|
||||
-- giving standard CursorLine background + CursorLineNr line number styling
|
||||
-- without conflicting with IncSearch match highlights.
|
||||
|
||||
-- Fuzzy mode: use pre-computed byte offsets from Rust's match_indices.
|
||||
-- These are the exact matched character positions within the line, already
|
||||
-- computed by the SIMD scoring + reference smith-waterman traceback.
|
||||
-- We only highlight the target line since each fuzzy result has its own
|
||||
-- unique set of matched positions.
|
||||
if location.fuzzy_match_ranges and location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
for _, range in ipairs(location.fuzzy_match_ranges) do
|
||||
local start_byte = range[1] -- 0-based byte offset
|
||||
local end_byte = range[2] -- 0-based exclusive end
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, start_byte, {
|
||||
end_col = end_byte,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
end
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
local query = location.grep_query
|
||||
|
||||
-- Extract the actual search text from the grep query (strip file constraints like *.rs /src/)
|
||||
-- The query parser uses space-separated tokens; the first non-constraint token is the pattern.
|
||||
-- Simple heuristic: strip tokens that look like constraints (start with *, /, or !)
|
||||
local search_text = query
|
||||
local parts = vim.split(query, '%s+')
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(parts) do
|
||||
if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end
|
||||
end
|
||||
if #text_parts > 0 then search_text = text_parts[1] end
|
||||
|
||||
if not search_text or search_text == '' then return nil end
|
||||
|
||||
-- Build case-insensitive pattern if the query has no uppercase (smart case)
|
||||
local has_upper = search_text:match('[A-Z]')
|
||||
local escaped = vim.pesc(search_text)
|
||||
|
||||
-- Highlight pattern occurrences in a window around the target line.
|
||||
-- Limit to ±200 lines from target to keep it fast for large files.
|
||||
local scan_start = 1
|
||||
local scan_end = line_count
|
||||
if location.line then
|
||||
scan_start = math.max(1, location.line - 200)
|
||||
scan_end = math.min(line_count, location.line + 200)
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, scan_start - 1, scan_end, false)
|
||||
for idx, line in ipairs(lines) do
|
||||
local i = scan_start + idx - 1
|
||||
local search_line = has_upper and line or line:lower()
|
||||
local search_pat = has_upper and escaped or escaped:lower()
|
||||
local start_pos = 1
|
||||
while true do
|
||||
local s, e = search_line:find(search_pat, start_pos, true)
|
||||
if not s then break end
|
||||
-- s and e are 1-based byte positions; extmarks need 0-based
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, i - 1, s - 1, {
|
||||
end_col = e,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = i - 1 }) end
|
||||
start_pos = e + 1
|
||||
end
|
||||
end
|
||||
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
--- Clear location highlights from a buffer
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param namespace number Namespace for extmarks
|
||||
|
||||
@@ -17,6 +17,36 @@ function M.find_files(opts)
|
||||
end
|
||||
end
|
||||
|
||||
--- Live grep: search file contents in the current directory
|
||||
--- @param opts? table Optional configuration overrides
|
||||
--- @param opts.cwd? string Custom working directory
|
||||
--- @param opts.title? string Window title (default: "Live Grep")
|
||||
--- @param opts.prompt? string Input prompt text (default: "grep> ")
|
||||
--- @param opts.layout? table Layout overrides
|
||||
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
|
||||
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
|
||||
function M.live_grep(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if not picker_ok then
|
||||
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
local grep_renderer = require('fff.grep.grep_renderer')
|
||||
|
||||
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
|
||||
|
||||
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
|
||||
title = (opts and opts.title) or 'Live Grep',
|
||||
mode = 'grep',
|
||||
renderer = grep_renderer,
|
||||
grep_config = grep_config,
|
||||
})
|
||||
|
||||
picker_ui.open(picker_opts)
|
||||
end
|
||||
|
||||
function M.find_in_git_root()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
|
||||
+749
-282
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
--- Treesitter Highlight Extraction
|
||||
--- Extracts syntax highlights from a code string using treesitter.
|
||||
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
|
||||
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
|
||||
local M = {}
|
||||
|
||||
--- Per-language scratch buffer cache
|
||||
--- @type table<string, number>
|
||||
local scratch_bufs = {}
|
||||
|
||||
--- Get or create a scratch buffer for a given treesitter language.
|
||||
--- The buffer is reused across calls — content is overwritten each time.
|
||||
--- @param lang string Treesitter language name
|
||||
--- @return number buf Buffer handle
|
||||
local function get_scratch_buf(lang)
|
||||
local buf = scratch_bufs[lang]
|
||||
if buf and vim.api.nvim_buf_is_valid(buf) then return buf end
|
||||
|
||||
buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_name(buf, 'fff://treesitter/' .. lang)
|
||||
vim.bo[buf].bufhidden = 'hide'
|
||||
vim.bo[buf].buftype = 'nofile'
|
||||
vim.bo[buf].swapfile = false
|
||||
vim.bo[buf].undolevels = -1
|
||||
scratch_bufs[lang] = buf
|
||||
return buf
|
||||
end
|
||||
|
||||
--- Resolve a filename to a treesitter language.
|
||||
--- Returns nil if no parser is available.
|
||||
--- @param filename string File name (e.g. "foo.rs")
|
||||
--- @return string|nil lang Treesitter language name, or nil
|
||||
function M.lang_from_filename(filename)
|
||||
if not filename or filename == '' then return nil end
|
||||
|
||||
-- Use vim.filetype.match to get the filetype from the filename
|
||||
local ok, ft = pcall(vim.filetype.match, { filename = filename })
|
||||
if not ok or not ft then return nil end
|
||||
|
||||
-- Convert filetype to treesitter language
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
|
||||
if not lang_ok or not lang then lang = ft end
|
||||
|
||||
-- Check if the parser is actually installed
|
||||
local has_parser = pcall(vim.treesitter.language.add, lang)
|
||||
if not has_parser then return nil end
|
||||
|
||||
return lang
|
||||
end
|
||||
|
||||
--- Extract treesitter highlights for a single line of code.
|
||||
--- Returns an array of { col, end_col, hl_group } tables where col/end_col
|
||||
--- are 0-based byte offsets within the input string.
|
||||
---
|
||||
--- @param text string The line of code to highlight
|
||||
--- @param lang string Treesitter language name (from lang_from_filename)
|
||||
--- @return table[] highlights Array of { col: number, end_col: number, hl_group: string }
|
||||
function M.get_line_highlights(text, lang)
|
||||
if not text or text == '' or not lang then return {} end
|
||||
|
||||
local buf = get_scratch_buf(lang)
|
||||
|
||||
-- Write the single line into the scratch buffer
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text })
|
||||
vim.bo[buf].modifiable = false
|
||||
|
||||
-- Parse with treesitter
|
||||
local ok, parser = pcall(vim.treesitter.get_parser, buf, lang)
|
||||
if not ok or not parser then return {} end
|
||||
|
||||
local parse_ok = pcall(parser.parse, parser, true)
|
||||
if not parse_ok then return {} end
|
||||
|
||||
local highlights = {}
|
||||
|
||||
parser:for_each_tree(function(tstree, tree)
|
||||
if not tstree then return end
|
||||
local root = tstree:root()
|
||||
if not root then return end
|
||||
|
||||
local tree_lang = tree:lang()
|
||||
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
|
||||
if not query_ok or not query then return end
|
||||
|
||||
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
|
||||
local name = query.captures[capture]
|
||||
if name and name ~= 'spell' and name ~= 'conceal' then
|
||||
local start_row, start_col, end_row, end_col = node:range()
|
||||
-- Only process highlights on line 0 (our single line)
|
||||
if start_row == 0 then
|
||||
if end_row > 0 then end_col = #text end -- multi-line node: clamp to line end
|
||||
if start_col < end_col then
|
||||
highlights[#highlights + 1] = {
|
||||
col = start_col,
|
||||
end_col = end_col,
|
||||
hl_group = '@' .. name .. '.' .. tree_lang,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return highlights
|
||||
end
|
||||
|
||||
--- Clean up all scratch buffers.
|
||||
--- Called when the picker closes.
|
||||
function M.cleanup()
|
||||
for lang, buf in pairs(scratch_bufs) do
|
||||
if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end
|
||||
scratch_bufs[lang] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-darwin-arm64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.dylib",
|
||||
"files": ["libfff_c.dylib"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-darwin-arm64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-darwin-x64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for macOS x64 (Intel)",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"],
|
||||
"main": "libfff_c.dylib",
|
||||
"files": ["libfff_c.dylib"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-darwin-x64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-arm64-gnu",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux ARM64 (glibc)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-arm64-gnu"
|
||||
},
|
||||
"libc": ["glibc"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-arm64-musl",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux ARM64 (musl)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-arm64-musl"
|
||||
},
|
||||
"libc": ["musl"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-x64-gnu",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux x64 (glibc)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-x64-gnu"
|
||||
},
|
||||
"libc": ["glibc"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-linux-x64-musl",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Linux x64 (musl)",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "libfff_c.so",
|
||||
"files": ["libfff_c.so"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-linux-x64-musl"
|
||||
},
|
||||
"libc": ["musl"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-win32-arm64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Windows ARM64",
|
||||
"os": ["win32"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "fff_c.dll",
|
||||
"files": ["fff_c.dll"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-win32-arm64"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ff-labs/fff-bun-win32-x64",
|
||||
"version": "0.0.0",
|
||||
"description": "fff native binary for Windows x64",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"main": "fff_c.dll",
|
||||
"files": ["fff_c.dll"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff-bun-win32-x64"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,22 @@ High-performance fuzzy file finder for Bun, powered by Rust. Perfect for LLM age
|
||||
bun add @ff-labs/bun
|
||||
```
|
||||
|
||||
The native binary will be downloaded automatically during installation.
|
||||
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bun-darwin-arm64`, `@ff-labs/fff-bun-linux-x64-gnu`). No GitHub downloads are needed.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Architecture | Package |
|
||||
|----------|-------------|---------|
|
||||
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bun-darwin-arm64` |
|
||||
| macOS | x64 (Intel) | `@ff-labs/fff-bun-darwin-x64` |
|
||||
| Linux | x64 (glibc) | `@ff-labs/fff-bun-linux-x64-gnu` |
|
||||
| Linux | ARM64 (glibc) | `@ff-labs/fff-bun-linux-arm64-gnu` |
|
||||
| Linux | x64 (musl) | `@ff-labs/fff-bun-linux-x64-musl` |
|
||||
| Linux | ARM64 (musl) | `@ff-labs/fff-bun-linux-arm64-musl` |
|
||||
| Windows | x64 | `@ff-labs/fff-bun-win32-x64` |
|
||||
| Windows | ARM64 | `@ff-labs/fff-bun-win32-arm64` |
|
||||
|
||||
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -98,6 +113,48 @@ Track file access for frecency scoring.
|
||||
FileFinder.trackAccess("/path/to/file.ts");
|
||||
```
|
||||
|
||||
### `FileFinder.liveGrep(query, options?)`
|
||||
|
||||
Search file contents with SIMD-accelerated matching.
|
||||
|
||||
```typescript
|
||||
interface GrepOptions {
|
||||
maxFileSize?: number; // Max file size in bytes (default: 10MB)
|
||||
maxMatchesPerFile?: number; // Max matches per file (default: 200)
|
||||
smartCase?: boolean; // Case-insensitive if all lowercase (default: true)
|
||||
fileOffset?: number; // Pagination offset (default: 0)
|
||||
pageLimit?: number; // Max matches to return (default: 50)
|
||||
mode?: "plain" | "regex" | "fuzzy"; // Search mode (default: "plain")
|
||||
timeBudgetMs?: number; // Time limit in ms, 0 = unlimited (default: 0)
|
||||
}
|
||||
|
||||
// Plain text search
|
||||
const result = FileFinder.liveGrep("TODO", { pageLimit: 20 });
|
||||
if (result.ok) {
|
||||
for (const match of result.value.items) {
|
||||
console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Regex search
|
||||
const regexResult = FileFinder.liveGrep("fn\\s+\\w+", { mode: "regex" });
|
||||
|
||||
// Fuzzy search
|
||||
const fuzzyResult = FileFinder.liveGrep("imprt recat", { mode: "fuzzy" });
|
||||
|
||||
// Pagination
|
||||
const page1 = FileFinder.liveGrep("error");
|
||||
if (page1.ok && page1.value.nextCursor) {
|
||||
const page2 = FileFinder.liveGrep("error", {
|
||||
cursor: page1.value.nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
// With file constraints
|
||||
const tsOnly = FileFinder.liveGrep("*.ts useState");
|
||||
const srcOnly = FileFinder.liveGrep("src/ handleClick");
|
||||
```
|
||||
|
||||
### `FileFinder.trackQuery(query, selectedFile)`
|
||||
|
||||
Track query completion for smart suggestions.
|
||||
@@ -121,6 +178,7 @@ if (health.ok) {
|
||||
|
||||
### Other Methods
|
||||
|
||||
- `FileFinder.liveGrep(query, options?)` - Search file contents
|
||||
- `FileFinder.scanFiles()` - Trigger rescan
|
||||
- `FileFinder.isScanning()` - Check scan status
|
||||
- `FileFinder.getScanProgress()` - Get scan progress
|
||||
@@ -168,6 +226,33 @@ interface FileItem {
|
||||
}
|
||||
```
|
||||
|
||||
## Grep Result Types
|
||||
|
||||
```typescript
|
||||
interface GrepResult {
|
||||
items: GrepMatch[];
|
||||
totalMatched: number;
|
||||
totalFilesSearched: number;
|
||||
totalFiles: number;
|
||||
filteredFileCount: number;
|
||||
nextCursor: GrepCursor | null; // Pass to options.cursor for next page
|
||||
regexFallbackError?: string; // Set if regex was invalid
|
||||
}
|
||||
|
||||
interface GrepMatch {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
fileName: string;
|
||||
gitStatus: string;
|
||||
lineNumber: number; // 1-based
|
||||
col: number; // 0-based byte column
|
||||
byteOffset: number; // Absolute byte offset in file
|
||||
lineContent: string; // The matched line text
|
||||
matchRanges: [number, number][]; // Byte offsets for highlighting
|
||||
fuzzyScore?: number; // Only in fuzzy mode
|
||||
}
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
If prebuilt binaries aren't available for your platform:
|
||||
@@ -186,10 +271,10 @@ cargo build --release -p fff-c
|
||||
## CLI Tools
|
||||
|
||||
```bash
|
||||
# Download binary manually
|
||||
bunx fff download [version]
|
||||
# Download binary manually (fallback if npm package unavailable)
|
||||
bunx fff download [tag]
|
||||
|
||||
# Show platform info
|
||||
# Show platform info and binary location
|
||||
bunx fff info
|
||||
```
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Interactive live grep demo
|
||||
*
|
||||
* Usage:
|
||||
* bun examples/grep.ts [directory] [--mode=plain|regex|fuzzy]
|
||||
*
|
||||
* Indexes the specified directory (or cwd) and provides an interactive
|
||||
* content search prompt with match highlighting.
|
||||
*/
|
||||
|
||||
import { FileFinder } from "../src/index";
|
||||
import type { GrepMode } from "../src/types";
|
||||
import * as readline from "readline";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const BLUE = "\x1b[34m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const RED = "\x1b[31m";
|
||||
const BG_YELLOW = "\x1b[43m";
|
||||
const BLACK = "\x1b[30m";
|
||||
|
||||
function formatGitStatus(status: string): string {
|
||||
switch (status) {
|
||||
case "modified":
|
||||
return `${YELLOW}M${RESET}`;
|
||||
case "untracked":
|
||||
return `${GREEN}?${RESET}`;
|
||||
case "added":
|
||||
return `${GREEN}A${RESET}`;
|
||||
case "deleted":
|
||||
return `${RED}D${RESET}`;
|
||||
case "renamed":
|
||||
return `${BLUE}R${RESET}`;
|
||||
case "clear":
|
||||
case "current":
|
||||
return `${DIM} ${RESET}`;
|
||||
default:
|
||||
return `${DIM}${status.charAt(0)}${RESET}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight match ranges within line content using ANSI escape codes.
|
||||
* The match_ranges are byte offsets into line_content.
|
||||
*/
|
||||
function highlightLine(content: string, ranges: [number, number][]): string {
|
||||
if (ranges.length === 0) return content;
|
||||
|
||||
// Convert the string to a buffer to work with byte offsets
|
||||
const buf = Buffer.from(content, "utf-8");
|
||||
const parts: string[] = [];
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
// Clamp to valid range
|
||||
const s = Math.max(0, Math.min(start, buf.length));
|
||||
const e = Math.max(s, Math.min(end, buf.length));
|
||||
|
||||
if (s > lastEnd) {
|
||||
parts.push(buf.subarray(lastEnd, s).toString("utf-8"));
|
||||
}
|
||||
parts.push(
|
||||
`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`
|
||||
);
|
||||
lastEnd = e;
|
||||
}
|
||||
|
||||
if (lastEnd < buf.length) {
|
||||
parts.push(buf.subarray(lastEnd).toString("utf-8"));
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function parseArgs(): { directory: string; mode: GrepMode } {
|
||||
let directory = process.cwd();
|
||||
let mode: GrepMode = "plain";
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg.startsWith("--mode=")) {
|
||||
const m = arg.slice(7);
|
||||
if (m === "plain" || m === "regex" || m === "fuzzy") {
|
||||
mode = m;
|
||||
} else {
|
||||
console.error(`Unknown mode: ${m}. Use plain, regex, or fuzzy.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (!arg.startsWith("-")) {
|
||||
directory = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return { directory, mode };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { directory, mode } = parseArgs();
|
||||
|
||||
console.log(`${BOLD}${CYAN}fff - Live Grep Demo${RESET}`);
|
||||
console.log(`${DIM}Mode: ${mode}${RESET}\n`);
|
||||
|
||||
if (!FileFinder.isAvailable()) {
|
||||
console.error(`${RED}Error: Native library not found.${RESET}`);
|
||||
console.error("Build with: cargo build --release -p fff-c");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
|
||||
const initResult = FileFinder.init({
|
||||
basePath: directory,
|
||||
warmupMmapCache: true,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Wait for scan
|
||||
process.stdout.write(`${DIM}Scanning files...${RESET}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
process.stdout.write(
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = FileFinder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok
|
||||
? finalProgress.value.scannedFilesCount
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`
|
||||
);
|
||||
console.log(
|
||||
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`
|
||||
);
|
||||
|
||||
let currentMode = mode;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const prompt = () => {
|
||||
const modeLabel =
|
||||
currentMode === "plain" ? "txt" : currentMode === "regex" ? "re" : "fzy";
|
||||
|
||||
rl.question(`${CYAN}grep[${modeLabel}]>${RESET} `, (query) => {
|
||||
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
|
||||
console.log(`\n${DIM}Goodbye!${RESET}`);
|
||||
FileFinder.destroy();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle mode switching
|
||||
if (query.startsWith(":mode ")) {
|
||||
const newMode = query.slice(6).trim();
|
||||
if (
|
||||
newMode === "plain" ||
|
||||
newMode === "regex" ||
|
||||
newMode === "fuzzy"
|
||||
) {
|
||||
currentMode = newMode;
|
||||
console.log(`${DIM}Switched to ${currentMode} mode${RESET}\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`
|
||||
);
|
||||
}
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.trim() === "") {
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const searchStart = Date.now();
|
||||
const result = FileFinder.liveGrep(query, {
|
||||
mode: currentMode,
|
||||
pageLimit: 30,
|
||||
timeBudgetMs: 5000,
|
||||
});
|
||||
const searchTime = Date.now() - searchStart;
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(`${RED}Grep error: ${result.error}${RESET}\n`);
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
items,
|
||||
totalMatched,
|
||||
totalFilesSearched,
|
||||
totalFiles: indexedFiles,
|
||||
filteredFileCount,
|
||||
nextCursor,
|
||||
regexFallbackError,
|
||||
} = result.value;
|
||||
|
||||
console.log();
|
||||
|
||||
if (regexFallbackError) {
|
||||
console.log(
|
||||
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`
|
||||
);
|
||||
console.log();
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log(`${DIM}No matches found.${RESET}\n`);
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group matches by file for display
|
||||
let lastFile = "";
|
||||
for (const match of items) {
|
||||
if (match.relativePath !== lastFile) {
|
||||
lastFile = match.relativePath;
|
||||
const git = formatGitStatus(match.gitStatus);
|
||||
console.log(
|
||||
`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`
|
||||
);
|
||||
}
|
||||
|
||||
const lineNum = String(match.lineNumber).padStart(4);
|
||||
const highlighted = highlightLine(
|
||||
match.lineContent,
|
||||
match.matchRanges
|
||||
);
|
||||
|
||||
let suffix = "";
|
||||
if (match.fuzzyScore !== undefined) {
|
||||
suffix = ` ${DIM}(score: ${match.fuzzyScore})${RESET}`;
|
||||
}
|
||||
|
||||
console.log(`${DIM}${lineNum}:${RESET} ${highlighted}${suffix}`);
|
||||
}
|
||||
|
||||
if (nextCursor) {
|
||||
console.log(
|
||||
`\n${DIM}... more results available${RESET}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`${RED}Fatal error: ${err.message}${RESET}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -118,13 +118,11 @@ async function main() {
|
||||
// Show index info
|
||||
const health = FileFinder.healthCheck();
|
||||
if (health.ok) {
|
||||
console.log(`${DIM}─────────────────────────────────────────${RESET}`);
|
||||
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
|
||||
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
|
||||
if (health.value.git.repositoryFound) {
|
||||
console.log(`${DIM}Git root:${RESET} ${health.value.git.workdir}`);
|
||||
}
|
||||
console.log(`${DIM}─────────────────────────────────────────${RESET}\n`);
|
||||
}
|
||||
|
||||
// Interactive search loop
|
||||
@@ -172,7 +170,6 @@ async function main() {
|
||||
console.log(
|
||||
`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`
|
||||
);
|
||||
console.log(`${DIM}──────┼───────┼────────┼────────────┼${"─".repeat(40)}${RESET}`);
|
||||
|
||||
// Results
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "@ff-labs/bun",
|
||||
"name": "@ff-labs/fff-bun",
|
||||
"version": "0.1.37",
|
||||
"private": false,
|
||||
"nativeBinaryHash": "1537fc7",
|
||||
"description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -15,7 +14,8 @@
|
||||
},
|
||||
"bin": {
|
||||
"fff": "./scripts/cli.ts",
|
||||
"fff-demo": "./examples/search.ts"
|
||||
"fff-demo": "./examples/search.ts",
|
||||
"fff-grep": "./examples/grep.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
@@ -66,6 +66,16 @@
|
||||
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
|
||||
},
|
||||
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
|
||||
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-x64": "0.0.0",
|
||||
"@ff-labs/fff-bun-win32-arm64": "0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0"
|
||||
@@ -3,19 +3,16 @@
|
||||
* CLI tool for fff package management
|
||||
*
|
||||
* Usage:
|
||||
* bunx fff download [hash] - Download native binary
|
||||
* bunx fff download [tag] - Download native binary from GitHub
|
||||
* bunx fff info - Show platform and binary info
|
||||
* bunx fff check - Check for updates
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadBinary,
|
||||
getBinaryPath,
|
||||
findBinary,
|
||||
getInstalledHash,
|
||||
checkForUpdate
|
||||
} from "../src/download";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "../src/platform";
|
||||
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "../src/platform";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -24,7 +21,6 @@ const command = args[0];
|
||||
|
||||
interface PackageJson {
|
||||
version: string;
|
||||
nativeBinaryHash?: string;
|
||||
}
|
||||
|
||||
async function getPackageInfo(): Promise<PackageJson> {
|
||||
@@ -41,11 +37,11 @@ async function getPackageInfo(): Promise<PackageJson> {
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case "download": {
|
||||
const hash = args[1];
|
||||
console.log("fff: Downloading native library...");
|
||||
const tag = args[1];
|
||||
console.log("fff: Downloading native library from GitHub...");
|
||||
try {
|
||||
const resolvedHash = await downloadBinary(hash);
|
||||
console.log(`fff: Download complete! (${resolvedHash})`);
|
||||
const resolvedTag = await downloadBinary(tag);
|
||||
console.log(`fff: Download complete! (${resolvedTag})`);
|
||||
} catch (error) {
|
||||
console.error("fff: Download failed:", error);
|
||||
process.exit(1);
|
||||
@@ -53,38 +49,23 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "check": {
|
||||
console.log("fff: Checking for updates...");
|
||||
try {
|
||||
const { currentHash, latestHash, updateAvailable } = await checkForUpdate();
|
||||
console.log(` Installed: ${currentHash || "not installed"}`);
|
||||
console.log(` Latest: ${latestHash}`);
|
||||
if (updateAvailable) {
|
||||
console.log("");
|
||||
console.log(" Update available! Run: bunx fff download");
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(" You're up to date!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("fff: Failed to check for updates:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "info": {
|
||||
const pkg = await getPackageInfo();
|
||||
const installedHash = await getInstalledHash();
|
||||
let npmPackage: string;
|
||||
try {
|
||||
npmPackage = getNpmPackageName();
|
||||
} catch {
|
||||
npmPackage = "unsupported";
|
||||
}
|
||||
|
||||
console.log("fff - Fast File Finder");
|
||||
console.log(`Package version: ${pkg.version}`);
|
||||
console.log(`Binary hash: ${installedHash || "not installed"}`);
|
||||
console.log("");
|
||||
console.log("Platform Information:");
|
||||
console.log(` Triple: ${getTriple()}`);
|
||||
console.log(` Extension: ${getLibExtension()}`);
|
||||
console.log(` Library name: ${getLibFilename()}`);
|
||||
console.log(` npm package: ${npmPackage}`);
|
||||
console.log("");
|
||||
console.log("Binary Status:");
|
||||
const existing = findBinary();
|
||||
@@ -93,6 +74,7 @@ async function main() {
|
||||
} else {
|
||||
console.log(` Not found`);
|
||||
console.log(` Expected path: ${getBinaryPath()}`);
|
||||
console.log(` Try: bun add ${npmPackage}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -113,16 +95,17 @@ async function main() {
|
||||
console.log(`fff - Fast File Finder CLI v${pkg.version}`);
|
||||
console.log("");
|
||||
console.log("Usage:");
|
||||
console.log(" bunx fff download [hash] Download native binary");
|
||||
console.log(" bunx fff check Check for updates");
|
||||
console.log(" bunx fff download [tag] Download native binary from GitHub (fallback)");
|
||||
console.log(" bunx fff info Show platform and binary info");
|
||||
console.log(" bunx fff version Show version");
|
||||
console.log(" bunx fff help Show this help message");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" bunx fff download Download binary for configured hash");
|
||||
console.log(" bunx fff download latest Download latest release");
|
||||
console.log(" bunx fff download abc1234 Download specific commit hash");
|
||||
console.log(" bunx fff download Download latest binary from GitHub");
|
||||
console.log(" bunx fff download abc1234 Download specific release tag");
|
||||
console.log("");
|
||||
console.log("Note: Binaries are normally provided via platform-specific npm packages.");
|
||||
console.log("The download command is a fallback for when those aren't available.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Postinstall script - ensures the native binary is available
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Platform-specific npm package (installed via optionalDependencies)
|
||||
* 2. Local dev build (target/release or target/debug)
|
||||
* 3. Fallback: download from GitHub releases
|
||||
*/
|
||||
|
||||
import { findBinary, downloadBinary } from "../src/download";
|
||||
import { getNpmPackageName } from "../src/platform";
|
||||
|
||||
async function main() {
|
||||
// Check if binary is already available (npm package or dev build)
|
||||
const existing = findBinary();
|
||||
if (existing) {
|
||||
console.log(`fff: Native library found at ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary not found via npm package - try downloading from GitHub as fallback
|
||||
let packageName: string;
|
||||
try {
|
||||
packageName = getNpmPackageName();
|
||||
} catch {
|
||||
packageName = "unknown";
|
||||
}
|
||||
|
||||
console.log(
|
||||
`fff: Platform package ${packageName} not found, falling back to GitHub download...`
|
||||
);
|
||||
|
||||
try {
|
||||
const tag = await downloadBinary();
|
||||
console.log(`fff: Native library installed successfully! (${tag})`);
|
||||
} catch (error) {
|
||||
console.error("fff: Failed to download native library:", error);
|
||||
console.error("");
|
||||
console.error("fff: You can build from source instead:");
|
||||
console.error(" cargo build --release -p fff-c");
|
||||
console.error("");
|
||||
console.error(
|
||||
"fff: Or run `bunx fff download` after fixing network issues."
|
||||
);
|
||||
// Don't exit with error - allow install to complete
|
||||
// The error will surface when the user tries to use the library
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Binary resolution utilities for fff
|
||||
*
|
||||
* Resolves the native binary from:
|
||||
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bun-darwin-arm64) - primary
|
||||
* 2. Local bin/ directory (legacy or manual download)
|
||||
* 3. Local dev build (target/release or target/debug)
|
||||
* 4. GitHub releases (fallback, requires network)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
|
||||
|
||||
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
|
||||
/**
|
||||
* Get the current file's directory
|
||||
*/
|
||||
function getCurrentDir(): string {
|
||||
const url = import.meta.url;
|
||||
if (url.startsWith("file://")) {
|
||||
return dirname(fileURLToPath(url));
|
||||
}
|
||||
return dirname(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package root directory
|
||||
*/
|
||||
function getPackageDir(): string {
|
||||
const currentDir = getCurrentDir();
|
||||
return dirname(currentDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory where binaries are stored (legacy/fallback)
|
||||
*/
|
||||
export function getBinDir(): string {
|
||||
return join(getPackageDir(), "bin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the native library in bin/ (legacy/fallback)
|
||||
*/
|
||||
export function getBinaryPath(): string {
|
||||
const binDir = getBinDir();
|
||||
return join(binDir, getLibFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the binary exists in any known location
|
||||
*/
|
||||
export function binaryExists(): boolean {
|
||||
return findBinary() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve the binary from the platform-specific npm package.
|
||||
*
|
||||
* When users install @ff-labs/bun, npm/bun automatically installs the matching
|
||||
* optionalDependency (e.g. @ff-labs/fff-bun-darwin-arm64). We resolve the binary
|
||||
* path by requiring that package's package.json and looking for the binary
|
||||
* in the same directory.
|
||||
*/
|
||||
function resolveFromNpmPackage(): string | null {
|
||||
const packageName = getNpmPackageName();
|
||||
|
||||
try {
|
||||
// Use createRequire to resolve the platform package's location
|
||||
const require = createRequire(join(getPackageDir(), "package.json"));
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
||||
const packageDir = dirname(packageJsonPath);
|
||||
const binaryPath = join(packageDir, getLibFilename());
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath;
|
||||
}
|
||||
} catch {
|
||||
// Package not installed - this is expected on unsupported platforms
|
||||
// or when installed without optional dependencies
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the development binary path (for local development)
|
||||
*/
|
||||
export function getDevBinaryPath(): string | null {
|
||||
const packageDir = getPackageDir();
|
||||
const workspaceRoot = join(packageDir, "..", "..");
|
||||
|
||||
const possiblePaths = [
|
||||
join(workspaceRoot, "target", "release", getLibFilename()),
|
||||
join(workspaceRoot, "target", "debug", getLibFilename()),
|
||||
];
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the binary, checking all known locations in priority order:
|
||||
* 1. Platform-specific npm package (primary distribution method)
|
||||
* 2. Local bin/ directory (legacy postinstall download)
|
||||
* 3. Local dev build (cargo build output)
|
||||
*/
|
||||
export function findBinary(): string | null {
|
||||
// 1. Try platform-specific npm package first
|
||||
const npmPath = resolveFromNpmPackage();
|
||||
if (npmPath) return npmPath;
|
||||
|
||||
// 2. Try local bin/ directory (legacy or manual download)
|
||||
const installedPath = getBinaryPath();
|
||||
if (existsSync(installedPath)) return installedPath;
|
||||
|
||||
// 3. Try local dev build
|
||||
return getDevBinaryPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the binary from GitHub releases as a fallback.
|
||||
* This is only used when the platform npm package is not available.
|
||||
*
|
||||
* @param tag - The release tag to download (e.g. commit hash), or "latest"
|
||||
*/
|
||||
export async function downloadBinary(tag?: string): Promise<string> {
|
||||
const resolvedTag = tag || "latest";
|
||||
const triple = getTriple();
|
||||
const ext = getLibExtension();
|
||||
|
||||
// Resolve "latest" tag via GitHub API
|
||||
let releaseTag = resolvedTag;
|
||||
if (releaseTag === "latest") {
|
||||
console.log("fff: Fetching latest release tag from GitHub...");
|
||||
releaseTag = await fetchLatestReleaseTag();
|
||||
}
|
||||
|
||||
const binaryName = `c-lib-${triple}.${ext}`;
|
||||
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${releaseTag}`;
|
||||
const binaryUrl = `${baseUrl}/${binaryName}`;
|
||||
|
||||
console.log(`fff: Downloading native library for ${triple}...`);
|
||||
console.log(`fff: Release: ${releaseTag}`);
|
||||
|
||||
const binaryResponse = await fetch(binaryUrl);
|
||||
if (!binaryResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}\nURL: ${binaryUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const binaryBuffer = Buffer.from(await binaryResponse.arrayBuffer());
|
||||
|
||||
const binDir = getBinDir();
|
||||
if (!existsSync(binDir)) {
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
writeFileSync(binaryPath, binaryBuffer);
|
||||
|
||||
// Make executable on Unix
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
console.log(`fff: Binary downloaded to ${binaryPath}`);
|
||||
return releaseTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release tag from GitHub
|
||||
*/
|
||||
async function fetchLatestReleaseTag(): Promise<string> {
|
||||
const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const allReleasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases`;
|
||||
const allResponse = await fetch(allReleasesUrl, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!allResponse.ok) {
|
||||
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
|
||||
}
|
||||
|
||||
const releases = await allResponse.json() as Array<{ tag_name: string }>;
|
||||
if (releases.length === 0) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
|
||||
return releases[0].tag_name;
|
||||
}
|
||||
|
||||
const release = await response.json() as { tag_name: string };
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the binary exists, downloading from GitHub if necessary.
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
const existingPath = findBinary();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
// Fallback: download from GitHub
|
||||
await downloadBinary();
|
||||
return getBinaryPath();
|
||||
}
|
||||
@@ -28,6 +28,12 @@ const ffiDefinition = {
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Live grep (content search)
|
||||
fff_live_grep: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// File index
|
||||
fff_scan_files: {
|
||||
args: [],
|
||||
@@ -335,6 +341,18 @@ export function ffiHealthCheck(testPath: string): Result<unknown> {
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live grep - search file contents
|
||||
*/
|
||||
export function ffiLiveGrep(query: string, optsJson: string): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_live_grep(
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the library is loaded (for preloading)
|
||||
*/
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ffiInit,
|
||||
ffiDestroy,
|
||||
ffiSearch,
|
||||
ffiLiveGrep,
|
||||
ffiScanFiles,
|
||||
ffiIsScanning,
|
||||
ffiGetScanProgress,
|
||||
@@ -30,9 +31,11 @@ import type {
|
||||
SearchResult,
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
GrepOptions,
|
||||
GrepResult,
|
||||
} from "./types";
|
||||
|
||||
import { err, toInternalInitOptions, toInternalSearchOptions } from "./types";
|
||||
import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOptions, createGrepCursor } from "./types";
|
||||
|
||||
/**
|
||||
* FileFinder - Fast file finder with fuzzy search
|
||||
@@ -153,6 +156,71 @@ export class FileFinder {
|
||||
return result as Result<SearchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search file contents (live grep).
|
||||
*
|
||||
* Searches through the contents of indexed files using the specified mode:
|
||||
* - `"plain"` (default): SIMD-accelerated literal text matching
|
||||
* - `"regex"`: Regular expression matching
|
||||
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
|
||||
*
|
||||
* Supports pagination for large result sets. The result includes a `nextCursor`
|
||||
* that can be passed back to fetch the next page.
|
||||
*
|
||||
* The query also supports constraint syntax:
|
||||
* - `*.ts pattern` - Only search in TypeScript files
|
||||
* - `src/ pattern` - Only search in the src directory
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param options - Grep options (mode, pagination, limits)
|
||||
* @returns Grep results with matched lines and file metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // First page
|
||||
* const result = FileFinder.liveGrep("TODO", { mode: "plain", pageLimit: 20 });
|
||||
* if (result.ok) {
|
||||
* for (const match of result.value.items) {
|
||||
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
||||
* }
|
||||
* // Fetch next page
|
||||
* if (result.value.nextCursor) {
|
||||
* const page2 = FileFinder.liveGrep("TODO", {
|
||||
* cursor: result.value.nextCursor,
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
|
||||
const internalOpts = toInternalGrepOptions(options);
|
||||
const result = ffiLiveGrep(query, JSON.stringify(internalOpts));
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Transform the raw FFI result: replace nextFileOffset with an opaque cursor
|
||||
const raw = result.value as Record<string, unknown>;
|
||||
const nextFileOffset = raw.nextFileOffset as number;
|
||||
|
||||
const grepResult: GrepResult = {
|
||||
items: raw.items as GrepResult["items"],
|
||||
totalMatched: raw.totalMatched as number,
|
||||
totalFilesSearched: raw.totalFilesSearched as number,
|
||||
totalFiles: raw.totalFiles as number,
|
||||
filteredFileCount: raw.filteredFileCount as number,
|
||||
nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
|
||||
regexFallbackError: raw.regexFallbackError as string | undefined,
|
||||
};
|
||||
|
||||
return { ok: true, value: grepResult };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a rescan of the indexed directory.
|
||||
*
|
||||
@@ -51,6 +51,11 @@ export type {
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
DbHealth,
|
||||
GrepMode,
|
||||
GrepOptions,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
GrepCursor,
|
||||
} from "./types";
|
||||
|
||||
// Result helpers
|
||||
@@ -66,4 +71,4 @@ export {
|
||||
} from "./download";
|
||||
|
||||
// Platform utilities
|
||||
export { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
export { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
|
||||
@@ -90,3 +90,32 @@ export function getLibFilename(): string {
|
||||
const ext = getLibExtension();
|
||||
return `${prefix}fff_c.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from Rust target triple to npm platform package name
|
||||
*/
|
||||
const TRIPLE_TO_NPM_PACKAGE: Record<string, string> = {
|
||||
"aarch64-apple-darwin": "@ff-labs/fff-bun-darwin-arm64",
|
||||
"x86_64-apple-darwin": "@ff-labs/fff-bun-darwin-x64",
|
||||
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bun-linux-x64-gnu",
|
||||
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bun-linux-arm64-gnu",
|
||||
"x86_64-unknown-linux-musl": "@ff-labs/fff-bun-linux-x64-musl",
|
||||
"aarch64-unknown-linux-musl": "@ff-labs/fff-bun-linux-arm64-musl",
|
||||
"x86_64-pc-windows-msvc": "@ff-labs/fff-bun-win32-x64",
|
||||
"aarch64-pc-windows-msvc": "@ff-labs/fff-bun-win32-arm64",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the npm package name for the current platform's native binary.
|
||||
*
|
||||
* @returns Package name like "@ff-labs/fff-bun-darwin-arm64"
|
||||
* @throws If the current platform is not supported
|
||||
*/
|
||||
export function getNpmPackageName(): string {
|
||||
const triple = getTriple();
|
||||
const packageName = TRIPLE_TO_NPM_PACKAGE[triple];
|
||||
if (!packageName) {
|
||||
throw new Error(`No npm package available for platform: ${triple}`);
|
||||
}
|
||||
return packageName;
|
||||
}
|
||||
@@ -31,6 +31,13 @@ export interface InitOptions {
|
||||
historyDbPath?: string;
|
||||
/** Use unsafe no-lock mode for databases (optional, defaults to false) */
|
||||
useUnsafeNoLock?: boolean;
|
||||
/**
|
||||
* Pre-populate mmap caches for all files after the initial scan completes.
|
||||
* When enabled, the first grep search will be as fast as subsequent ones
|
||||
* at the cost of a longer scan time and higher initial memory usage.
|
||||
* (default: false)
|
||||
*/
|
||||
warmupMmapCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +217,7 @@ export interface InitOptionsInternal {
|
||||
frecency_db_path?: string;
|
||||
history_db_path?: string;
|
||||
use_unsafe_no_lock: boolean;
|
||||
warmup_mmap_cache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +243,7 @@ export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
|
||||
frecency_db_path: opts.frecencyDbPath,
|
||||
history_db_path: opts.historyDbPath,
|
||||
use_unsafe_no_lock: opts.useUnsafeNoLock ?? false,
|
||||
warmup_mmap_cache: opts.warmupMmapCache ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,3 +263,168 @@ export function toInternalSearchOptions(
|
||||
page_size: opts?.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live content search) types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Grep search mode
|
||||
*/
|
||||
export type GrepMode = "plain" | "regex" | "fuzzy";
|
||||
|
||||
/**
|
||||
* Opaque pagination cursor for grep results.
|
||||
* Pass this to `GrepOptions.cursor` to fetch the next page.
|
||||
* Do not construct or modify this — use the `nextCursor` from a previous `GrepResult`.
|
||||
*/
|
||||
export interface GrepCursor {
|
||||
/** @internal */
|
||||
readonly __brand: "GrepCursor";
|
||||
/** @internal */
|
||||
readonly _offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Create a GrepCursor from a raw file offset.
|
||||
*/
|
||||
export function createGrepCursor(offset: number): GrepCursor {
|
||||
return { __brand: "GrepCursor" as const, _offset: offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for live grep (content search)
|
||||
*
|
||||
* Files are searched sequentially in frecency order (most recently/frequently
|
||||
* accessed first). The engine collects matching lines across files until
|
||||
* `pageLimit` total matches are reached, then stops and returns a
|
||||
* `nextCursor` for fetching the next page.
|
||||
*/
|
||||
export interface GrepOptions {
|
||||
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Maximum matching lines to collect from a single file (default: 200) */
|
||||
maxMatchesPerFile?: number;
|
||||
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
|
||||
smartCase?: boolean;
|
||||
/**
|
||||
* Pagination cursor from a previous `GrepResult.nextCursor`.
|
||||
* Omit (or pass `null`) for the first page.
|
||||
*/
|
||||
cursor?: GrepCursor | null;
|
||||
/**
|
||||
* Maximum total number of matching lines to return across all files.
|
||||
* The engine walks files in frecency order, accumulating matches until this
|
||||
* limit is reached, then truncates and stops.
|
||||
*
|
||||
* Pagination is file-based, not match-based: if a single file produces more
|
||||
* matches than the remaining capacity, the excess matches from that file are
|
||||
* dropped and the next page resumes from the *next* file. This means some
|
||||
* matches at the boundary may be skipped, but it guarantees no duplicates
|
||||
* across pages and requires no server-side cursor state.
|
||||
*
|
||||
* Use `nextCursor` from the result to fetch the next page. (default: 50)
|
||||
*/
|
||||
pageLimit?: number;
|
||||
/** Search mode (default: "plain") */
|
||||
mode?: GrepMode;
|
||||
/**
|
||||
* Maximum wall-clock time in milliseconds to spend searching before returning
|
||||
* partial results. The engine will still return at least `pageLimit / 2` matches
|
||||
* (if available) before honoring the budget. 0 = unlimited. (default: 0)
|
||||
*/
|
||||
timeBudgetMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single grep match with file and line information
|
||||
*/
|
||||
export interface GrepMatch {
|
||||
/** Absolute path to the file */
|
||||
path: string;
|
||||
/** Path relative to the indexed directory */
|
||||
relativePath: string;
|
||||
/** File name only */
|
||||
fileName: string;
|
||||
/** Git status */
|
||||
gitStatus: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp (Unix seconds) */
|
||||
modified: number;
|
||||
/** Whether the file is binary */
|
||||
isBinary: boolean;
|
||||
/** Combined frecency score */
|
||||
totalFrecencyScore: number;
|
||||
/** Access-based frecency score */
|
||||
accessFrecencyScore: number;
|
||||
/** Modification-based frecency score */
|
||||
modificationFrecencyScore: number;
|
||||
/** 1-based line number of the match */
|
||||
lineNumber: number;
|
||||
/** 0-based byte column of first match start */
|
||||
col: number;
|
||||
/** Absolute byte offset of the matched line from file start */
|
||||
byteOffset: number;
|
||||
/** The matched line text (may be truncated) */
|
||||
lineContent: string;
|
||||
/** Byte offset pairs [start, end] within lineContent for highlighting */
|
||||
matchRanges: [number, number][];
|
||||
/** Fuzzy match score (only in fuzzy mode) */
|
||||
fuzzyScore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a grep search
|
||||
*/
|
||||
export interface GrepResult {
|
||||
/** Matched items with file and line information. At most `pageLimit` entries. */
|
||||
items: GrepMatch[];
|
||||
/** Total number of matches collected (equal to items.length unless truncated by pageLimit) */
|
||||
totalMatched: number;
|
||||
/** Number of files actually opened and searched in this call */
|
||||
totalFilesSearched: number;
|
||||
/** Total number of indexed files (before any filtering) */
|
||||
totalFiles: number;
|
||||
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
|
||||
filteredFileCount: number;
|
||||
/**
|
||||
* Cursor for the next page, or `null` if all eligible files have been searched.
|
||||
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
|
||||
*/
|
||||
nextCursor: GrepCursor | null;
|
||||
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
|
||||
regexFallbackError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Grep options format sent to Rust FFI
|
||||
* @internal
|
||||
*/
|
||||
export interface GrepOptionsInternal {
|
||||
max_file_size?: number;
|
||||
max_matches_per_file?: number;
|
||||
smart_case?: boolean;
|
||||
file_offset?: number;
|
||||
page_limit?: number;
|
||||
mode?: string;
|
||||
time_budget_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert public GrepOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalGrepOptions(
|
||||
opts?: GrepOptions
|
||||
): GrepOptionsInternal {
|
||||
return {
|
||||
max_file_size: opts?.maxFileSize,
|
||||
max_matches_per_file: opts?.maxMatchesPerFile,
|
||||
smart_case: opts?.smartCase,
|
||||
file_offset: opts?.cursor?._offset ?? 0,
|
||||
page_limit: opts?.pageLimit,
|
||||
mode: opts?.mode,
|
||||
time_budget_ms: opts?.timeBudgetMs,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Test script for fff package
|
||||
*
|
||||
* Run with: bun packages/fff/test.ts
|
||||
*/
|
||||
|
||||
import { FileFinder } from "./src/index";
|
||||
import { resolve, dirname } from "path";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Postinstall script - automatically downloads the native binary
|
||||
*/
|
||||
|
||||
import { downloadBinary, findBinary, getInstalledHash } from "../src/download";
|
||||
|
||||
async function main() {
|
||||
// Check if binary already exists (dev build or previous download)
|
||||
const existing = findBinary();
|
||||
if (existing) {
|
||||
const hash = await getInstalledHash();
|
||||
console.log(`fff: Native library found at ${existing}`);
|
||||
if (hash) {
|
||||
console.log(`fff: Version: ${hash}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("fff: Native library not found, downloading...");
|
||||
|
||||
try {
|
||||
const hash = await downloadBinary();
|
||||
console.log(`fff: Native library installed successfully! (${hash})`);
|
||||
} catch (error) {
|
||||
console.error("fff: Failed to download native library:", error);
|
||||
console.error("");
|
||||
console.error("fff: You can build from source instead:");
|
||||
console.error(" cd node_modules/fff && cargo build --release -p fff-c");
|
||||
console.error("");
|
||||
console.error("fff: Or run `bunx fff download` after fixing network issues.");
|
||||
// Don't exit with error - allow install to complete
|
||||
// The error will surface when the user tries to use the library
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* Binary download utilities for fff
|
||||
*
|
||||
* Downloads prebuilt binaries from GitHub releases based on commit hash.
|
||||
* The release tag corresponds to the short commit SHA (7 characters).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
|
||||
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
|
||||
/**
|
||||
* Get the current file's directory
|
||||
*/
|
||||
function getCurrentDir(): string {
|
||||
const url = import.meta.url;
|
||||
if (url.startsWith("file://")) {
|
||||
return dirname(fileURLToPath(url));
|
||||
}
|
||||
return dirname(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package root directory
|
||||
*/
|
||||
function getPackageDir(): string {
|
||||
const currentDir = getCurrentDir();
|
||||
return dirname(currentDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to package.json
|
||||
*/
|
||||
function getPackageJsonPath(): string {
|
||||
return join(getPackageDir(), "package.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory where binaries are stored
|
||||
*/
|
||||
export function getBinDir(): string {
|
||||
return join(getPackageDir(), "bin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the native library
|
||||
*/
|
||||
export function getBinaryPath(): string {
|
||||
const binDir = getBinDir();
|
||||
return join(binDir, getLibFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the binary exists
|
||||
*/
|
||||
export function binaryExists(): boolean {
|
||||
return existsSync(getBinaryPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package.json
|
||||
*/
|
||||
async function readPackageJson(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await Bun.file(getPackageJsonPath()).json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json
|
||||
*/
|
||||
async function writePackageJson(pkg: Record<string, unknown>): Promise<void> {
|
||||
const content = JSON.stringify(pkg, null, 2) + "\n";
|
||||
writeFileSync(getPackageJsonPath(), content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installed binary hash from package.json
|
||||
*/
|
||||
export async function getInstalledHash(): Promise<string | null> {
|
||||
const pkg = await readPackageJson();
|
||||
return (pkg.nativeBinaryHash as string) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the installed hash in package.json
|
||||
*/
|
||||
async function setInstalledHash(hash: string): Promise<void> {
|
||||
const pkg = await readPackageJson();
|
||||
pkg.nativeBinaryHash = hash;
|
||||
await writePackageJson(pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the development binary path (for local development)
|
||||
*/
|
||||
export function getDevBinaryPath(): string | null {
|
||||
const packageDir = getPackageDir();
|
||||
const workspaceRoot = join(packageDir, "..", "..");
|
||||
|
||||
const possiblePaths = [
|
||||
join(workspaceRoot, "target", "release", getLibFilename()),
|
||||
join(workspaceRoot, "target", "debug", getLibFilename()),
|
||||
];
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the binary, checking both installed and dev paths
|
||||
*/
|
||||
export function findBinary(): string | null {
|
||||
const installedPath = getBinaryPath();
|
||||
if (existsSync(installedPath)) {
|
||||
return installedPath;
|
||||
}
|
||||
return getDevBinaryPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release tag from GitHub
|
||||
*/
|
||||
async function fetchLatestReleaseTag(): Promise<string> {
|
||||
const url = `${GITHUB_API}/repos/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If no "latest" release, try getting the most recent prerelease
|
||||
const allReleasesUrl = `${GITHUB_API}/repos/${GITHUB_REPO}/releases`;
|
||||
const allResponse = await fetch(allReleasesUrl, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!allResponse.ok) {
|
||||
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
|
||||
}
|
||||
|
||||
const releases = await allResponse.json() as Array<{ tag_name: string }>;
|
||||
if (releases.length === 0) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
|
||||
return releases[0].tag_name;
|
||||
}
|
||||
|
||||
const release = await response.json() as { tag_name: string };
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hash to use for downloading
|
||||
* If "latest", fetches the latest release tag from GitHub
|
||||
*/
|
||||
async function resolveHash(hash: string): Promise<string> {
|
||||
if (hash === "latest") {
|
||||
console.log("fff: Fetching latest release tag...");
|
||||
return await fetchLatestReleaseTag();
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and verify checksum for a binary
|
||||
*/
|
||||
async function downloadWithChecksum(
|
||||
binaryUrl: string,
|
||||
checksumUrl: string,
|
||||
): Promise<Buffer> {
|
||||
// Download binary
|
||||
const binaryResponse = await fetch(binaryUrl);
|
||||
if (!binaryResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}\nURL: ${binaryUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const binaryBuffer = Buffer.from(await binaryResponse.arrayBuffer());
|
||||
|
||||
// Try to download and verify checksum
|
||||
try {
|
||||
const checksumResponse = await fetch(checksumUrl);
|
||||
if (checksumResponse.ok) {
|
||||
const checksumText = await checksumResponse.text();
|
||||
// Format: "hash filename" or just "hash"
|
||||
const expectedHash = checksumText.trim().split(/\s+/)[0];
|
||||
|
||||
const actualHash = createHash("sha256").update(binaryBuffer).digest("hex");
|
||||
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum mismatch!\nExpected: ${expectedHash}\nActual: ${actualHash}`,
|
||||
);
|
||||
}
|
||||
console.log("fff: Checksum verified ✓");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Checksum mismatch")) {
|
||||
throw error;
|
||||
}
|
||||
// Checksum file not found, continue without verification
|
||||
console.log("fff: Checksum file not available, skipping verification");
|
||||
}
|
||||
|
||||
return binaryBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the binary from GitHub releases
|
||||
* @param hash - The commit hash (release tag) to download, or "latest"
|
||||
*/
|
||||
export async function downloadBinary(hash?: string): Promise<string> {
|
||||
const currentHash = await getInstalledHash();
|
||||
const packageHash = hash || currentHash || "latest";
|
||||
const resolvedHash = await resolveHash(packageHash);
|
||||
|
||||
const triple = getTriple();
|
||||
const ext = getLibExtension();
|
||||
|
||||
// Binary name format: c-lib-{triple}.{ext}
|
||||
const binaryName = `c-lib-${triple}.${ext}`;
|
||||
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${resolvedHash}`;
|
||||
const binaryUrl = `${baseUrl}/${binaryName}`;
|
||||
const checksumUrl = `${baseUrl}/${binaryName}.sha256`;
|
||||
|
||||
console.log(`fff: Downloading native library for ${triple}...`);
|
||||
console.log(`fff: Release: ${resolvedHash}`);
|
||||
console.log(`fff: URL: ${binaryUrl}`);
|
||||
|
||||
const binaryBuffer = await downloadWithChecksum(binaryUrl, checksumUrl);
|
||||
|
||||
const binDir = getBinDir();
|
||||
if (!existsSync(binDir)) {
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
writeFileSync(binaryPath, binaryBuffer);
|
||||
|
||||
// Save the hash to package.json
|
||||
await setInstalledHash(resolvedHash);
|
||||
|
||||
// Make executable on Unix
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
console.log(`fff: Binary downloaded to ${binaryPath}`);
|
||||
return resolvedHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
*/
|
||||
export async function checkForUpdate(): Promise<{
|
||||
currentHash: string | null;
|
||||
latestHash: string;
|
||||
updateAvailable: boolean;
|
||||
}> {
|
||||
const currentHash = await getInstalledHash();
|
||||
const latestHash = await fetchLatestReleaseTag();
|
||||
|
||||
return {
|
||||
currentHash,
|
||||
latestHash,
|
||||
updateAvailable: currentHash !== latestHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the binary exists, downloading if necessary
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
const existingPath = findBinary();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
await downloadBinary();
|
||||
return getBinaryPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download binary, with fallback to cargo build instructions
|
||||
*/
|
||||
export async function downloadOrBuild(): Promise<void> {
|
||||
try {
|
||||
await downloadBinary();
|
||||
} catch (error) {
|
||||
console.error(`fff: Failed to download binary: ${error}`);
|
||||
console.error(`fff: You can build from source instead:`);
|
||||
console.error(` cargo build --release -p fff-c`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user