Compare commits

...

6 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 4b83987ea1 feat: Colorized hex dump for binary file previews (#291)
* feat: Colorized hex dump for binary file previews

* chore: Update docs for - feat: Colorized hex dump for binary file previews
2026-03-17 12:27:48 -07:00
Dmitriy Kovalenko c6cb66b597 fix: Windows dir locking (#293)
* fix: Windows dir locking

* chore: Update docs for - fix: Windows dir locking
2026-03-17 10:40:09 -07:00
George 64861f8142 chore(readme) Fix install-fff.sh link to install-mcp.sh (#283)
* install-fff.sh -> install.mcp.sh

* install-fff.sh -> install.mcp.sh
2026-03-15 08:00:18 -07:00
Mohamed Ibraheem eb5f2b3648 refactor: move change_indexing_directory to picker_ui module (#286)
* refactor: move change_indexing_directory to picker_ui module

closes #237

* chore: format

* chore: format
2026-03-15 07:59:38 -07:00
Dmitriy Kovalenko fcdf4a9172 feat(mcp): Make install script understand upgrade use case 2026-03-13 16:45:21 -07:00
Dmitriy Kovalenko 1001eb8b5e feat(mcp): Add file path fallback for grep (#281)
* feat(mcp): Add file path fallback for grep

* chore: Update docs for - feat(mcp): Add file path fallback for grep
2026-03-13 16:29:24 -07:00
44 changed files with 1285 additions and 704 deletions
+8 -5
View File
@@ -9,6 +9,9 @@ on:
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13"
# Force Node 24 for all JS-based actions to avoid the libuv
# process_title assertion crash on Windows (known Node 20 bug).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lua-tests:
@@ -20,12 +23,12 @@ jobs:
fail-fast: false
matrix:
include:
# - os: ubuntu-latest TODO uncomment once bun stop crashing
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install Zig
@@ -34,11 +37,11 @@ jobs:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
cache-key: "v1-lua-e2e"
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Neovim
run: |
@@ -38,7 +38,7 @@ jobs:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install luacheck
run: |
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# fetch last 2 commits required for auto force push back
fetch-depth: 2
+8 -5
View File
@@ -5,6 +5,9 @@ on:
branches: [main, feat/mcp-ai]
pull_request:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-nvim:
name: Build Neovim ${{ matrix.target }}
@@ -62,7 +65,7 @@ jobs:
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -193,7 +196,7 @@ jobs:
ext: dll
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -306,7 +309,7 @@ jobs:
artifact_name: target/aarch64-pc-windows-msvc/release/fff-mcp.exe
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -360,7 +363,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -469,7 +472,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
@@ -29,7 +29,7 @@ jobs:
version: 0.15.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.15.4
with:
cache: true
cache-on-failure: true
@@ -45,7 +45,7 @@ jobs:
name: cargo fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
@@ -58,7 +58,7 @@ jobs:
name: cargo clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
name: Check lua files using Stylua
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
Generated
+2 -2
View File
@@ -81,7 +81,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -92,7 +92,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+5 -1
View File
@@ -41,7 +41,11 @@ parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
smallvec = { version = "1.13", features = [
"const_generics",
"union",
"may_dangle",
] }
thiserror = "2.0.10"
tracing = "0.1"
+1 -1
View File
@@ -36,7 +36,7 @@ You can install FFF as a dependency for your AI agent using a simple bash script
curl -L https://dmtrkovalenko.dev/install-fff-mcp.sh | bash
```
> The installation script is here [./install-fff.sh](./install-fff.sh) if you want to review it before running.
> The installation script is here [./install-mcp.sh](./install-mcp.sh) if you want to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`, `Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to "use fff".
Here is an example addition to `CLAUDE.md` that works perfectly:
+3 -5
View File
@@ -261,8 +261,7 @@ pub unsafe extern "C" fn fff_search(
let results = FilePicker::fuzzy_search(
picker.get_files(),
query_str,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: opts.max_threads.unwrap_or(0),
current_file: opts.current_file.as_deref(),
@@ -349,8 +348,7 @@ pub unsafe extern "C" fn fff_live_grep(
classify_definitions: opts.classify_definitions.unwrap_or(false),
};
let result =
fff_core::grep::grep_search(picker.get_files(), query_str, parsed.as_ref(), &options);
let result = fff_core::grep::grep_search(picker.get_files(), &parsed, &options);
let json_result = ffi_types::GrepResultJson::from_grep_result(&result);
match serde_json::to_string(&json_result) {
@@ -406,7 +404,7 @@ pub unsafe extern "C" fn fff_multi_grep(
let is_ai = picker.mode().is_ai();
// Parse constraints from the optional string (e.g. "*.rs /src/")
let parsed_constraints = opts.constraints.as_deref().and_then(|c| {
let parsed_constraints = opts.constraints.as_deref().map(|c| {
if is_ai {
fff_core::QueryParser::new(fff_query_parser::AiGrepConfig).parse(c)
} else {
+55 -16
View File
@@ -8,14 +8,30 @@ use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{Level, debug, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
/// Owns the file-system watcher and guarantees that all background threads
/// are fully joined before `stop()` / `Drop` returns.
///
/// Architecture:
/// - The debouncer (and its internal watcher) live inside an **owner thread**
/// that we spawn and hold the `JoinHandle` for.
/// - `stop()` sets a flag, unparks the owner thread, and **joins** it.
/// - Inside the owner thread, `Debouncer::stop()` is called which joins the
/// debouncer's event-processing thread.
/// - On Windows an additional short sleep is added after `Debouncer::stop()`
/// because `notify`'s `ReadDirectoryChangesWatcher` discards its thread
/// `JoinHandle`, so we cannot join it directly. The watcher's `Drop` does
/// signal the thread via semaphore so it exits almost immediately, but we
/// need to give the OS a moment to reclaim it.
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
stop_signal: Arc<AtomicBool>,
owner_thread: Option<std::thread::JoinHandle<()>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
@@ -43,8 +59,33 @@ impl BackgroundWatcher {
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency, mode)?;
info!("Background file watcher initialized successfully");
let stop_signal = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_signal);
// The owner thread keeps the debouncer alive and ensures proper
// cleanup: `Debouncer::stop()` joins its internal thread, then the
// watcher `Drop` signals its I/O thread to exit.
let owner_thread = std::thread::Builder::new()
.name("fff-watcher-owner".into())
.spawn(move || {
while !stop_clone.load(Ordering::Acquire) {
std::thread::park_timeout(Duration::from_secs(1));
}
// Debouncer::stop() joins the debouncer's event thread, then
// drops the watcher (whose Drop signals the I/O thread).
debouncer.stop();
// On Windows the notify crate discards the ReadDirectoryChangesW
// thread's JoinHandle — we cannot join it. Its Drop signals the
// thread via semaphore so it exits almost immediately; give the
// OS a moment to fully reclaim it.
#[cfg(windows)]
std::thread::sleep(Duration::from_millis(250));
})
.expect("failed to spawn fff-watcher-owner thread");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
stop_signal,
owner_thread: Some(owner_thread),
})
}
@@ -130,25 +171,23 @@ impl BackgroundWatcher {
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::Release);
if let Some(handle) = self.owner_thread.take() {
handle.thread().unpark();
if let Err(e) = handle.join() {
error!("Watcher owner thread panicked: {:?}", e);
}
}
info!("Background file watcher stopped successfully");
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
self.stop();
}
}
+15 -23
View File
@@ -321,11 +321,10 @@ impl FilePicker {
///
/// # Returns
/// SearchResult containing matched files, scores, and location information
pub fn fuzzy_search<'a>(
pub fn fuzzy_search<'a, 'q>(
files: &'a [FileItem],
query: &'a str,
parsed: Option<FFFQuery<'a>>,
options: FuzzySearchOptions<'a>,
query: &'q FFFQuery<'q>,
options: FuzzySearchOptions<'q>,
) -> SearchResult<'a> {
let max_threads = if options.max_threads == 0 {
std::thread::available_parallelism()
@@ -335,8 +334,7 @@ impl FilePicker {
options.max_threads
};
debug!(
?query,
parsed_is_some = parsed.is_some(),
raw_query = ?query.raw_query,
pagination = ?options.pagination,
?max_threads,
current_file = ?options.current_file,
@@ -345,25 +343,20 @@ impl FilePicker {
let total_files = files.len();
// Extract location from parsed query
let location = parsed.as_ref().and_then(|p| p.location);
let location = query.location;
// Get effective query for max_typos calculation (without location suffix)
let effective_query = match &parsed {
Some(p) => match &p.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.trim(),
},
None => query.trim(),
let effective_query = match &query.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => *t,
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => query.raw_query.trim(),
};
// small queries with a large number of results can match absolutely everything
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
let context = ScoringContext {
raw_query: query,
parsed_query: parsed,
query,
project_path: options.project_path,
max_typos,
max_threads,
@@ -620,7 +613,7 @@ impl FilePicker {
}
pub fn stop_background_monitor(&mut self) {
if let Some(watcher) = self.background_watcher.take() {
if let Some(mut watcher) = self.background_watcher.take() {
watcher.stop();
}
}
@@ -798,11 +791,10 @@ fn warmup_mmaps(files: &[FileItem]) {
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());
if let Some(content) = file.get_mmap() {
// Read the first byte to trigger the initial page fault (mmap)
// or ensure the content is cached (Windows buffer).
let _ = std::hint::black_box(content.first());
warmed.fetch_add(1, Ordering::Relaxed);
}
+117 -39
View File
@@ -165,6 +165,67 @@ pub fn has_regex_metacharacters(text: &str) -> bool {
regex::escape(text) != text
}
/// Check if `text` contains `\n` that is NOT preceded by another `\`.
///
/// `\n` → true (user wants multiline search)
/// `\\n` → false (escaped backslash followed by literal `n`, e.g. `\\nvim-data`)
#[inline]
fn has_unescaped_newline_escape(text: &str) -> bool {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len().saturating_sub(1) {
if bytes[i] == b'\\' {
if bytes[i + 1] == b'n' {
// Count consecutive backslashes ending at position i
let mut backslash_count = 1;
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
backslash_count += 1;
}
// Odd number of backslashes before 'n' → real \n escape
if backslash_count % 2 == 1 {
return true;
}
}
// Skip past the escaped character
i += 2;
} else {
i += 1;
}
}
false
}
/// Replace only unescaped `\n` sequences with real newlines.
///
/// `\n` → newline character
/// `\\n` → preserved as-is (literal backslash + `n`)
fn replace_unescaped_newline_escapes(text: &str) -> String {
let bytes = text.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
if bytes[i + 1] == b'n' {
let mut backslash_count = 1;
while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
backslash_count += 1;
}
if backslash_count % 2 == 1 {
result.push(b'\n');
i += 2;
continue;
}
}
result.push(bytes[i]);
i += 1;
} else {
result.push(bytes[i]);
i += 1;
}
}
String::from_utf8(result).unwrap_or_else(|_| text.to_string())
}
/// Controls how the grep pattern is interpreted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GrepMode {
@@ -1018,8 +1079,8 @@ where
return None;
}
let mmap = file.get_mmap()?;
let file_matches = search_file(&mmap[..], options.max_matches_per_file);
let content = file.get_mmap()?;
let file_matches = search_file(content, options.max_matches_per_file);
if file_matches.is_empty() {
return None;
@@ -1303,8 +1364,7 @@ fn fuzzy_grep_search<'a>(
return None;
}
let mmap = file.get_mmap()?;
let file_bytes = &mmap[..];
let file_bytes = file.get_mmap()?;
// File-level prefilter: check if enough distinct needle chars
// exist anywhere in the file bytes. Uses memchr for speed.
@@ -1496,8 +1556,7 @@ fn fuzzy_grep_search<'a>(
/// frecency for the "welcome state" UI.
pub fn grep_search<'a>(
files: &'a [FileItem],
raw_query: &str,
query: Option<&FFFQuery<'_>>,
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
) -> GrepResult<'a> {
let total_files = files.len();
@@ -1507,36 +1566,23 @@ pub fn grep_search<'a>(
// removed. All non-constraint text tokens are collected and joined with
// spaces to form the grep pattern:
// "name = *.rs someth" -> grep "name = someth" with constraint Extension("rs")
let constraints_from_query: &[fff_query_parser::Constraint<'_>];
let constraints_from_query = &query.constraints[..];
let mut grep_text = match query {
Some(p) => {
constraints_from_query = &p.constraints[..];
p.grep_text()
}
None => {
constraints_from_query = &[];
// Single-token query (parser returned None). If the token is a
// backslash-escaped constraint (e.g. `\*.rs`, `\/src/`, `\!test`),
// strip the leading `\` so the literal text is searched. Other
// backslash sequences (e.g. `\bfoo\b` in regex mode) are left alone.
let t = raw_query.trim();
if t.starts_with('\\') && t.len() > 1 {
// Re-parse the unescaped suffix: if it would be a constraint,
// the user intended an escape; strip the backslash.
let suffix = &t[1..];
let parser = QueryParser::new(GrepConfig);
if parser
.parse(suffix)
.is_some_and(|q| !q.constraints.is_empty())
{
suffix.to_string()
} else {
t.to_string()
}
let grep_text = if !matches!(query.fuzzy_query, fff_query_parser::FuzzyQuery::Empty) {
query.grep_text()
} else {
// Constraint-only or empty query — use raw_query for backslash-escape handling.
let t = query.raw_query.trim();
if t.starts_with('\\') && t.len() > 1 {
let suffix = &t[1..];
let parser = QueryParser::new(GrepConfig);
if !parser.parse(suffix).constraints.is_empty() {
suffix.to_string()
} else {
t.to_string()
}
} else {
t.to_string()
}
};
@@ -1556,16 +1602,16 @@ pub fn grep_search<'a>(
prepare_files_to_search(files, constraints_from_query, options);
// If constraints yielded 0 files and we had a FilePath constraint,
// retry without it (the path token was likely part of the search text).
// Also restore the raw query as grep text so the filename token is
// searched as literal text rather than silently dropped.
// retry without it — the filename may not exist in this repo.
// Keep the original grep_text (e.g. "ActorAuth") rather than restoring
// the raw query ("nonexistent.rs ActorAuth"), since the search term
// was correctly extracted by the parser.
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
let (retry_files, retry_count) = prepare_files_to_search(files, &stripped, options);
files_to_search = retry_files;
filtered_file_count = retry_count;
grep_text = raw_query.trim().to_string();
}
if files_to_search.is_empty() {
@@ -1605,10 +1651,10 @@ pub fn grep_search<'a>(
.ok(),
};
let is_multiline = grep_text.contains("\\n");
let is_multiline = has_unescaped_newline_escape(&grep_text);
let effective_pattern = if is_multiline {
grep_text.replace("\\n", "\n")
replace_unescaped_newline_escapes(&grep_text)
} else {
grep_text.to_string()
};
@@ -1685,7 +1731,7 @@ pub fn grep_search<'a>(
}
/// Parse a grep query using the GrepConfig parser.
pub fn parse_grep_query(query: &str) -> Option<FFFQuery<'_>> {
pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
let parser = QueryParser::new(GrepConfig);
parser.parse(query)
}
@@ -1711,6 +1757,38 @@ fn strip_file_path_constraints<'a>(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unescaped_newline_detection() {
// Single \n → multiline
assert!(has_unescaped_newline_escape("foo\\nbar"));
// \\n → escaped backslash + literal n, NOT multiline
// (this is what the user types when grepping Rust source with `\\nvim`)
assert!(!has_unescaped_newline_escape("foo\\\\nvim-data"));
// Real-world: source file has literal \\AppData\\Local\\nvim-data
// (double backslash in the file, so user types double backslash)
assert!(!has_unescaped_newline_escape(
r#"format!("{}\\AppData\\Local\\nvim-data","#
));
// No \n at all
assert!(!has_unescaped_newline_escape("hello world"));
// \\\\n → even number of backslashes before n → NOT multiline
assert!(!has_unescaped_newline_escape("foo\\\\\\\\nbar"));
// \\\n → 3 backslashes: first two pair up, third + n = \n → multiline
assert!(has_unescaped_newline_escape("foo\\\\\\nbar"));
}
#[test]
fn test_replace_unescaped_newline() {
// \n → real newline
assert_eq!(replace_unescaped_newline_escapes("foo\\nbar"), "foo\nbar");
// \\n → preserved as-is
assert_eq!(
replace_unescaped_newline_escapes("foo\\\\nvim"),
"foo\\\\nvim"
);
}
#[test]
fn test_fuzzy_typo_scoring() {
+16
View File
@@ -17,6 +17,22 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
#[cfg(windows)]
pub fn expand_tilde(path: &str) -> PathBuf {
return PathBuf::from(path);
}
#[cfg(not(windows))]
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home_dir) = dirs::home_dir()
{
return home_dir.join(stripped);
}
PathBuf::from(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 {
+32 -29
View File
@@ -131,39 +131,34 @@ pub fn match_and_score_files<'a>(
return (vec![], vec![], 0);
}
let parsed = &context.parsed_query;
let working_files: FileItems<'a> = match parsed.as_ref().and_then(|p| {
if p.constraints.is_empty() {
None
} else {
apply_constraints(files, &p.constraints)
let parsed = context.query;
let working_files: FileItems<'a> = if parsed.constraints.is_empty() {
FileItems::All(files)
} else {
match apply_constraints(files, &parsed.constraints) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
}
}) {
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
Some(_) => {
return (vec![], vec![], 0);
}
None => FileItems::All(files),
};
let query_trimmed: &str = context.raw_query.trim();
let query_trimmed: &str = parsed.raw_query.trim();
let single_part_storage: [&str; 1] = [query_trimmed];
let fuzzy_parts: &[&str] = match parsed {
None => {
tracing::debug!("STEP 3: Query too short (<2 chars), returning frecency-sorted");
let fuzzy_parts: &[&str] = match &parsed.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
FuzzyQuery::Text(_) | FuzzyQuery::Parts(_) => {
return score_filtered_by_frecency(&working_files, context);
}
FuzzyQuery::Empty => {
if query_trimmed.len() < 2 {
return score_filtered_by_frecency(&working_files, context);
}
&single_part_storage
}
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
_ => {
return score_filtered_by_frecency(&working_files, context);
}
},
};
let has_uppercase = fuzzy_parts
@@ -327,6 +322,7 @@ pub fn match_and_score_files<'a>(
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
None if path_match.exact => "exact_path",
None => "fuzzy_path",
},
};
@@ -501,6 +497,7 @@ fn sort_and_paginate<'a>(
mod tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
use std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
@@ -552,9 +549,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -601,9 +600,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
@@ -648,9 +649,11 @@ mod tests {
.map(|(file, score)| (file, score.clone()))
.collect();
let query_str = "test";
let parser = QueryParser::default();
let query = parser.parse(query_str);
let context = ScoringContext {
raw_query: "test",
parsed_query: None,
query: &query,
max_threads: 1,
max_typos: 2,
current_file: None,
+83 -47
View File
@@ -1,18 +1,41 @@
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};
/// A single indexed file with metadata, frecency scores, and lazy mmap.
/// Cached file contents — mmap on Unix, heap buffer on Windows.
///
/// 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.
/// On Windows, memory-mapped files hold the file handle open and prevent
/// editors from saving (writing/replacing) those files. Reading into a
/// `Vec<u8>` releases the handle immediately after the read completes.
#[derive(Debug)]
#[allow(dead_code)] // variants are conditionally used per platform
enum FileContent {
#[cfg(not(target_os = "windows"))]
Mmap(memmap2::Mmap),
#[cfg(target_os = "windows")]
Buffer(Vec<u8>),
}
impl std::ops::Deref for FileContent {
type Target = [u8];
fn deref(&self) -> &[u8] {
match self {
#[cfg(not(target_os = "windows"))]
FileContent::Mmap(m) => m,
#[cfg(target_os = "windows")]
FileContent::Buffer(b) => b,
}
}
}
/// A single indexed file with metadata, frecency scores, and lazy content cache.
///
/// File contents are initialized lazily on the first grep access and cached for
/// subsequent searches. On Unix, uses mmap backed by the kernel page cache. On
/// Windows, reads into a heap buffer to avoid holding file handles open.
///
/// Thread-safety: `OnceLock` provides lock-free reads after initialization.
/// Each file is only searched by one rayon worker at a time via `par_iter`.
@@ -30,10 +53,9 @@ pub struct FileItem {
pub total_frecency_score: i64,
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Lazily-initialized memory-mapped file contents for grep.
/// Lazily-initialized 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>,
content: OnceLock<FileContent>,
}
impl Clone for FileItem {
@@ -51,8 +73,8 @@ impl Clone for FileItem {
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(),
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
}
}
@@ -83,46 +105,65 @@ impl FileItem {
total_frecency_score: 0,
git_status,
is_binary,
mmap: OnceLock::new(),
content: OnceLock::new(),
}
}
/// Invalidate the cached mmap so the next `get_mmap()` call creates a fresh one.
/// Invalidate the cached content so the next `get_content()` 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.
/// On Unix, a file that is truncated while mapped can cause SIGBUS. On Windows,
/// the stale buffer simply won't reflect the new contents. In both cases,
/// invalidating ensures a fresh read on the next access.
pub fn invalidate_mmap(&mut self) {
self.mmap = OnceLock::new();
self.content = 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.
/// Get the cached file contents or lazily load them. Returns `None` if the
/// file is too large, empty, or can't be opened.
///
/// 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.
/// On Unix, uses mmap backed by the kernel page cache. On Windows, reads into
/// a heap buffer so the file handle is released immediately.
#[inline]
pub fn get_mmap(&self) -> Option<&Mmap> {
if let Some(mmap) = self.mmap.get() {
return Some(mmap);
pub fn get_content(&self) -> Option<&[u8]> {
if let Some(content) = self.content.get() {
return Some(content);
}
if self.size == 0 || self.size > MAX_MMAP_FILE_SIZE {
return None;
}
let file = std::fs::File::open(&self.path).ok()?;
let content = load_file_content(&self.path)?;
// If another thread raced us, OnceLock discards ours and returns theirs.
Some(self.content.get_or_init(|| content))
}
/// Backward-compatible alias for `get_content`.
#[inline]
pub fn get_mmap(&self) -> Option<&[u8]> {
self.get_content()
}
}
/// Load file contents: mmap on Unix, heap buffer on Windows.
fn load_file_content(path: &Path) -> Option<FileContent> {
#[cfg(not(target_os = "windows"))]
{
let file = std::fs::File::open(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()?;
// truncated while mapped.
let mmap = unsafe { memmap2::Mmap::map(&file) }.ok()?;
Some(FileContent::Mmap(mmap))
}
// 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))
#[cfg(target_os = "windows")]
{
let data = std::fs::read(path).ok()?;
Some(FileContent::Buffer(data))
}
}
@@ -148,7 +189,7 @@ impl Constrainable for FileItem {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Score {
pub total: i32,
pub base_score: i32,
@@ -171,15 +212,13 @@ pub struct PaginationArgs {
/// Context for scoring files during search.
///
/// The `parsed_query` field contains the pre-parsed query with constraints,
/// The `query` field contains the pre-parsed query with constraints,
/// fuzzy parts, and location information. Parsing is done once at the API
/// boundary and passed through.
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
/// The original raw query string (for compatibility and debugging)
pub raw_query: &'a str,
/// Pre-parsed query containing constraints, fuzzy parts, and location
pub parsed_query: Option<FFFQuery<'a>>,
/// Parsed query containing raw text, constraints, fuzzy parts, and location
pub query: &'a FFFQuery<'a>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
@@ -190,17 +229,14 @@ pub struct ScoringContext<'a> {
pub pagination: PaginationArgs,
}
impl<'a> ScoringContext<'a> {
impl ScoringContext<'_> {
/// Get the effective fuzzy query string for matching.
/// Returns the first fuzzy part, or the raw query if no parsing was done.
pub fn effective_query(&self) -> &'a str {
match &self.parsed_query {
Some(p) => match &p.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.raw_query.trim(),
},
None => self.raw_query.trim(),
pub fn effective_query(&self) -> &str {
match &self.query.fuzzy_query {
FuzzyQuery::Text(t) => t,
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
_ => self.query.raw_query.trim(),
}
}
}
+123 -66
View File
@@ -72,7 +72,8 @@ fn plain_text_finds_exact_literal() {
"Hello, World!\nGoodbye, World!\n",
)];
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -89,7 +90,8 @@ fn plain_text_smart_case_insensitive() {
)];
// All lowercase query → smart case → case-insensitive
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -108,7 +110,8 @@ fn plain_text_smart_case_sensitive_with_uppercase() {
)];
// Query has uppercase → smart case → case-sensitive
let result = grep_search(&files, "Hello", None, &plain_opts());
let parsed = parse_grep_query("Hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -128,13 +131,15 @@ fn plain_text_regex_metacharacters_are_literal() {
)];
// In plain text mode, these regex metacharacters should be literal
let result = grep_search(&files, "fn main()", None, &plain_opts());
let parsed = parse_grep_query("fn main()");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
// Parentheses should NOT be treated as regex groups
let result2 = grep_search(&files, "(\"test\")", None, &plain_opts());
let parsed2 = parse_grep_query("(\"test\")");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 2);
}
@@ -149,7 +154,8 @@ fn plain_text_dot_is_literal() {
)];
// In plain text mode, dot should be literal, not "any char"
let result = grep_search(&files, "1.0", None, &plain_opts());
let parsed = parse_grep_query("1.0");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -168,7 +174,8 @@ fn plain_text_asterisk_is_literal() {
"Use **bold** text\nUse *italic* text\nUse normal text\n",
)];
let result = grep_search(&files, "**bold**", None, &plain_opts());
let parsed = parse_grep_query("**bold**");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
}
@@ -182,7 +189,8 @@ fn plain_text_backslash_is_literal() {
"C:\\Users\\foo\\bar\n/home/user/bin\n",
)];
let result = grep_search(&files, "C:\\Users", None, &plain_opts());
let parsed = parse_grep_query("C:\\Users");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -195,7 +203,8 @@ fn plain_text_across_multiple_files() {
create_file(tmp.path(), "c.txt", "no match here\n"),
];
let result = grep_search(&files, "use std", None, &plain_opts());
let parsed = parse_grep_query("use std");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 3);
// Should match in files a.txt and b.txt
@@ -207,7 +216,8 @@ fn plain_text_highlight_offsets_are_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "foo bar foo baz foo\n")];
let result = grep_search(&files, "foo", None, &plain_opts());
let parsed = parse_grep_query("foo");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -224,7 +234,8 @@ fn plain_text_empty_query_returns_no_content_matches() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &plain_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &plain_opts());
// Empty query in grep returns git-modified welcome state (no content matches)
// Since our test files have no git status, we expect 0 matches
@@ -245,7 +256,8 @@ fn plain_text_binary_files_are_skipped() {
let files = vec![binary_file, text_file];
let result = grep_search(&files, "match this text", None, &plain_opts());
let parsed = parse_grep_query("match this text");
let result = grep_search(&files, &parsed, &plain_opts());
// Only the text file should be searched, not the binary one
assert_eq!(result.files.len(), 1);
@@ -264,7 +276,8 @@ fn plain_text_max_matches_per_file() {
let mut opts = plain_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match_target", None, &opts);
let parsed = parse_grep_query("match_target");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -285,7 +298,8 @@ fn plain_text_page_limit() {
let mut opts = plain_opts();
opts.page_limit = 10;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
@@ -327,7 +341,8 @@ fn plain_text_file_offset_pagination() {
let max_pages = 20; // safety limit
loop {
let result = grep_search(&files, "unique_token", None, &opts);
let parsed = parse_grep_query("unique_token");
let result = grep_search(&files, &parsed, &opts);
for m in &result.matches {
let text = m.line_content.trim().to_string();
@@ -377,7 +392,8 @@ fn plain_text_line_numbers_are_correct() {
"line one\nline two\nline three\nline four\n",
)];
let result = grep_search(&files, "line", None, &plain_opts());
let parsed = parse_grep_query("line");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 4);
assert_eq!(result.matches[0].line_number, 1);
@@ -396,7 +412,8 @@ fn plain_text_max_file_size_filter() {
let mut opts = plain_opts();
opts.max_file_size = 100; // Only allow files up to 100 bytes
let result = grep_search(&files, "match_me", None, &opts);
let parsed = parse_grep_query("match_me");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(result.matches.len(), 0, "large file should be filtered out");
assert_eq!(result.filtered_file_count, 0);
@@ -413,7 +430,8 @@ fn regex_basic_pattern() {
"foo123\nbar456\nbaz789\nfoo_bar\n",
)];
let result = grep_search(&files, "foo\\d+", None, &regex_opts());
let parsed = parse_grep_query("foo\\d+");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -426,7 +444,8 @@ fn regex_capture_group_matching() {
let files = vec![create_file(tmp.path(), "a.txt", "foobar\nfoobaz\nfoo123\n")];
// Use a capturing group (not lookahead, which regex crate doesn't support)
let result = grep_search(&files, "foo(bar|baz)", None, &regex_opts());
let parsed = parse_grep_query("foo(bar|baz)");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let contents: Vec<&str> = result
@@ -448,7 +467,8 @@ fn regex_dot_matches_any_char() {
)];
// In regex mode, . matches any character, so v1.0 matches v1.0, v1x0, v1-0, and v100
let result = grep_search(&files, "v1.0", None, &regex_opts());
let parsed = parse_grep_query("v1.0");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -466,7 +486,8 @@ fn regex_alternation() {
"apple\nbanana\ncherry\napricot\n",
)];
let result = grep_search(&files, "apple|cherry", None, &regex_opts());
let parsed = parse_grep_query("apple|cherry");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 2);
let lines: Vec<u64> = result.matches.iter().map(|m| m.line_number).collect();
@@ -483,7 +504,8 @@ fn regex_character_class() {
"cat\ncut\ncot\ncit\ncxt\n",
)];
let result = grep_search(&files, "c[aou]t", None, &regex_opts());
let parsed = parse_grep_query("c[aou]t");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3);
let contents: Vec<&str> = result
@@ -505,7 +527,8 @@ fn regex_quantifiers() {
"fo\nfoo\nfooo\nfoooo\nbar\n",
)];
let result = grep_search(&files, "fo{2,3}", None, &regex_opts());
let parsed = parse_grep_query("fo{2,3}");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo");
}
@@ -519,7 +542,8 @@ fn regex_anchors() {
"start of line\nmiddle start end\nend of line\n",
)];
let result = grep_search(&files, "^start", None, &regex_opts());
let parsed = parse_grep_query("^start");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -535,7 +559,8 @@ fn regex_anchors_multiword() {
)];
// ^int ff_ should match lines starting with "int ff_"
let result = grep_search(&files, "^int ff_", None, &regex_opts());
let parsed = parse_grep_query("^int ff_");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -551,7 +576,8 @@ fn regex_highlight_offsets_variable_length() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "aab aaab aaaab\n")];
let result = grep_search(&files, "a+b", None, &regex_opts());
let parsed = parse_grep_query("a+b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -573,7 +599,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
)];
// Invalid regex: unmatched group — should fall back to literal search
let result = grep_search(&files, "name(", None, &regex_opts());
let parsed = parse_grep_query("name(");
let result = grep_search(&files, &parsed, &regex_opts());
// Fallback to literal: finds "name(" in "call name(arg)"
assert_eq!(
@@ -588,7 +615,8 @@ fn regex_invalid_pattern_falls_back_to_literal() {
assert!(result.matches[0].line_content.contains("name("));
// A pattern that doesn't exist anywhere — still falls back but finds nothing
let result2 = grep_search(&files, "zzz(", None, &regex_opts());
let parsed2 = parse_grep_query("zzz(");
let result2 = grep_search(&files, &parsed2, &regex_opts());
assert_eq!(result2.matches.len(), 0);
assert!(result2.regex_fallback_error.is_some());
}
@@ -603,11 +631,13 @@ fn regex_smart_case() {
)];
// Lowercase query → case-insensitive
let result_lower = grep_search(&files, "foo", None, &regex_opts());
let parsed_lower = parse_grep_query("foo");
let result_lower = grep_search(&files, &parsed_lower, &regex_opts());
assert_eq!(result_lower.matches.len(), 3);
// Query with uppercase → case-sensitive
let result_upper = grep_search(&files, "Foo", None, &regex_opts());
let parsed_upper = parse_grep_query("Foo");
let result_upper = grep_search(&files, &parsed_upper, &regex_opts());
assert_eq!(result_upper.matches.len(), 1);
}
@@ -628,7 +658,8 @@ fn regex_across_multiple_files() {
create_file(tmp.path(), "readme.md", "# Title\nSome text\n"),
];
let result = grep_search(&files, "fn \\w+\\(\\)", None, &regex_opts());
let parsed = parse_grep_query("fn \\w+\\(\\)");
let result = grep_search(&files, &parsed, &regex_opts());
// Should match: fn main(), fn helper(), fn test_one(), fn test_two()
assert_eq!(result.matches.len(), 4);
@@ -646,8 +677,9 @@ fn plain_text_and_regex_agree_on_simple_literal() {
"hello world\ngoodbye world\nhello again\n",
)];
let plain_result = grep_search(&files, "hello", None, &plain_opts());
let regex_result = grep_search(&files, "hello", None, &regex_opts());
let parsed = parse_grep_query("hello");
let plain_result = grep_search(&files, &parsed, &plain_opts());
let regex_result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(plain_result.matches.len(), regex_result.matches.len());
for (p, r) in plain_result.matches.iter().zip(regex_result.matches.iter()) {
@@ -666,8 +698,10 @@ fn plain_text_escapes_what_regex_does_not() {
)];
// "$100" — in plain text, $ is literal; in regex, $ is anchor
let plain_result = grep_search(&files, "$100", None, &plain_opts());
let regex_result = grep_search(&files, "\\$100", None, &regex_opts());
let parsed_plain = parse_grep_query("$100");
let plain_result = grep_search(&files, &parsed_plain, &plain_opts());
let parsed_regex = parse_grep_query("\\$100");
let regex_result = grep_search(&files, &parsed_regex, &regex_opts());
// Plain text should find "$100" literally
assert_eq!(plain_result.matches.len(), 1);
@@ -689,7 +723,7 @@ fn grep_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// Should only search .rs files
for file in &result.files {
@@ -716,7 +750,8 @@ fn plain_text_bracket_is_literal() {
"let x = arr[0];\nlet y = arr[1];\nlet z = something;\n",
)];
let result = grep_search(&files, "arr[0]", None, &plain_opts());
let parsed = parse_grep_query("arr[0]");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -738,7 +773,7 @@ fn grep_backslash_escapes_extension_filter() {
// Without escape: "*.rs" is an extension filter, so only .rs files are searched
let parsed = parse_grep_query("pattern *.rs");
let result_filter = grep_search(&files, "pattern *.rs", parsed.as_ref(), &plain_opts());
let result_filter = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result_filter.files.len(),
1,
@@ -747,7 +782,7 @@ fn grep_backslash_escapes_extension_filter() {
// With escape: "\*.rs" is literal text, both files are searched
let parsed_escaped = parse_grep_query("\\*.rs");
let result_literal = grep_search(&files, "\\*.rs", parsed_escaped.as_ref(), &plain_opts());
let result_literal = grep_search(&files, &parsed_escaped, &plain_opts());
assert_eq!(
result_literal.matches.len(),
2,
@@ -764,7 +799,8 @@ fn grep_backslash_escapes_path_segment() {
];
// With escape: "\\/src/" is literal text, not a path constraint
let result = grep_search(&files, "\\/src/", None, &plain_opts());
let parsed = parse_grep_query("\\/src/");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
2,
@@ -782,7 +818,8 @@ fn grep_backslash_escapes_negation() {
)];
// With escape: "\\!test" is literal text "!test"
let result = grep_search(&files, "\\!test", None, &plain_opts());
let parsed = parse_grep_query("\\!test");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.matches[0].line_content.contains("!test"));
}
@@ -797,7 +834,7 @@ fn grep_with_path_constraint() {
];
let parsed = parse_grep_query("target_text /src/");
let result = grep_search(&files, "target_text /src/", parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert!(result.files[0].relative_path.starts_with("src/"));
@@ -816,7 +853,7 @@ fn grep_with_negated_extension_constraint() {
let query = "target_text !*.rs";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -842,7 +879,7 @@ fn grep_with_negated_path_constraint() {
let query = "target_text !/src/";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -868,7 +905,7 @@ fn grep_with_negated_text_constraint() {
let query = "target_text !test";
let parsed = parse_grep_query(query);
let result = grep_search(&files, query, parsed.as_ref(), &plain_opts());
let result = grep_search(&files, &parsed, &plain_opts());
// "tests/helper.rs" contains "test" in path, should be excluded
assert_eq!(
@@ -898,7 +935,8 @@ fn grep_empty_file_is_skipped() {
let text_file = create_file(tmp.path(), "text.txt", "findme\n");
let files = vec![empty_file, text_file];
let result = grep_search(&files, "findme", None, &plain_opts());
let parsed = parse_grep_query("findme");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
}
@@ -908,7 +946,8 @@ fn grep_single_line_no_trailing_newline() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "no newline at end")];
let result = grep_search(&files, "no newline", None, &plain_opts());
let parsed = parse_grep_query("no newline");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 1);
@@ -923,11 +962,13 @@ fn grep_unicode_content() {
"日本語テスト\nrégulière\nñoño\n",
)];
let result = grep_search(&files, "régulière", None, &plain_opts());
let parsed = parse_grep_query("régulière");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].line_number, 2);
let result2 = grep_search(&files, "ñoño", None, &plain_opts());
let parsed2 = parse_grep_query("ñoño");
let result2 = grep_search(&files, &parsed2, &plain_opts());
assert_eq!(result2.matches.len(), 1);
assert_eq!(result2.matches[0].line_number, 3);
}
@@ -938,7 +979,8 @@ fn grep_long_line_is_truncated() {
let long_line = "x".repeat(1000) + "NEEDLE" + &"y".repeat(1000);
let files = vec![create_file(tmp.path(), "long.txt", &long_line)];
let result = grep_search(&files, "NEEDLE", None, &plain_opts());
let parsed = parse_grep_query("NEEDLE");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
// The line_content should be truncated to MAX_LINE_DISPLAY_LEN (512)
@@ -958,7 +1000,8 @@ fn regex_word_boundary() {
"foo\nfoobar\nbarfoo\nfoo_baz\n",
)];
let result = grep_search(&files, "\\bfoo\\b", None, &regex_opts());
let parsed = parse_grep_query("\\bfoo\\b");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -977,7 +1020,8 @@ fn plain_text_question_mark_is_literal() {
"what is this?\nhow does it work?\nno question here\nwhat?\n",
)];
let result = grep_search(&files, "?", None, &plain_opts());
let parsed = parse_grep_query("?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -995,7 +1039,8 @@ fn plain_text_query_with_question_mark_in_word() {
"let x = foo?;\nlet y = bar.baz();\nfoo?.unwrap()\n",
)];
let result = grep_search(&files, "foo?", None, &plain_opts());
let parsed = parse_grep_query("foo?");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(
result.matches.len(),
@@ -1010,7 +1055,8 @@ fn regex_question_mark_is_quantifier() {
let files = vec![create_file(tmp.path(), "a.txt", "color\ncolour\ncolouur\n")];
// In regex mode, ? means "zero or one of preceding"
let result = grep_search(&files, "colou?r", None, &regex_opts());
let parsed = parse_grep_query("colou?r");
let result = grep_search(&files, &parsed, &regex_opts());
assert_eq!(
result.matches.len(),
@@ -1030,7 +1076,8 @@ fn fuzzy_finds_exact_substring() {
"hello world\ngoodbye world\nhello again\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -1051,7 +1098,8 @@ fn fuzzy_finds_scattered_characters() {
)];
// "mutex" should fuzzy match "mutex_lock" (contiguous prefix)
let result = grep_search(&files, "mutex", None, &fuzzy_opts());
let parsed = parse_grep_query("mutex");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert!(
!result.matches.is_empty(),
@@ -1065,7 +1113,8 @@ fn fuzzy_highlight_offsets_correct() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "hello world\n")];
let result = grep_search(&files, "hell", None, &fuzzy_opts());
let parsed = parse_grep_query("hell");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1089,7 +1138,8 @@ fn fuzzy_unicode_char_indices() {
// Use "guli" which is a contiguous ASCII substring within "régulière"
// (the chars g-u-l-i appear contiguously between the two accented chars)
let result = grep_search(&files, "guli", None, &fuzzy_opts());
let parsed = parse_grep_query("guli");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should fuzzy match "régulière" (with multi-byte é and è)
// This tests that character-to-byte offset conversion works with UTF-8
@@ -1102,7 +1152,8 @@ fn fuzzy_empty_query_returns_empty() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "a.txt", "some content\n")];
let result = grep_search(&files, "", None, &fuzzy_opts());
let parsed = parse_grep_query("");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Empty query returns git-modified files, not fuzzy matches
assert_eq!(result.matches.len(), 0);
@@ -1118,7 +1169,7 @@ fn fuzzy_with_extension_constraint() {
];
let parsed = parse_grep_query("use std *.rs");
let result = grep_search(&files, "use std *.rs", parsed.as_ref(), &fuzzy_opts());
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only search .rs files
for file in &result.files {
@@ -1143,7 +1194,8 @@ fn fuzzy_respects_page_limit() {
opts.page_limit = 10;
opts.max_matches_per_file = 50;
let result = grep_search(&files, "target", None, &opts);
let parsed = parse_grep_query("target");
let result = grep_search(&files, &parsed, &opts);
// page_limit is a soft minimum: we always finish the current file, so we
// get at least page_limit matches (no data loss) and at most
@@ -1178,7 +1230,8 @@ fn fuzzy_respects_max_matches_per_file() {
let mut opts = fuzzy_opts();
opts.max_matches_per_file = 5;
let result = grep_search(&files, "match", None, &opts);
let parsed = parse_grep_query("match");
let result = grep_search(&files, &parsed, &opts);
assert_eq!(
result.matches.len(),
@@ -1198,7 +1251,8 @@ fn fuzzy_filters_low_quality_matches() {
// Search for "abc" - should match "abc_def_ghi" and "abcdefghij" with high scores,
// but NOT "xyz" (no relation) or "mutex_lock" (only weak letter overlap)
let result = grep_search(&files, "abc", None, &fuzzy_opts());
let parsed = parse_grep_query("abc");
let result = grep_search(&files, &parsed, &fuzzy_opts());
// Should only get high-quality matches
assert!(
@@ -1227,7 +1281,8 @@ fn fuzzy_exact_match_always_passes() {
)];
// Exact matches should always pass regardless of score threshold
let result = grep_search(&files, "exact", None, &fuzzy_opts());
let parsed = parse_grep_query("exact");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(
result.matches.len(),
@@ -1246,7 +1301,8 @@ fn fuzzy_score_is_captured() {
"hello world\ngoodbye world\n",
)];
let result = grep_search(&files, "hello", None, &fuzzy_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &fuzzy_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
@@ -1267,7 +1323,8 @@ fn fuzzy_score_is_none_in_plain_mode() {
let tmp = TempDir::new().unwrap();
let files = vec![create_file(tmp.path(), "test.txt", "hello world\n")];
let result = grep_search(&files, "hello", None, &plain_opts());
let parsed = parse_grep_query("hello");
let result = grep_search(&files, &parsed, &plain_opts());
assert_eq!(result.matches.len(), 1);
let m = &result.matches[0];
+2 -2
View File
@@ -103,7 +103,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
/// FFF MCP Server — high-performance file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
@@ -209,7 +209,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.to_string()
});
if !Repository::discover(&base_path).is_ok() {
if Repository::discover(&base_path).is_err() {
tracing::error!("MCP server must be run within a Git repository");
return Err(format!("Not a Git repository: {}", base_path).into());
}
+46 -58
View File
@@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
use fff_core::file_picker::FilePicker;
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{Constraint, FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
@@ -21,12 +21,12 @@ use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use crate::cursor::CursorStore;
use crate::output::{GrepFormatter, OutputMode, file_suffix};
/// Strip common delimiters for fuzzy fallback queries.
fn strip_delimiters(s: &str) -> String {
/// Strip common delimiters and lowercase for fuzzy fallback queries.
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.push(c);
out.extend(c.to_lowercase());
}
}
out
@@ -253,7 +253,7 @@ impl FffServer {
let parser = QueryParser::new(AiGrepConfig);
let parsed = parser.parse(query);
let result = grep::grep_search(files, query, parsed.as_ref(), &options);
let result = grep::grep_search(files, &parsed, &options);
if result.matches.is_empty() && file_offset == 0 {
// Auto-retry: try broadening multi-word queries by dropping first non-constraint word
@@ -268,10 +268,7 @@ impl FffServer {
let rest_query = parts[1..].join(" ");
let rest_parsed = parser.parse(&rest_query);
let rest_text: Cow<str> = rest_parsed
.as_ref()
.map(|p| Cow::Owned(p.grep_text()))
.unwrap_or(Cow::Borrowed(&rest_query));
let rest_text = rest_parsed.grep_text();
let retry_mode = if has_regex_metacharacters(&rest_text) {
GrepMode::Regex
} else {
@@ -279,8 +276,7 @@ impl FffServer {
};
let (retry_options, _) = make_grep_options(output_mode, retry_mode, 0, context);
let retry_result =
grep::grep_search(files, &rest_query, rest_parsed.as_ref(), &retry_options);
let retry_result = grep::grep_search(files, &rest_parsed, &retry_options);
if !retry_result.matches.is_empty() && retry_result.matches.len() <= 10 {
let mut cs = self.lock_cursors()?;
@@ -305,11 +301,10 @@ impl FffServer {
}
// Fuzzy fallback for typo tolerance
let fuzzy_query = strip_delimiters(&query.to_lowercase());
let fuzzy_query = cleanup_fuzzy_query(query);
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result =
grep::grep_search(files, &fuzzy_query, fuzzy_parsed.as_ref(), &fuzzy_options);
let fuzzy_result = grep::grep_search(files, &fuzzy_parsed, &fuzzy_options);
if !fuzzy_result.matches.is_empty() {
let mut lines: Vec<String> = Vec::new();
@@ -331,35 +326,40 @@ impl FffServer {
)]));
}
let hint = match &parsed {
Some(q)
if q.constraints
.iter()
.any(|c| matches!(c, Constraint::FilePath(_))) =>
// File path fallback: if query looks like a path, suggest the matching file
if query.contains('/') {
let file_parser = QueryParser::default();
let file_query = file_parser.parse(query);
let file_opts = FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(picker.base_path()),
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, &file_query, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
let path = q
.constraints
.iter()
.find_map(|c| match c {
Constraint::FilePath(p) => Some(*p),
_ => None,
})
.unwrap();
let ext = path.rsplit('.').next().unwrap_or("");
format!(
" Constraint '{path}' looks like a file path — use Read to search in a specific file, or '*.{ext}' for extension filter."
)
// Only suggest when the match is strong enough.
let query_len = query.len() as i32;
if score.base_score > query_len * 10 {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 content matches. But there is a relevant file path: {}",
top.relative_path
))]));
}
}
Some(q) if !q.constraints.is_empty() && !q.grep_text().is_empty() => {
" Try to omit constraint".to_string()
}
_ => String::new(),
};
}
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches {}",
hint
))]));
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
@@ -434,7 +434,7 @@ impl FffServer {
let parser = QueryParser::default();
let fff_query = parser.parse(query);
let result = FilePicker::fuzzy_search(files, query, fff_query, make_opts(page_offset));
let result = FilePicker::fuzzy_search(files, &fff_query, make_opts(page_offset));
let total_files = result.total_files;
// Auto-retry with fewer terms if 3+ words return 0 results
@@ -445,8 +445,7 @@ impl FffServer {
if result.items.is_empty() && words.len() >= 3 && page_offset == 0 {
if let Some(shorter) = &shorter {
let shorter_query = parser.parse(shorter);
let retry =
FilePicker::fuzzy_search(files, shorter, shorter_query, make_opts(0));
let retry = FilePicker::fuzzy_search(files, &shorter_query, make_opts(0));
(retry.items, retry.scores, retry.total_matched)
} else {
@@ -518,10 +517,7 @@ impl FffServer {
let output_mode = OutputMode::new(params.output_mode.as_deref());
let parsed = QueryParser::new(AiGrepConfig).parse(&params.query);
let grep_text: Cow<str> = parsed
.as_ref()
.map(|p| Cow::Owned(p.grep_text()))
.unwrap_or(Cow::Borrowed(&params.query));
let grep_text = parsed.grep_text();
let mode = if has_regex_metacharacters(&grep_text) {
GrepMode::Regex
@@ -587,15 +583,8 @@ impl FffServer {
let patterns_refs: Vec<&str> = params.patterns.iter().map(|s| s.as_str()).collect();
let parser = fff_query_parser::QueryParser::new(fff_query_parser::AiGrepConfig);
let parsed_constraints = if !constraint_query.is_empty() {
parser.parse(constraint_query)
} else {
None
};
let constraints = parsed_constraints
.as_ref()
.map(|p| p.constraints.as_slice())
.unwrap_or(&[]);
let parsed_constraints = parser.parse(constraint_query);
let constraints = parsed_constraints.constraints.as_slice();
let files = picker.get_files();
let result = grep::multi_grep_search(files, &patterns_refs, constraints, &options);
@@ -620,8 +609,7 @@ impl FffServer {
};
let parsed = parser.parse(&full_query);
let fb_result =
grep::grep_search(files, &full_query, parsed.as_ref(), &fallback_options);
let fb_result = grep::grep_search(files, &parsed, &fallback_options);
if !fb_result.matches.is_empty() {
let fb_file_refs: Vec<&FileItem> = fb_result.files.to_vec();
+29 -42
View File
@@ -1,7 +1,7 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_core::file_picker::{FFFMode, FilePicker};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
@@ -226,17 +226,18 @@ fn bench_search_queries(c: &mut Criterion) {
("partial", "src/lib"),
];
let parser = QueryParser::default();
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
let parsed = parser.parse(query);
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &_query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -269,6 +270,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
@@ -279,12 +282,10 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -318,6 +319,8 @@ fn bench_search_result_limits(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
@@ -325,12 +328,10 @@ fn bench_search_result_limits(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -371,6 +372,8 @@ fn bench_search_scalability(c: &mut Criterion) {
group.sample_size(50);
let query = "controller";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
@@ -383,12 +386,10 @@ fn bench_search_scalability(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -420,19 +421,19 @@ fn bench_search_ordering(c: &mut Criterion) {
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
let parser = QueryParser::default();
let parsed_controller = parser.parse("controller");
let parsed_mod = parser.parse("mod");
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -452,12 +453,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -477,12 +476,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -501,12 +498,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
None,
black_box(&parsed_mod),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -526,12 +521,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -550,12 +543,10 @@ fn bench_search_ordering(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
None,
black_box(&parsed_controller),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -587,6 +578,8 @@ fn bench_pagination_performance(c: &mut Criterion) {
group.sample_size(100);
let query = "mod";
let parser = QueryParser::default();
let parsed = parser.parse(query);
let page_size = 40;
// Benchmark first page (uses partial sort optimization)
@@ -594,12 +587,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -619,12 +610,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
@@ -644,12 +633,10 @@ fn bench_pagination_performance(c: &mut Criterion) {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
None,
black_box(&parsed),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
+1 -2
View File
@@ -87,8 +87,7 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
+1 -1
View File
@@ -78,7 +78,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
let parsed = parse_grep_query(query);
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
eprintln!("══════════════════════════════════════════════════════════════");
+2 -2
View File
@@ -144,7 +144,7 @@ impl<'a> GrepBench<'a> {
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.as_ref(), &self.options);
let result = grep_search(self.files, &parsed, &self.options);
let elapsed = start.elapsed();
(elapsed, result.matches.len(), result.total_files_searched)
}
@@ -452,7 +452,7 @@ fn main() {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(&files, pagination_query, parsed.as_ref(), &opts);
let result = grep_search(&files, &parsed, &opts);
let elapsed = start.elapsed();
eprintln!(
" {:>6} | {:>12} | {:>8} | {:>6} | {:>12}",
+4 -8
View File
@@ -210,17 +210,13 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
#[allow(dead_code)]
fn benchmark_fff_smart_case(
files: &[FileItem],
query: &str,
parsed: Option<FFFQuery<'_>>,
) -> (usize, Duration) {
fn benchmark_fff_smart_case(files: &[FileItem], parsed: &FFFQuery<'_>) -> (usize, Duration) {
let options = GrepSearchOptions {
max_file_size: 10 * 1024 * 1024,
max_matches_per_file: usize::MAX,
@@ -234,7 +230,7 @@ fn benchmark_fff_smart_case(
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
@@ -255,7 +251,7 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
classify_definitions: false,
};
let start = Instant::now();
let result = grep_search(files, query, parsed.as_ref(), &options);
let result = grep_search(files, &parsed, &options);
let elapsed = start.elapsed();
(result.matches.len(), elapsed)
}
+1 -2
View File
@@ -90,8 +90,7 @@ fn test_search_memory_pattern(
let parsed = parser.parse(&query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
+1 -2
View File
@@ -126,8 +126,7 @@ fn main() {
let parsed = parser.parse(query);
let results = FilePicker::fuzzy_search(
&files,
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 4,
current_file: None,
+1 -2
View File
@@ -200,8 +200,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = parser.parse(query);
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
parsed,
&parsed,
FuzzySearchOptions {
max_threads,
current_file: None,
+1 -2
View File
@@ -155,8 +155,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = parser.parse("rs");
let search_results = FilePicker::fuzzy_search(
files,
"rs",
parsed,
&parsed,
FuzzySearchOptions {
max_threads: 2,
current_file: None,
+190
View File
@@ -0,0 +1,190 @@
use mlua::prelude::*;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
// Byte category colors (matching hexyl's default theme)
const COLOR_OFFSET: &str = "#888888";
const COLOR_NULL: &str = "#555753";
const COLOR_ASCII_PRINTABLE: &str = "#06989a";
const COLOR_ASCII_WHITESPACE: &str = "#4e9a06";
const COLOR_ASCII_OTHER: &str = "#4e9a06";
const COLOR_NON_ASCII: &str = "#c4a000";
fn byte_color(b: u8) -> &'static str {
match b {
0x00 => COLOR_NULL,
0x20 | 0x09 | 0x0a | 0x0d => COLOR_ASCII_WHITESPACE,
0x21..=0x7e => COLOR_ASCII_PRINTABLE,
0x01..=0x1f | 0x7f => COLOR_ASCII_OTHER,
_ => COLOR_NON_ASCII,
}
}
fn byte_char(b: u8) -> char {
match b {
0x20..=0x7e => b as char,
_ => '.',
}
}
const BYTES_PER_LINE: usize = 16;
struct Span {
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
}
/// Push a span, merging with the previous one if same line and color.
fn push_span(
spans: &mut Vec<Span>,
line: usize,
col_start: usize,
col_end: usize,
color: &'static str,
) {
if let Some(last) = spans.last_mut() {
// Merge if same line, same color, and adjacent (allow small gaps for spaces between hex pairs)
if last.line == line && std::ptr::eq(last.color, color) && col_start <= last.col_end + 1 {
last.col_end = col_end;
return;
}
}
spans.push(Span {
line,
col_start,
col_end,
color,
});
}
/// Format raw bytes into hex dump lines with coalesced highlight spans.
///
/// Layout per line:
/// ```text
/// XXXXXXXX HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH HH CCCCCCCCCCCCCCCC
/// ```
fn format_hex_dump(raw_bytes: &[u8], base_offset: u64) -> (Vec<String>, Vec<Span>) {
let mut lines = Vec::new();
let mut spans = Vec::new();
for (chunk_idx, chunk) in raw_bytes.chunks(BYTES_PER_LINE).enumerate() {
let addr = base_offset + (chunk_idx * BYTES_PER_LINE) as u64;
let mut line = format!("{addr:08x} ");
// Offset label highlight
push_span(&mut spans, chunk_idx, 0, 8, COLOR_OFFSET);
// Hex pairs with a gap after 8 bytes
for (i, &b) in chunk.iter().enumerate() {
if i == 8 {
line.push(' ');
}
let col = line.len();
push_span(&mut spans, chunk_idx, col, col + 2, byte_color(b));
write!(line, "{b:02x} ").unwrap();
}
// Pad if the last line is short
if chunk.len() < BYTES_PER_LINE {
let missing = BYTES_PER_LINE - chunk.len();
let mut pad = missing * 3;
if chunk.len() <= 8 {
pad += 1;
}
for _ in 0..pad {
line.push(' ');
}
}
// Separator before char panel
line.push(' ');
// Character panel — consecutive same-color chars merge automatically
let char_start = line.len();
for (i, &b) in chunk.iter().enumerate() {
let col = char_start + i;
push_span(&mut spans, chunk_idx, col, col + 1, byte_color(b));
line.push(byte_char(b));
}
lines.push(line);
}
(lines, spans)
}
/// Generate a hex dump for a binary file with paging support and highlight data.
///
/// Returns a Lua table:
/// ```text
/// {
/// lines: string[],
/// highlights: {line_0idx, col_start, col_end, color}[],
/// has_more: bool,
/// next_offset: number,
/// }
/// ```
pub fn hex_dump(
lua: &Lua,
(file_path, offset, length): (String, Option<u64>, Option<u64>),
) -> LuaResult<LuaValue> {
let offset = offset.unwrap_or(0);
let length = length.unwrap_or(4096);
let file = std::fs::File::open(&file_path)
.map_err(|e| LuaError::RuntimeError(format!("Failed to open file: {e}")))?;
let file_size = file
.metadata()
.map_err(|e| LuaError::RuntimeError(format!("Failed to get metadata: {e}")))?
.len();
let table = lua.create_table()?;
if offset >= file_size {
table.set("lines", lua.create_table()?)?;
table.set("highlights", lua.create_table()?)?;
table.set("has_more", false)?;
table.set("next_offset", file_size)?;
return Ok(LuaValue::Table(table));
}
let mut reader = std::io::BufReader::new(file);
reader
.seek(SeekFrom::Start(offset))
.map_err(|e| LuaError::RuntimeError(format!("Failed to seek: {e}")))?;
let mut raw_bytes = Vec::with_capacity(length as usize);
reader
.by_ref()
.take(length)
.read_to_end(&mut raw_bytes)
.map_err(|e| LuaError::RuntimeError(format!("Failed to read: {e}")))?;
let (plain_lines, hl_spans) = format_hex_dump(&raw_bytes, offset);
let lines_table = lua.create_table()?;
for (i, line) in plain_lines.iter().enumerate() {
lines_table.set(i + 1, line.as_str())?;
}
table.set("lines", lines_table)?;
let highlights_table = lua.create_table()?;
for (i, span) in hl_spans.iter().enumerate() {
let hl = lua.create_table()?;
hl.raw_set(1, span.line)?;
hl.raw_set(2, span.col_start)?;
hl.raw_set(3, span.col_end)?;
hl.raw_set(4, span.color)?;
highlights_table.raw_set(i + 1, hl)?;
}
table.set("highlights", highlights_table)?;
let bytes_read = raw_bytes.len() as u64;
let next_offset = offset + bytes_read;
table.set("has_more", next_offset < file_size)?;
table.set("next_offset", next_offset)?;
Ok(LuaValue::Table(table))
}
+87 -6
View File
@@ -2,10 +2,11 @@ use crate::path_shortening::shorten_path_with_cache;
use error::{IntoCoreError, IntoLuaResult};
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::path_utils::expand_tilde;
use fff_core::query_tracker::QueryTracker;
use fff_core::{
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser,
SharedFrecency, SharedPicker, SharedQueryTracker,
DbHealthChecker, Error, FFFMode, FuzzySearchOptions, PaginationArgs, QueryParser, Score,
SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker,
};
use mimalloc::MiMalloc;
use mlua::prelude::*;
@@ -16,6 +17,7 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
mod error;
mod hex_dump;
mod log;
mod lua_types;
mod path_shortening;
@@ -248,10 +250,10 @@ pub fn fuzzy_search_files(
let parser = QueryParser::default();
let parsed = parser.parse(&query);
let files = picker.get_files();
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
parsed,
files,
&parsed,
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
@@ -266,6 +268,34 @@ pub fn fuzzy_search_files(
},
);
if results.items.is_empty() && query.contains(std::path::MAIN_SEPARATOR) {
let pure_query = match &parsed.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => t.trim(),
_ => query.trim(),
};
let path = expand_tilde(pure_query);
if path.is_absolute() && path.is_file() {
if let Ok(idx) = files.binary_search_by(|f| f.path.as_path().cmp(&path)) {
let found = SearchResult {
items: vec![&files[idx]],
scores: vec![Score {
exact_match: true,
match_type: "path",
..Default::default()
}],
total_matched: 1,
total_files: results.total_files,
location: parsed.location,
};
return lua_types::SearchResultLua::from(found).into_lua(lua);
}
return build_file_path_fallback(lua, &path, results.total_files);
}
}
lua_types::SearchResultLua::from(results).into_lua(lua)
}
@@ -321,11 +351,61 @@ pub fn live_grep(
classify_definitions: false,
};
let result = fff_core::grep::grep_search(picker.get_files(), &query, parsed.as_ref(), &options);
let result = fff_core::grep::grep_search(picker.get_files(), &parsed, &options);
lua_types::GrepResultLua::from(result).into_lua(lua)
}
/// Build a file-picker result for an absolute path that exists on disk but
/// isn't in the picker index (e.g. file from a different project).
fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = path.to_string_lossy().to_string();
let item = lua.create_table()?;
item.set("path", path_str.as_str())?;
item.set("relative_path", path_str.as_str())?;
item.set("name", name.as_str())?;
item.set("size", path.metadata().map(|m| m.len()).unwrap_or(0))?;
item.set("modified", 0u64)?;
item.set("access_frecency_score", 0i64)?;
item.set("modification_frecency_score", 0i64)?;
item.set("total_frecency_score", 0i64)?;
item.set("git_status", "")?;
item.set("is_binary", false)?;
let items_table = lua.create_table()?;
items_table.set(1, item)?;
table.set("items", items_table)?;
let score = lua.create_table()?;
score.set("total", 0)?;
score.set("base_score", 0)?;
score.set("filename_bonus", 0)?;
score.set("special_filename_bonus", 0)?;
score.set("frecency_boost", 0)?;
score.set("git_status_boost", 0)?;
score.set("distance_penalty", 0)?;
score.set("current_file_penalty", 0)?;
score.set("combo_match_boost", 0)?;
score.set("exact_match", true)?;
score.set("match_type", "path")?;
let scores_table = lua.create_table()?;
scores_table.set(1, score)?;
table.set("scores", scores_table)?;
table.set("total_matched", 1)?;
table.set("total_files", total_files)?;
Ok(LuaValue::Table(table))
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let file_path = PathBuf::from(&file_path);
@@ -827,6 +907,7 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
)?;
exports.set("health_check", lua.create_function(health_check)?)?;
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
exports.set("hex_dump", lua.create_function(hex_dump::hex_dump)?)?;
Ok(exports)
}
-6
View File
@@ -1,12 +1,7 @@
//! Lua type conversions for fff-core types
//!
//! This module provides IntoLua implementations for core types.
use fff_core::git::format_git_status;
use fff_core::{FileItem, GrepResult, Location, Score, SearchResult};
use mlua::prelude::*;
/// Wrapper for SearchResult that implements IntoLua
pub struct SearchResultLua<'a> {
inner: SearchResult<'a>,
}
@@ -17,7 +12,6 @@ impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
}
}
/// Wrapper for GrepResult that implements IntoLua
pub struct GrepResultLua<'a> {
inner: GrepResult<'a>,
}
+26 -3
View File
@@ -73,6 +73,13 @@ pub trait ParserConfig {
true
}
/// Should parse location suffixes (e.g., file:12, file:12:4)
/// Disabled for grep modes where colon-number patterns like localhost:8080
/// are search text, not file locations.
fn enable_location(&self) -> bool {
true
}
/// Determine whether a token should be treated as a glob constraint.
///
/// The default implementation delegates to `zlob::has_wildcards` with
@@ -126,6 +133,10 @@ impl ParserConfig for GrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
@@ -148,9 +159,17 @@ impl ParserConfig for GrepConfig {
return true;
}
// Brace expansion → useful for directory alternatives
if bytes.contains(&b'{') && bytes.contains(&b'}') {
return true;
// Brace expansion → useful for directory alternatives.
// Require a comma between `{` and `}` AND at least one letter to
// distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
// from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
if let Some(open) = bytes.iter().position(|&b| b == b'{')
&& let Some(close) = bytes.iter().rposition(|&b| b == b'}')
{
let inner = &bytes[open + 1..close];
if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
return true;
}
}
// Everything else (?, [, bare * without /) → treat as literal text
@@ -177,6 +196,10 @@ impl ParserConfig for AiGrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
+22 -37
View File
@@ -13,12 +13,13 @@
//!
//! let parser = QueryParser::default();
//!
//! // Single-token queries return None (no parsing needed)
//! // Single-token queries return FFFQuery with Text fuzzy query and no constraints
//! let result = parser.parse("hello");
//! assert!(result.is_none());
//! assert!(result.constraints.is_empty());
//! assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
//!
//! // Multi-token queries are parsed
//! let result = parser.parse("name *.rs").expect("Should parse");
//! let result = parser.parse("name *.rs");
//! match &result.fuzzy_query {
//! FuzzyQuery::Text(text) => assert_eq!(*text, "name"),
//! _ => panic!("Expected text"),
@@ -26,11 +27,11 @@
//! assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
//!
//! // Parse glob pattern with text
//! let result = parser.parse("**/*.rs foo").expect("Should parse");
//! let result = parser.parse("**/*.rs foo");
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
//!
//! // Parse negation
//! let result = parser.parse("!*.rs foo").expect("Should parse");
//! let result = parser.parse("!*.rs foo");
//! match &result.constraints[0] {
//! Constraint::Not(inner) => {
//! assert!(matches!(inner.as_ref(), Constraint::Extension("rs")));
@@ -64,32 +65,30 @@ mod tests {
fn test_empty_query() {
let parser = QueryParser::default();
let result = parser.parse("");
// Empty query returns None (single-token behavior)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_whitespace_only() {
let parser = QueryParser::default();
let result = parser.parse(" ");
// Whitespace-only returns None
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Empty);
}
#[test]
fn test_single_token() {
let parser = QueryParser::default();
let result = parser.parse("hello");
// Single token returns None (no parsing needed)
assert!(result.is_none());
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("hello"));
}
#[test]
fn test_simple_text() {
let parser = QueryParser::default();
let result = parser
.parse("hello world")
.expect("Should parse multi-token");
let result = parser.parse("hello world");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -107,9 +106,7 @@ mod tests {
fn test_extension_only() {
let parser = QueryParser::default();
// Single constraint token - returns Some so constraint can be applied
let result = parser
.parse("*.rs")
.expect("Should parse single constraint");
let result = parser.parse("*.rs");
assert!(matches!(result.fuzzy_query, FuzzyQuery::Empty));
assert_eq!(result.constraints.len(), 1);
assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
@@ -118,9 +115,7 @@ mod tests {
#[test]
fn test_glob_pattern() {
let parser = QueryParser::default();
let result = parser
.parse("**/*.rs foo")
.expect("Should parse multi-token");
let result = parser.parse("**/*.rs foo");
assert_eq!(result.constraints.len(), 1);
// Glob patterns with ** are treated as globs, not extensions
match &result.constraints[0] {
@@ -132,7 +127,7 @@ mod tests {
#[test]
fn test_negation_pattern() {
let parser = QueryParser::default();
let result = parser.parse("!test foo").expect("Should parse multi-token");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -145,7 +140,7 @@ mod tests {
#[test]
fn test_path_segment() {
let parser = QueryParser::default();
let result = parser.parse("/src/ foo").expect("Should parse multi-token");
let result = parser.parse("/src/ foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -156,9 +151,7 @@ mod tests {
#[test]
fn test_git_status() {
let parser = QueryParser::default();
let result = parser
.parse("status:modified foo")
.expect("Should parse multi-token");
let result = parser.parse("status:modified foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -169,9 +162,7 @@ mod tests {
#[test]
fn test_file_type() {
let parser = QueryParser::default();
let result = parser
.parse("type:rust foo")
.expect("Should parse multi-token");
let result = parser.parse("type:rust foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -182,9 +173,7 @@ mod tests {
#[test]
fn test_complex_query() {
let parser = QueryParser::default();
let result = parser
.parse("src name *.rs !test /lib/ status:modified")
.expect("Should parse");
let result = parser.parse("src name *.rs !test /lib/ status:modified");
// Verify we have fuzzy text
match &result.fuzzy_query {
@@ -226,9 +215,7 @@ mod tests {
#[test]
fn test_no_heap_allocation_for_small_queries() {
let parser = QueryParser::default();
let result = parser
.parse("*.rs *.toml !test")
.expect("Should parse multi-token");
let result = parser.parse("*.rs *.toml !test");
// SmallVec should not have spilled to heap
assert!(!result.constraints.spilled());
}
@@ -236,9 +223,7 @@ mod tests {
#[test]
fn test_many_fuzzy_parts() {
let parser = QueryParser::default();
let result = parser
.parse("one two three four five six")
.expect("Should parse");
let result = parser.parse("one two three four five six");
match &result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
+169 -158
View File
@@ -14,6 +14,8 @@ pub enum FuzzyQuery<'a> {
#[derive(Debug, Clone, PartialEq)]
pub struct FFFQuery<'a> {
/// The original raw query string before parsing
pub raw_query: &'a str,
/// Parsed constraints (stack-allocated for ≤8 constraints)
pub constraints: ConstraintVec<'a>,
pub fuzzy_query: FuzzyQuery<'a>,
@@ -32,8 +34,8 @@ impl<C: ParserConfig> QueryParser<C> {
Self { config }
}
pub fn parse<'a>(&self, query: &'a str) -> Option<FFFQuery<'a>> {
let query: &'a str = query;
pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
let raw_query = query;
let config: &C = &self.config;
let mut constraints = ConstraintVec::new();
let query = query.trim();
@@ -47,28 +49,53 @@ impl<C: ParserConfig> QueryParser<C> {
// Don't treat filename tokens (FilePath) as constraints in single-token
// queries — the user is fuzzy-searching, not filtering. FilePath constraints
// are only useful as filters in multi-token queries like "score.rs search".
if !matches!(constraint, Constraint::FilePath(_)) {
//
// Also skip PathSegment constraints when the token looks like an absolute
// file path with a location suffix (e.g. /Users/.../file.rs:12). Without
// this, the leading `/` causes the entire path to be consumed as a
// PathSegment, preventing location parsing from running.
let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
&& query.bytes().any(|b| b == b':')
&& query
.bytes()
.rev()
.take_while(|&b| b != b':')
.all(|b| b.is_ascii_digit());
if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
constraints.push(constraint);
return Some(FFFQuery {
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Empty,
location: None,
});
};
}
}
// Try to extract location from single token (e.g., "file:12")
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
if config.enable_location() {
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return FFFQuery {
raw_query,
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
};
}
}
// Plain text single token - return None (caller handles as simple fuzzy match)
return None;
// Plain text single token
return FFFQuery {
raw_query,
constraints,
fuzzy_query: if query.is_empty() {
FuzzyQuery::Empty
} else {
FuzzyQuery::Text(query)
},
location: None,
};
}
let mut text_parts = TextPartsBuffer::new();
@@ -99,7 +126,7 @@ impl<C: ParserConfig> QueryParser<C> {
// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if !text_parts.is_empty() {
let location = if config.enable_location() && !text_parts.is_empty() {
let last_idx = text_parts.len() - 1;
let (without_loc, loc) = parse_location(text_parts[last_idx]);
if loc.is_some() {
@@ -131,11 +158,12 @@ impl<C: ParserConfig> QueryParser<C> {
}
};
Some(FFFQuery {
FFFQuery {
raw_query,
constraints,
fuzzy_query,
location,
})
}
}
}
@@ -469,9 +497,7 @@ mod tests {
#[test]
fn test_trailing_slash_in_query() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("www/ test")
.expect("Should parse multi-token query");
let result = parser.parse("www/ test");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -508,9 +534,7 @@ mod tests {
fn test_negation_text() {
let parser = QueryParser::new(FilePickerConfig);
// Need two tokens for parsing to return Some
let result = parser
.parse("!test foo")
.expect("Should parse multi-token query");
let result = parser.parse("!test foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -523,9 +547,7 @@ mod tests {
#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!*.rs foo")
.expect("Should parse multi-token query");
let result = parser.parse("!*.rs foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -538,9 +560,7 @@ mod tests {
#[test]
fn test_negation_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!/src/ foo")
.expect("Should parse multi-token query");
let result = parser.parse("!/src/ foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -553,9 +573,7 @@ mod tests {
#[test]
fn test_negation_git_status() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("!status:modified foo")
.expect("Should parse multi-token query");
let result = parser.parse("!status:modified foo");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -571,9 +589,7 @@ mod tests {
#[test]
fn test_backslash_escape_extension() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\*.rs foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\*.rs foo");
// \*.rs should NOT be parsed as an Extension constraint
assert_eq!(result.constraints.len(), 0);
// Both tokens should be text
@@ -590,9 +606,7 @@ mod tests {
#[test]
fn test_backslash_escape_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\/src/ foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\/src/ foo");
assert_eq!(result.constraints.len(), 0);
match result.fuzzy_query {
FuzzyQuery::Parts(parts) => {
@@ -606,117 +620,90 @@ mod tests {
#[test]
fn test_backslash_escape_negation() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("\\!test foo")
.expect("Should parse multi-token query");
let result = parser.parse("\\!test foo");
assert_eq!(result.constraints.len(), 0);
}
#[test]
fn test_grep_text_plain_text() {
// Multi-token plain text — no constraints
let q = QueryParser::new(GrepConfig)
.parse("name =")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_strips_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name = *.rs someth")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
assert_eq!(q.grep_text(), "name = someth");
}
#[test]
fn test_grep_text_leading_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs name =")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("*.rs name =");
assert_eq!(q.grep_text(), "name =");
}
#[test]
fn test_grep_text_only_constraints() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs /src/")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
assert_eq!(q.grep_text(), "");
}
#[test]
fn test_grep_text_path_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name /src/ value")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name /src/ value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_negation_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("name !*.rs value")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
assert_eq!(q.grep_text(), "name value");
}
#[test]
fn test_grep_text_backslash_escape_stripped() {
// \*.rs should be text with the leading \ removed
let q = QueryParser::new(GrepConfig)
.parse("\\*.rs foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
assert_eq!(q.grep_text(), "*.rs foo");
let q = QueryParser::new(GrepConfig)
.parse("\\/src/ foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(q.grep_text(), "/src/ foo");
let q = QueryParser::new(GrepConfig)
.parse("\\!test foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\!test foo");
assert_eq!(q.grep_text(), "!test foo");
}
#[test]
fn test_grep_text_question_mark_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("foo? bar")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("foo? bar");
assert_eq!(q.grep_text(), "foo? bar");
}
#[test]
fn test_grep_text_bracket_is_text() {
let q = QueryParser::new(GrepConfig)
.parse("arr[0] more")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("arr[0] more");
assert_eq!(q.grep_text(), "arr[0] more");
}
#[test]
fn test_grep_text_path_glob_is_constraint() {
let q = QueryParser::new(GrepConfig)
.parse("pattern src/**/*.rs")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
assert_eq!(q.grep_text(), "pattern");
}
#[test]
fn test_grep_question_mark_is_text() {
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");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
}
#[test]
fn test_grep_bracket_is_text() {
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);
}
@@ -724,9 +711,7 @@ mod tests {
#[test]
fn test_grep_path_glob_is_constraint() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern src/**/*.rs")
.expect("Should parse with path glob");
let result = parser.parse("pattern src/**/*.rs");
// src/**/*.rs contains / so it should be treated as a glob
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -738,9 +723,7 @@ mod tests {
#[test]
fn test_grep_brace_is_constraint() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern {src,lib}")
.expect("Should parse with brace expansion");
let result = parser.parse("pattern {src,lib}");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -753,45 +736,34 @@ mod tests {
// Regex patterns like \w+ and \bfoo\b must survive grep_text()
// The parser sees \w+ as a text token (not a constraint escape),
// but strip_leading_backslash was stripping the \ anyway.
let q = QueryParser::new(GrepConfig)
.parse("pub struct \\w+")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
assert_eq!(
q.grep_text(),
"pub struct \\w+",
"Backslash-w in regex must be preserved"
);
let q = QueryParser::new(GrepConfig)
.parse("\\bword\\b more")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
assert_eq!(
q.grep_text(),
"\\bword\\b more",
"Backslash-b word boundaries must be preserved"
);
// Single-token regex like "fn\\s+\\w+" returns None from parse()
// (single token = no parsing needed, caller uses raw_query directly).
// Single-token regex like "fn\\s+\\w+" returns FFFQuery with Text fuzzy query
let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
assert!(
result.is_none(),
"Single-token regex should return None (no parsing)"
);
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
// But the escaped constraint forms SHOULD still be stripped:
let q = QueryParser::new(GrepConfig)
.parse("\\*.rs foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
assert_eq!(
q.grep_text(),
"*.rs foo",
"Escaped constraint \\*.rs should still have backslash stripped"
);
let q = QueryParser::new(GrepConfig)
.parse("\\/src/ foo")
.expect("should parse");
let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
assert_eq!(
q.grep_text(),
"/src/ foo",
@@ -804,7 +776,6 @@ mod tests {
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,
@@ -815,9 +786,7 @@ mod tests {
#[test]
fn test_grep_negated_text() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !test")
.expect("Should parse negated text in grep mode");
let result = parser.parse("pattern !test");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -834,9 +803,7 @@ mod tests {
#[test]
fn test_grep_negated_path_segment() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !/src/")
.expect("Should parse negated path segment in grep mode");
let result = parser.parse("pattern !/src/");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -853,9 +820,7 @@ mod tests {
#[test]
fn test_grep_negated_extension() {
let parser = QueryParser::new(GrepConfig);
let result = parser
.parse("pattern !*.rs")
.expect("Should parse negated extension in grep mode");
let result = parser.parse("pattern !*.rs");
assert_eq!(result.constraints.len(), 1);
match &result.constraints[0] {
Constraint::Not(inner) => {
@@ -875,9 +840,7 @@ mod tests {
fn test_ai_grep_detects_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser
.parse("libswscale/input.c rgba32ToY")
.expect("Should parse");
let result = parser.parse("libswscale/input.c rgba32ToY");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
@@ -894,7 +857,7 @@ mod tests {
fn test_ai_grep_detects_nested_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/main.rs fn main").expect("Should parse");
let result = parser.parse("src/main.rs fn main");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -907,7 +870,7 @@ mod tests {
fn test_ai_grep_no_false_positive_trailing_slash() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/ pattern").expect("Should parse");
let result = parser.parse("src/ pattern");
// Should be PathSegment, NOT FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -921,7 +884,7 @@ mod tests {
fn test_ai_grep_bare_filename_is_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern").expect("Should parse");
let result = parser.parse("main.rs pattern");
// Bare filename with valid extension → FilePath constraint
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -936,9 +899,7 @@ mod tests {
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser
.parse("schema.rs part_revisions")
.expect("Should parse");
let result = parser.parse("schema.rs part_revisions");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
@@ -952,7 +913,7 @@ mod tests {
fn test_ai_grep_bare_word_no_extension_not_constraint() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema pattern").expect("Should parse");
let result = parser.parse("schema pattern");
// No extension → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "schema pattern");
@@ -962,7 +923,7 @@ mod tests {
fn test_ai_grep_no_false_positive_no_extension() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/utils pattern").expect("Should parse");
let result = parser.parse("src/utils pattern");
// No extension in last component → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "src/utils pattern");
@@ -972,7 +933,7 @@ mod tests {
fn test_ai_grep_wildcard_not_filepath() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("src/**/*.rs pattern").expect("Should parse");
let result = parser.parse("src/**/*.rs pattern");
// Contains wildcards → should be a Glob, not FilePath
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -986,7 +947,7 @@ mod tests {
fn test_ai_grep_star_text_star_is_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("*quote* TODO").expect("Should parse");
let result = parser.parse("*quote* TODO");
// `*quote*` should be recognised as a glob constraint in AI mode
assert_eq!(result.constraints.len(), 1);
assert!(
@@ -1001,7 +962,7 @@ mod tests {
fn test_ai_grep_bare_star_not_glob() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("* pattern").expect("Should parse");
let result = parser.parse("* pattern");
// Bare `*` should NOT be treated as a glob (too broad)
assert!(
result.constraints.is_empty(),
@@ -1010,11 +971,62 @@ mod tests {
);
}
#[test]
fn test_grep_no_location_parsing_single_token() {
let parser = QueryParser::new(GrepConfig);
// localhost:8080 should NOT be parsed as location -- it's a search pattern
let result = parser.parse("localhost:8080");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
}
#[test]
fn test_grep_no_location_parsing_multi_token() {
let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
assert_eq!(
q.grep_text(),
"localhost:8080",
"Colon-number suffix should be preserved in grep text"
);
assert!(
q.location.is_none(),
"Grep should not parse location from colon-number"
);
}
#[test]
fn test_grep_braces_without_comma_is_text() {
let parser = QueryParser::new(GrepConfig);
// Code patterns like format!("{}") should NOT be treated as brace expansion
let result = parser.parse(r#"format!("{}\\AppData", home)"#);
assert!(
result.constraints.is_empty(),
"Braces without comma should be text, got {:?}",
result.constraints
);
assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
}
#[test]
fn test_grep_format_braces_not_glob() {
let parser = QueryParser::new(GrepConfig);
// Code like format!("{}\\path", var) must not have tokens eaten as glob constraints.
// The trailing comma on the first token means both { } and , are present,
// but the comma is outside the braces so it should NOT trigger brace expansion.
let input = "format!(\"{}\\\\AppData\", home)";
let result = parser.parse(input);
assert!(
result.constraints.is_empty(),
"format! pattern should have no constraints, got {:?}",
result.constraints
);
}
#[test]
fn test_grep_config_star_text_star_not_glob() {
use crate::GrepConfig;
let parser = QueryParser::new(GrepConfig);
let result = parser.parse("*quote* TODO").expect("Should parse");
let result = parser.parse("*quote* TODO");
// Regular grep mode should NOT treat `*quote*` as a glob
assert!(
result.constraints.is_empty(),
@@ -1028,9 +1040,7 @@ mod tests {
#[test]
fn test_file_picker_bare_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("score.rs file_picker")
.expect("Should parse multi-token query");
let result = parser.parse("score.rs file_picker");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("score.rs")),
@@ -1043,9 +1053,7 @@ mod tests {
#[test]
fn test_file_picker_path_prefixed_filename_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("libswscale/slice.c lum_convert")
.expect("Should parse multi-token query");
let result = parser.parse("libswscale/slice.c lum_convert");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(
@@ -1061,22 +1069,35 @@ mod tests {
#[test]
fn test_file_picker_single_token_filename_stays_fuzzy() {
let parser = QueryParser::new(FilePickerConfig);
// Single-token filename should NOT become a constraint it should
// return None so the caller uses the raw query for fuzzy matching.
// Single-token filename should NOT become a constraint -- it should
// return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
let result = parser.parse("score.rs");
assert!(result.constraints.is_empty());
assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
}
#[test]
fn test_absolute_path_with_location_not_path_segment() {
let parser = QueryParser::new(FilePickerConfig);
// Absolute file path with :line should parse as text + location,
// NOT as a PathSegment constraint (which would eat the whole token).
let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
assert!(
result.is_none(),
"Single-token filename should return None (fuzzy match), got {:?}",
result
result.constraints.is_empty(),
"Absolute path with location should not become a constraint, got {:?}",
result.constraints
);
assert_eq!(
result.fuzzy_query,
FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
);
assert_eq!(result.location, Some(Location::Line(12)));
}
#[test]
fn test_file_picker_filename_with_multiple_fuzzy_parts() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs src components")
.expect("Should parse multi-token query");
let result = parser.parse("main.rs src components");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
result.constraints[0],
@@ -1091,9 +1112,7 @@ mod tests {
#[test]
fn test_file_picker_version_number_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("v2.0 release")
.expect("Should parse multi-token query");
let result = parser.parse("v2.0 release");
// v2.0 extension starts with digit → not a filename constraint
assert!(
result.constraints.is_empty(),
@@ -1105,9 +1124,7 @@ mod tests {
#[test]
fn test_file_picker_only_one_filepath_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs score.rs")
.expect("Should parse multi-token query");
let result = parser.parse("main.rs score.rs");
// Only first filename becomes a constraint; second is text
assert_eq!(result.constraints.len(), 1);
assert!(matches!(
@@ -1120,9 +1137,7 @@ mod tests {
#[test]
fn test_file_picker_filename_with_extension_constraint() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("main.rs *.lua")
.expect("Should parse multi-token query");
let result = parser.parse("main.rs *.lua");
// main.rs → FilePath, *.lua → Extension
assert_eq!(result.constraints.len(), 2);
assert!(matches!(
@@ -1138,9 +1153,7 @@ mod tests {
#[test]
fn test_file_picker_dotfile_is_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse(".gitignore src")
.expect("Should parse multi-token query");
let result = parser.parse(".gitignore src");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
@@ -1153,9 +1166,7 @@ mod tests {
#[test]
fn test_file_picker_no_extension_not_filename() {
let parser = QueryParser::new(FilePickerConfig);
let result = parser
.parse("Makefile src")
.expect("Should parse multi-token query");
let result = parser.parse("Makefile src");
// No dot → not a filename constraint
assert!(
result.constraints.is_empty(),
+2 -2
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 17
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -38,7 +38,7 @@ script:
<
The installation script is here ./install-fff.sh <./install-fff.sh> if you want
The installation script is here ./install-mcp.sh <./install-mcp.sh> if you want
to review it before running.
It will print out the instructions on how to connect it to your `Claude Code`,
`Codex`, `OpenCode`, etc. Once you have it connected just ask your agent to
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1767744144,
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"owner": "ipetkov",
"repo": "crane",
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1773597492,
"narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1770865833,
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
"lastModified": 1773716879,
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
"type": "github"
},
"original": {
+24 -6
View File
@@ -126,7 +126,9 @@ download_binary() {
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
if [ "$IS_UPDATE" != true ]; then
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
fi
}
check_path() {
@@ -236,19 +238,35 @@ print_setup_instructions() {
}
main() {
info "Installing FFF MCP Server..."
echo ""
local target
target="$(detect_platform)"
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
IS_UPDATE=false
if [ -x "$existing_binary" ]; then
IS_UPDATE=true
info "Updating FFF MCP Server..."
else
info "Installing FFF MCP Server..."
fi
echo ""
info "Detected platform: ${target}"
local tag
tag="$(get_latest_release_tag "$target")"
download_binary "$target" "$tag"
check_path
print_setup_instructions
if [ "$IS_UPDATE" = true ]; then
echo ""
success "FFF MCP Server updated to ${tag}!"
echo ""
else
check_path
print_setup_instructions
fi
}
main
+107 -42
View File
@@ -2,6 +2,7 @@ local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local rust = require('fff.rust')
local M = {}
@@ -301,6 +302,8 @@ M.state = {
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
is_binary_preview = false, -- Whether the current preview is a hex dump
hex_byte_offset = 0, -- Next byte offset for hex dump paging
}
--- Setup preview configuration
@@ -554,7 +557,69 @@ function M.preview_file(file_path, bufnr)
return true
end
--- Preview a binary file with async file type detection
-- Hex preview highlight support: dynamically create hl groups from "#rrggbb"
local hex_ns = nil
local hex_hl_cache = {}
local function ensure_hex_ns()
if not hex_ns then hex_ns = vim.api.nvim_create_namespace('fff_hex_preview') end
return hex_ns
end
local function get_hex_hl_group(hex_color)
local cached = hex_hl_cache[hex_color]
if cached then return cached end
local group = 'FffHex_' .. hex_color:sub(2)
vim.api.nvim_set_hl(0, group, { fg = hex_color })
hex_hl_cache[hex_color] = group
return group
end
--- Apply hex highlight spans to a buffer
--- @param bufnr number Buffer number
--- @param highlights table Array of {line_0idx, col_start, col_end, "#rrggbb"}
--- @param line_offset number Lines to add to each highlight line index (for header)
local function apply_hex_highlights(bufnr, highlights, line_offset)
if not highlights or not vim.api.nvim_buf_is_valid(bufnr) then return end
local ns = ensure_hex_ns()
for _, hl in ipairs(highlights) do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hl[1] + line_offset, hl[2], {
end_col = hl[3],
hl_group = get_hex_hl_group(hl[4]),
})
end
end
--- Load a page of hex dump content from the Rust backend
--- @param file_path string Path to the binary file
--- @param byte_offset number Byte offset to start reading from
--- @return table|nil Result with lines, highlights, has_more, next_offset
local function load_hex_page(file_path, byte_offset)
local ok, result = pcall(rust.hex_dump, file_path, byte_offset, 4096)
if ok and result then return result end
return nil
end
--- Load more hex content when scrolling near the end of the buffer
local function load_more_hex_content()
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 not M.state.current_file then return end
local current_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local result = load_hex_page(M.state.current_file, M.state.hex_byte_offset)
if result and result.lines and #result.lines > 0 then
append_buffer_lines(M.state.bufnr, result.lines)
M.state.hex_byte_offset = result.next_offset
M.state.has_more_content = result.has_more
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
M.state.loaded_lines = M.state.content_height
apply_hex_highlights(M.state.bufnr, result.highlights, current_lines)
else
M.state.has_more_content = false
end
end
--- Preview a binary file using hexyl-powered hex dump with paging
--- @param file_path string Path to the file
--- @param bufnr number Buffer number for preview
--- @return boolean Success status
@@ -562,49 +627,40 @@ function M.preview_binary_file(file_path, bufnr)
local info = M.get_file_info(file_path)
local lines = {}
M.state.is_binary_preview = true
M.state.hex_byte_offset = 0
-- Build header synchronously (file -b is fast, typically <10ms)
if vim.fn.executable('file') == 1 then
local output = vim.fn.system({ 'file', '-b', file_path })
if vim.v.shell_error == 0 and output then
local file_type = output:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
table.insert(lines, '')
end
end
local hex_result = load_hex_page(file_path, 0)
if hex_result and hex_result.lines then
for _, hex_line in ipairs(hex_result.lines) do
table.insert(lines, hex_line)
end
M.state.hex_byte_offset = hex_result.next_offset
M.state.has_more_content = hex_result.has_more
end
set_buffer_lines(bufnr, lines)
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
if vim.fn.executable('file') == 1 then
local cmd = { 'file', '-b', file_path }
vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
M.state.content_height = #lines
M.state.loaded_lines = #lines
if result.code == 0 and result.stdout then
local file_type = result.stdout:gsub('\n', '')
table.insert(lines, 'Binary file: ' .. file_type)
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
if vim.fn.executable('xxd') == 1 then
table.insert(lines, '')
set_buffer_lines(bufnr, lines)
local hex_cmd = { 'xxd', '-l', '8192', file_path }
vim.system(hex_cmd, { text = true }, function(hex_result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
if hex_result.code == 0 and hex_result.stdout then
local hex_lines = vim.split(hex_result.stdout, '\n')
for _, line in ipairs(hex_lines) do
if line:match('%S') then table.insert(lines, line) end
end
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
end
set_buffer_lines(bufnr, lines)
end)
end)
else
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
set_buffer_lines(bufnr, lines)
end
end
end)
end)
if hex_result and hex_result.highlights then
local header_lines = #lines - (hex_result.lines and #hex_result.lines or 0)
apply_hex_highlights(bufnr, hex_result.highlights, header_lines)
end
return true
@@ -641,6 +697,9 @@ function M.preview(file_path, bufnr, location, is_binary)
M.state.total_file_lines = nil
M.state.has_more_content = true
M.state.is_loading = false
M.state.hex_byte_offset = 0
M.state.is_binary_preview = false
M.state.current_file = file_path
M.state.bufnr = bufnr
@@ -672,15 +731,19 @@ function M.scroll(lines)
-- If scrolling down and approaching end of loaded content, try to load more
if lines > 0 and not M.state.is_loading then
local target_line = new_offset + win_height
local buffer_needed = target_line + 20 -- Load a bit ahead
local buffer_needed = target_line + 20
if current_buffer_lines < buffer_needed and M.state.has_more_content then
-- Load more content asynchronously but don't wait for it
ensure_content_loaded_async(target_line)
if M.state.is_binary_preview then
load_more_hex_content()
-- Re-read line count after loading more
current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
else
ensure_content_loaded_async(target_line)
end
end
end
-- Use actual buffer line count for scroll calculations
local content_height = current_buffer_lines
local half_screen = math.floor(win_height / 2)
local max_scroll = math.max(0, content_height + half_screen - win_height)
@@ -802,6 +865,8 @@ function M.clear()
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
M.state.is_binary_preview = false
M.state.hex_byte_offset = 0
end
--- Apply location highlighting to the preview buffer
+7 -25
View File
@@ -165,11 +165,12 @@ function M.find_files_in_dir(directory)
return
end
M.change_indexing_directory(directory)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
picker_ui.open({
title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t'),
cwd = directory,
})
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
@@ -179,28 +180,9 @@ end
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then return picker_ui.change_indexing_directory(new_path) end
return false
end
--- Opens the file under the cursor with an optional callback if the only file
+64
View File
@@ -1700,6 +1700,23 @@ function M.update_preview()
-- Check if we need to update the preview (file changed OR location changed)
local effective_location = M.state.location
-- Fallback: if location is nil but query has a :line suffix, parse it directly
if not effective_location and M.state.query and M.state.query ~= '' then
local line_str = M.state.query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local l, c = M.state.query:match(':(%d+):(%d+)$')
if l then
effective_location = { line = tonumber(l), col = tonumber(c) }
else
effective_location = { line = line_num }
end
end
end
end
-- In grep mode (or when previewing grep suggestions), location comes from the match item
local is_grep_item = M.state.mode == 'grep' or M.state.suggestion_source == 'grep'
if is_grep_item and item.line_number and item.line_number > 0 then
@@ -2293,6 +2310,23 @@ function M.select(action)
end
end
-- Fallback: if location is nil but query has a :line suffix, parse it directly
if not location and query and query ~= '' then
local line_str = query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local col_and_line = query:match(':(%d+):(%d+)$')
if col_and_line then
local l, c = query:match(':(%d+):(%d+)$')
location = { line = tonumber(l), col = tonumber(c) }
else
location = { line = line_num }
end
end
end
end
vim.cmd('stopinsert')
M.close()
@@ -2601,6 +2635,8 @@ function M.open(opts)
local merged_config, base_path = initialize_picker(opts)
if not merged_config then return end
if base_path then M.change_indexing_directory(base_path) end
-- Initialize grep_mode to first configured mode when opening in grep mode
if M.state.mode == 'grep' then
-- Use grep_config.modes if provided, otherwise fall back to global config
@@ -2616,6 +2652,34 @@ function M.open(opts)
return open_ui_with_state(query, nil, nil, merged_config, current_file_cache)
end
--- Change the base directory for the file picker
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
end
function M.monitor_scan_progress(iteration)
if not M.state.active then return end
+10 -37
View File
@@ -148,9 +148,7 @@ function snakeToCamel(obj: unknown): unknown {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
letter.toUpperCase(),
);
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
result[camelKey] = snakeToCamel(value);
}
return result;
@@ -298,15 +296,9 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
/**
* Wait for scan to complete.
*/
export function ffiWaitForScan(
handle: NativeHandle,
timeoutMs: number,
): Result<boolean> {
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_scan(
handle,
BigInt(timeoutMs),
);
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
// JSON.parse("true") returns boolean true, but we also handle
@@ -317,15 +309,9 @@ export function ffiWaitForScan(
/**
* Restart index in new path.
*/
export function ffiRestartIndex(
handle: NativeHandle,
newPath: string,
): Result<void> {
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_restart_index(
handle,
ptr(encodeString(newPath)),
);
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
return parseResult<void>(resultPtr);
}
@@ -340,10 +326,7 @@ export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
// JSON.parse("3") returns 3 (number), parseInt handles both
return {
ok: true,
value:
typeof result.value === "number"
? result.value
: parseInt(result.value, 10),
value: typeof result.value === "number" ? result.value : parseInt(result.value, 10),
};
}
@@ -374,14 +357,10 @@ export function ffiGetHistoricalQuery(
offset: number,
): Result<string | null> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_historical_query(
handle,
BigInt(offset),
);
const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
const result = parseResult<string | null>(resultPtr);
if (!result.ok) return result;
if (result.value === null || result.value === "null")
return { ok: true, value: null };
if (result.value === null || result.value === "null") return { ok: true, value: null };
return result as Result<string>;
}
@@ -431,15 +410,9 @@ export function ffiLiveGrep(
/**
* Multi-pattern grep - Aho-Corasick multi-needle search.
*/
export function ffiMultiGrep(
handle: NativeHandle,
optsJson: string,
): Result<unknown> {
export function ffiMultiGrep(handle: NativeHandle, optsJson: string): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_multi_grep(
handle,
ptr(encodeString(optsJson)),
);
const resultPtr = library.symbols.fff_multi_grep(handle, ptr(encodeString(optsJson)));
return parseResult<unknown>(resultPtr);
}
+1 -1
View File
@@ -1,5 +1,5 @@
[toolchain]
channel = "nightly-2026-02-10"
channel = "nightly-2026-03-14"
components = [
"clippy-preview",
"rustfmt-preview",