Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 1b2e4f20f8 chore: Update docs for - perf: Packed byte layout for file item (reduce ram usage)
docs / docs (push) Has been cancelled
2026-04-06 12:17:37 -07:00
Dmitriy Kovalenko 1b1aeed72f perf: Packed byte layout for file item (reduce ram usage) 2026-04-06 11:03:59 -07:00
22 changed files with 321 additions and 232 deletions
Generated
+6 -6
View File
@@ -629,7 +629,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fff-c"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"fff-query-parser",
"fff-search",
@@ -642,7 +642,7 @@ dependencies = [
[[package]]
name = "fff-grep"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"bstr",
"memchr",
@@ -650,7 +650,7 @@ dependencies = [
[[package]]
name = "fff-mcp"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"clap",
"fff-query-parser",
@@ -667,7 +667,7 @@ dependencies = [
[[package]]
name = "fff-nvim"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"ahash",
"blake3",
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "fff-query-parser"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"criterion",
"zlob",
@@ -708,7 +708,7 @@ dependencies = [
[[package]]
name = "fff-search"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"ahash",
"aho-corasick",
+10 -10
View File
@@ -79,16 +79,16 @@ pub struct FffFileItem {
impl From<&FileItem> for FffFileItem {
fn from(item: &FileItem) -> Self {
FffFileItem {
path: cstring_new(&item.path.to_string_lossy()),
relative_path: cstring_new(&item.relative_path),
file_name: cstring_new(&item.file_name),
path: cstring_new(item.path_str()),
relative_path: cstring_new(item.relative_path()),
file_name: cstring_new(item.file_name()),
git_status: cstring_new(format_git_status(item.git_status)),
size: item.size,
modified: item.modified,
access_frecency_score: item.access_frecency_score as i64,
modification_frecency_score: item.modification_frecency_score as i64,
total_frecency_score: item.total_frecency_score as i64,
is_binary: item.is_binary,
total_frecency_score: item.total_frecency_score() as i64,
is_binary: item.is_binary(),
}
}
}
@@ -312,9 +312,9 @@ impl FffGrepMatch {
};
FffGrepMatch {
path: cstring_new(&file.path.to_string_lossy()),
relative_path: cstring_new(&file.relative_path),
file_name: cstring_new(&file.file_name),
path: cstring_new(file.path_str()),
relative_path: cstring_new(file.relative_path()),
file_name: cstring_new(file.file_name()),
git_status: cstring_new(format_git_status(file.git_status)),
line_content: cstring_new(&m.line_content),
match_ranges,
@@ -322,7 +322,7 @@ impl FffGrepMatch {
context_after,
size: file.size,
modified: file.modified,
total_frecency_score: file.total_frecency_score as i64,
total_frecency_score: file.total_frecency_score() as i64,
access_frecency_score: file.access_frecency_score as i64,
modification_frecency_score: file.modification_frecency_score as i64,
line_number: m.line_number,
@@ -333,7 +333,7 @@ impl FffGrepMatch {
context_after_count,
fuzzy_score,
has_fuzzy_score,
is_binary: file.is_binary,
is_binary: file.is_binary(),
is_definition: m.is_definition,
}
}
+2 -2
View File
@@ -333,9 +333,9 @@ fn handle_debounced_events(
debug!(
"on_create_or_modify({:?}) -> Some({})",
path,
file.path.display()
file.path_str()
);
files_to_update.push(file.path.clone());
files_to_update.push(PathBuf::from(file.path_str()));
}
None => {
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
+32 -32
View File
@@ -147,7 +147,7 @@ impl FileSync {
/// Find file index by path using binary search on the sorted base portion.
#[inline]
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
self.files[..self.base_count].binary_search_by(|f| f.path.as_path().cmp(path))
self.files[..self.base_count].binary_search_by(|f| f.as_path().cmp(path))
}
/// Find a file in the overflow portion by path (linear scan).
@@ -155,7 +155,7 @@ impl FileSync {
fn find_overflow_index(&self, path: &Path) -> Option<usize> {
self.files[self.base_count..]
.iter()
.position(|f| f.path == path)
.position(|f| f.as_path() == path)
.map(|pos| self.base_count + pos)
}
@@ -199,7 +199,7 @@ impl FileSync {
/// Insert a file in sorted order (by path).
/// Returns true if inserted, false if file already exists.
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
match self.find_file_index(&file.path) {
match self.find_file_index(file.as_path()) {
Ok(_) => false, // File already exists
Err(position) => {
self.insert_file(position, file);
@@ -227,12 +227,6 @@ impl FileItem {
.to_string_lossy()
.into_owned();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let (size, modified) = match metadata {
Some(metadata) => {
let size = metadata.len();
@@ -251,10 +245,17 @@ impl FileItem {
// Files not caught here are detected when content is first loaded.
let is_binary = is_known_binary_extension(&path);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind(std::path::MAIN_SEPARATOR)
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
Self::new_raw(
path,
relative_path,
name,
path_string,
relative_start,
filename_start,
size,
modified,
git_status,
@@ -267,10 +268,9 @@ impl FileItem {
tracker: &FrecencyTracker,
mode: FFFMode,
) -> Result<(), Error> {
self.access_frecency_score = tracker.get_access_score(&self.path, mode) as i32;
self.access_frecency_score = tracker.get_access_score(self.as_path(), mode) as i16;
self.modification_frecency_score =
tracker.get_modification_score(self.modified, self.git_status, mode) as i32;
self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score;
tracker.get_modification_score(self.modified, self.git_status, mode) as i16;
Ok(())
}
@@ -499,7 +499,7 @@ impl FilePicker {
// Apply git status synchronously.
if let Ok(Some(git_cache)) = walk.git_handle.join() {
for file in self.sync_data.files.iter_mut() {
file.git_status = git_cache.lookup_status(&file.path);
file.git_status = git_cache.lookup_status(file.as_path());
}
}
@@ -733,7 +733,7 @@ impl FilePicker {
/// Add a file to the picker's files in sorted order (used by background watcher)
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
let path = file.path.clone();
let path = PathBuf::from(file.path_str());
if self.sync_data.insert_file_sorted(file) {
// File was inserted, look it up
@@ -764,9 +764,9 @@ impl FilePicker {
if let Ok(pos) = self.sync_data.find_file_index(path) {
let file = self.sync_data.get_file_mut(pos)?;
if file.is_deleted {
if file.is_deleted() {
// Resurrect tombstoned file.
file.is_deleted = false;
file.set_deleted(false);
debug!(
"on_create_or_modify: resurrected tombstoned file at index {}",
pos
@@ -856,7 +856,7 @@ impl FilePicker {
match self.sync_data.find_file_index(path) {
Ok(index) => {
let file = &mut self.sync_data.files[index];
file.is_deleted = true;
file.set_deleted(true);
file.invalidate_mmap(&self.cache_budget);
if let Some(ref overlay) = self.bigram_overlay {
overlay.write().delete_file(index);
@@ -885,7 +885,7 @@ impl FilePicker {
let dir_path = dir.as_ref();
// Use the safe retain_files method which maintains both indices
self.sync_data
.retain_files(|file| !file.path.starts_with(dir_path))
.retain_files(|file| !file.as_path().starts_with(dir_path))
}
/// Use this to prevent any substantial background threads from acquiring the locks
@@ -932,7 +932,7 @@ impl FilePicker {
let mode = self.mode;
BACKGROUND_THREAD_POOL.install(|| {
self.sync_data.files.par_iter_mut().for_each(|file| {
file.git_status = git_cache.lookup_status(&file.path);
file.git_status = git_cache.lookup_status(file.as_path());
if let Some(frecency) = frecency_ref {
let _ = file.update_frecency_scores(frecency, mode);
}
@@ -1155,7 +1155,7 @@ fn spawn_scan_and_watcher(
{
for &idx in &content_binary {
if let Some(file) = picker.sync_data.get_file_mut(idx) {
file.is_binary = true;
file.set_binary(true);
}
}
@@ -1203,13 +1203,13 @@ pub fn warmup_mmaps(files: &[FileItem], budget: &ContentCacheBudget) {
// they naturally sink past the partition boundary.
if all.len() > max_files {
all.select_nth_unstable_by(max_files, |a, b| {
let a_ok = !a.is_binary && a.size > 0;
let b_ok = !b.is_binary && b.size > 0;
let a_ok = !a.is_binary() && a.size > 0;
let b_ok = !b.is_binary() && b.size > 0;
match (a_ok, b_ok) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => std::cmp::Ordering::Equal,
(true, true) => b.total_frecency_score.cmp(&a.total_frecency_score),
(true, true) => b.total_frecency_score().cmp(&a.total_frecency_score()),
}
});
}
@@ -1225,7 +1225,7 @@ pub fn warmup_mmaps(files: &[FileItem], budget: &ContentCacheBudget) {
return;
}
if file.is_binary || file.size == 0 || file.size > max_file_size {
if file.is_binary() || file.size == 0 || file.size > max_file_size {
return;
}
@@ -1265,7 +1265,7 @@ pub fn build_bigram_index(
BACKGROUND_THREAD_POOL.install(|| {
files.par_iter().enumerate().for_each(|(i, file)| {
if file.is_binary || file.size == 0 || file.size > max_file_size {
if file.is_binary() || file.size == 0 || file.size > max_file_size {
return;
}
// Use cached content if available (no extra memory).
@@ -1279,7 +1279,7 @@ pub fn build_bigram_index(
}
data = Some(cached);
owned = None;
} else if let Ok(read_data) = std::fs::read(&file.path) {
} else if let Ok(read_data) = std::fs::read(file.as_path()) {
if detect_binary_content(&read_data) {
content_binary.lock().unwrap().push(i);
return;
@@ -1389,7 +1389,7 @@ pub fn scan_files(base_path: &Path) -> Vec<FileItem> {
});
let mut files = files.into_inner();
files.sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
files.sort_unstable_by(|a, b| a.path_str().cmp(b.path_str()));
files
}
@@ -1522,7 +1522,7 @@ fn walk_filesystem(
drop(frecency);
BACKGROUND_THREAD_POOL.install(|| {
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
files.par_sort_unstable_by(|a, b| a.path_str().cmp(b.path_str()));
});
let total_time = scan_start.elapsed();
@@ -1567,7 +1567,7 @@ fn apply_git_status(
BACKGROUND_THREAD_POOL.install(|| {
picker.sync_data.files.par_iter_mut().for_each(|file| {
file.git_status = git_cache.lookup_status(&file.path);
file.git_status = git_cache.lookup_status(file.as_path());
if let Some(frecency) = frecency_ref {
let _ = file.update_frecency_scores(frecency, mode);
}
+28 -37
View File
@@ -1287,17 +1287,17 @@ fn prepare_files_to_search<'a>(
let prefiltered: Vec<&FileItem> = if constraints.is_empty() {
files
.iter()
.filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
.filter(|f| !f.is_binary() && f.size > 0 && f.size <= options.max_file_size)
.collect()
} else {
match apply_constraints(files, constraints) {
Some(constrained) => constrained
.into_iter()
.filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
.filter(|f| !f.is_binary() && f.size > 0 && f.size <= options.max_file_size)
.collect(),
None => files
.iter()
.filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
.filter(|f| !f.is_binary() && f.size > 0 && f.size <= options.max_file_size)
.collect(),
}
};
@@ -1310,12 +1310,12 @@ fn prepare_files_to_search<'a>(
// skipping the O(n log n) sort saves ~200ms per query.
let needs_sort = sorted_files
.iter()
.any(|f| f.total_frecency_score != 0 || f.modified != 0);
.any(|f| f.total_frecency_score() != 0 || f.modified != 0);
if needs_sort {
sort_with_buffer(&mut sorted_files, |a, b| {
b.total_frecency_score
.cmp(&a.total_frecency_score)
b.total_frecency_score()
.cmp(&a.total_frecency_score())
.then(b.modified.cmp(&a.modified))
});
}
@@ -1824,7 +1824,7 @@ pub fn grep_search<'a>(
let file_idx = base + bit;
if file_idx < files.len() {
let f = unsafe { files.get_unchecked(file_idx) };
if !f.is_binary && f.size <= options.max_file_size {
if !f.is_binary() && f.size <= options.max_file_size {
result.push(f);
}
}
@@ -1835,12 +1835,12 @@ pub fn grep_search<'a>(
let total_searchable = files.len();
let needs_sort = result
.iter()
.any(|f| f.total_frecency_score != 0 || f.modified != 0);
.any(|f| f.total_frecency_score() != 0 || f.modified != 0);
if needs_sort {
sort_with_buffer(&mut result, |a, b| {
b.total_frecency_score
.cmp(&a.total_frecency_score)
b.total_frecency_score()
.cmp(&a.total_frecency_score())
.then(b.modified.cmp(&a.modified))
});
}
@@ -2111,33 +2111,24 @@ mod tests {
let meta3 = std::fs::metadata(&file3_path).unwrap();
let files = vec![
FileItem::new_raw(
file1_path,
"grep.rs".to_string(),
"grep.rs".to_string(),
meta1.len(),
0,
None,
false,
),
FileItem::new_raw(
file2_path,
"matcher.rs".to_string(),
"matcher.rs".to_string(),
meta2.len(),
0,
None,
false,
),
FileItem::new_raw(
file3_path,
"other.rs".to_string(),
"other.rs".to_string(),
meta3.len(),
0,
None,
false,
),
{
let p = file1_path.to_string_lossy().into_owned();
let rs = (p.len() - "grep.rs".len()) as u16;
let fs = rs;
FileItem::new_raw(p, rs, fs, meta1.len(), 0, None, false)
},
{
let p = file2_path.to_string_lossy().into_owned();
let rs = (p.len() - "matcher.rs".len()) as u16;
let fs = rs;
FileItem::new_raw(p, rs, fs, meta2.len(), 0, None, false)
},
{
let p = file3_path.to_string_lossy().into_owned();
let rs = (p.len() - "other.rs".len()) as u16;
let fs = rs;
FileItem::new_raw(p, rs, fs, meta3.len(), 0, None, false)
},
];
let options = super::GrepSearchOptions {
+1 -1
View File
@@ -86,7 +86,7 @@
//! );
//!
//! assert!(results.total_matched > 0);
//! assert!(results.items.first().unwrap().path.ends_with("lib.rs"));
//! assert!(results.items.first().unwrap().as_path().ends_with("lib.rs"));
//!
//! let _ = std::fs::remove_dir_all(&tmp);
//! # Ok::<(), Box<dyn std::error::Error>>(())
+26 -36
View File
@@ -36,8 +36,8 @@ impl<'a> FileItems<'a> {
fn relative_paths(&self) -> Vec<&'a str> {
match self {
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
FileItems::All(s) => s.iter().map(|f| f.relative_path()).collect(),
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path()).collect(),
}
}
@@ -175,12 +175,12 @@ pub fn match_and_score_files<'a>(
for (i, path_match) in path_matches.iter().enumerate() {
let file = working_files.index(path_match.index as usize);
let filename_start = (file.relative_path.len() - file.file_name.len()) as u16;
let filename_start = file.filename_offset_in_relative() as u16;
let match_start_approx = path_match.match_end_col.saturating_sub(main_needle_len - 1);
if match_start_approx < filename_start {
fallback_indices.push(i as u32);
fallback_filenames.push(file.file_name.as_str());
fallback_filenames.push(file.file_name());
}
}
@@ -212,7 +212,7 @@ pub fn match_and_score_files<'a>(
let file = working_files.index(file_idx);
let base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score) / 100;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score()) / 100;
// Give modified/dirty files a 15% boost to make them appear higher in results
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
@@ -222,9 +222,9 @@ pub fn match_and_score_files<'a>(
};
let distance_penalty =
calculate_distance_penalty(context.current_file, &file.relative_path);
calculate_distance_penalty(context.current_file, file.relative_path());
let filename_start = (file.relative_path.len() - file.file_name.len()) as u16;
let filename_start = file.filename_offset_in_relative() as u16;
let match_start_approx = path_match.match_end_col.saturating_sub(main_needle_len - 1);
let end_col_filename_match = match_start_approx >= filename_start;
@@ -246,8 +246,8 @@ pub fn match_and_score_files<'a>(
let is_filename_match = end_col_filename_match || simd_filename_match.is_some();
let is_exact_filename = simd_filename_match.is_some_and(|m| m.exact)
|| (end_col_filename_match
&& main_needle_len as usize == file.file_name.len()
&& main_needle.eq_ignore_ascii_case(file.file_name.as_bytes()));
&& main_needle_len as usize == file.file_name().len()
&& main_needle.eq_ignore_ascii_case(file.file_name().as_bytes()));
let mut has_special_filename_bonus = false;
let filename_bonus = if is_exact_filename {
@@ -266,7 +266,7 @@ pub fn match_and_score_files<'a>(
} else {
max_bonus
}
} else if !is_filename_match && is_special_entry_point_file(&file.file_name) {
} else if !is_filename_match && is_special_entry_point_file(file.file_name()) {
// 5% bonus for special file but not as much as file name to avoid situations
// when you have /user_service/server.rs and /user_service/server/mod.rs
has_special_filename_bonus = true;
@@ -280,7 +280,7 @@ pub fn match_and_score_files<'a>(
let last_same_query_match = context
.last_same_query_match
.as_ref()
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
.filter(|m| m.file_path.as_os_str() == file.as_path().as_os_str());
match last_same_query_match {
// if we request a combo match without a boost we have to render it anyway
@@ -367,8 +367,8 @@ pub(crate) fn score_filtered_by_frecency<'a>(
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let score_file = |file: &'a FileItem| {
let total_frecency_score =
file.access_frecency_score + file.modification_frecency_score.saturating_mul(4);
let total_frecency_score = file.access_frecency_score as i32
+ (file.modification_frecency_score as i32).saturating_mul(4);
// Give modified/dirty files a boost even in frecency-only mode
let git_status_boost = if file.git_status.is_some_and(is_modified_status) {
@@ -417,7 +417,7 @@ fn calculate_current_file_penalty(
let mut penalty = 0i32;
if let Some(current) = context.current_file
&& file.relative_path.as_str() == current
&& file.relative_path() == current
{
penalty -= match file.git_status {
Some(status) if is_modified_status(status) => base_score / 2,
@@ -500,14 +500,13 @@ 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) {
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
let file = FileItem::new_raw(
PathBuf::from(path),
path.to_string(),
file_name,
0,
filename_start,
0,
modified,
None,
@@ -581,9 +580,9 @@ mod tests {
assert_eq!(scores[2].total, 200, "Third should be third highest");
// Verify the files match
assert_eq!(items[0].relative_path, "file4.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file2.rs");
assert_eq!(items[0].relative_path(), "file4.rs");
assert_eq!(items[1].relative_path(), "file6.rs");
assert_eq!(items[2].relative_path(), "file2.rs");
}
#[test]
@@ -677,9 +676,9 @@ mod tests {
assert_eq!(scores[0].total, 200);
assert_eq!(scores[1].total, 100);
assert_eq!(scores[2].total, 50);
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file1.rs");
assert_eq!(items[2].relative_path, "file3.rs");
assert_eq!(items[0].relative_path(), "file2.rs");
assert_eq!(items[1].relative_path(), "file1.rs");
assert_eq!(items[2].relative_path(), "file3.rs");
}
}
@@ -688,19 +687,10 @@ mod filename_bonus_tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
use std::path::PathBuf;
fn make_file(path: &str) -> FileItem {
let file_name = path.split('/').next_back().unwrap_or(path).to_string();
FileItem::new_raw(
PathBuf::from(path),
path.to_string(),
file_name,
0,
0,
None,
false,
)
let filename_start = path.rfind('/').map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(path.to_string(), 0, filename_start, 0, 0, None, false)
}
fn search(files: &[FileItem], query: &str) -> Vec<(String, Score)> {
@@ -724,7 +714,7 @@ mod filename_bonus_tests {
items
.iter()
.zip(scores.iter())
.map(|(f, s)| (f.relative_path.clone(), s.clone()))
.map(|(f, s)| (f.relative_path().to_string(), s.clone()))
.collect()
}
+118 -29
View File
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
@@ -33,7 +33,17 @@ impl std::ops::Deref for FileContent {
}
}
pub struct FileItemFlags;
impl FileItemFlags {
pub const BINARY: u8 = 1 << 0;
/// Tombstone — file was deleted but index slot is preserved so
/// bigram indices for other files stay valid.
pub const DELETED: u8 = 1 << 1;
}
/// A single indexed file with metadata, frecency scores, and lazy content cache.
/// Occupies ~100 bytes + file path per file
///
/// 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
@@ -43,19 +53,26 @@ impl std::ops::Deref for FileContent {
/// Each file is only searched by one rayon worker at a time via `par_iter`.
#[derive(Debug)]
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub file_name: String,
/// File size in bytes
pub size: u64,
/// Modification time in UNIX timestamp
pub modified: u64,
pub access_frecency_score: i32,
pub modification_frecency_score: i32,
pub total_frecency_score: i32,
/// Frecency access score
pub access_frecency_score: i16,
/// Frecency modification score
pub modification_frecency_score: i16,
/// The file's git status
pub git_status: Option<git2::Status>,
pub is_binary: bool,
/// Tombstone flag — file was deleted but index slot is preserved so
/// bigram indices for other files stay valid.
pub is_deleted: bool,
/// Absolute path stored as a plain String. We never use path components —
/// only slicing, comparison, and passing to fs/DB APIs via `as_path()`.
path: String,
/// Byte offset where the relative path begins (after base_path + separator).
relative_start: u16,
/// Byte offset where the filename begins (after last separator).
filename_start: u16,
/// Packed boolean flags — see `FileItemFlags`.
flags: u8,
/// Lazily-initialized file contents for grep.
/// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads.
content: OnceLock<FileContent>,
@@ -65,16 +82,14 @@ impl Clone for FileItem {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
relative_path: self.relative_path.clone(),
file_name: self.file_name.clone(),
relative_start: self.relative_start,
filename_start: self.filename_start,
size: self.size,
modified: self.modified,
access_frecency_score: self.access_frecency_score,
modification_frecency_score: self.modification_frecency_score,
total_frecency_score: self.total_frecency_score,
git_status: self.git_status,
is_binary: self.is_binary,
is_deleted: self.is_deleted,
flags: self.flags,
// Don't clone the content — the clone lazily re-creates it on demand
content: OnceLock::new(),
}
@@ -110,30 +125,97 @@ impl std::ops::Deref for FileContentRef<'_> {
impl FileItem {
/// Create a new `FileItem` with all fields specified and an empty (not yet loaded) mmap.
pub fn new_raw(
path: PathBuf,
relative_path: String,
file_name: String,
path: String,
relative_start: u16,
filename_start: u16,
size: u64,
modified: u64,
git_status: Option<git2::Status>,
is_binary: bool,
) -> Self {
let mut flags = 0u8;
if is_binary {
flags |= FileItemFlags::BINARY;
}
Self {
path,
relative_path,
file_name,
relative_start,
filename_start,
size,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status,
is_binary,
is_deleted: false,
flags,
content: OnceLock::new(),
}
}
/// The full absolute path as a string slice.
#[inline]
pub fn path_str(&self) -> &str {
&self.path
}
/// The full absolute path as a `&Path` (zero-cost on Unix).
#[inline]
pub fn as_path(&self) -> &Path {
Path::new(&self.path)
}
/// The relative path (from the base directory).
#[inline]
pub fn relative_path(&self) -> &str {
&self.path[self.relative_start as usize..]
}
/// Just the filename component.
#[inline]
pub fn file_name(&self) -> &str {
&self.path[self.filename_start as usize..]
}
/// Byte offset of the filename within the relative path.
/// Equivalent to `relative_path().len() - file_name().len()`.
#[inline]
pub fn filename_offset_in_relative(&self) -> usize {
(self.filename_start - self.relative_start) as usize
}
#[inline]
pub fn total_frecency_score(&self) -> i32 {
self.access_frecency_score as i32 + self.modification_frecency_score as i32
}
#[inline]
pub fn is_binary(&self) -> bool {
self.flags & FileItemFlags::BINARY != 0
}
#[inline]
pub fn set_binary(&mut self, val: bool) {
if val {
self.flags |= FileItemFlags::BINARY;
} else {
self.flags &= !FileItemFlags::BINARY;
}
}
#[inline]
pub fn is_deleted(&self) -> bool {
self.flags & FileItemFlags::DELETED != 0
}
#[inline]
pub fn set_deleted(&mut self, val: bool) {
if val {
self.flags |= FileItemFlags::DELETED;
} else {
self.flags &= !FileItemFlags::DELETED;
}
}
/// 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.
@@ -175,7 +257,7 @@ impl FileItem {
return None;
}
let content = load_file_content(&self.path, self.size)?;
let content = load_file_content(self.as_path(), self.size)?;
let result = self.content.get_or_init(|| content);
// Bump counters. Slight over-count under races is fine — the budget
@@ -203,12 +285,12 @@ impl FileItem {
// get_content returned None — either ineligible or over budget.
let max_file_size = budget.max_file_size;
if self.is_binary || self.size == 0 || self.size > max_file_size {
if self.is_binary() || self.size == 0 || self.size > max_file_size {
return None;
}
// Over budget: create a temporary mmap that is unmapped on drop.
let content = load_file_content(&self.path, self.size)?;
let content = load_file_content(self.as_path(), self.size)?;
Some(FileContentRef::Temp(content))
}
}
@@ -248,15 +330,22 @@ fn load_file_content(path: &Path, size: u64) -> Option<FileContent> {
}
}
impl AsRef<Path> for FileItem {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.path)
}
}
impl Constrainable for FileItem {
#[inline]
fn relative_path(&self) -> &str {
&self.relative_path
FileItem::relative_path(self)
}
#[inline]
fn file_name(&self) -> &str {
&self.file_name
FileItem::file_name(self)
}
#[inline]
@@ -276,7 +276,7 @@ fn new_file_findable_after_add() {
let overflow = picker.get_overflow_files();
assert_eq!(overflow.len(), 1, "Should have 1 overflow file");
assert!(
overflow[0].path.ends_with("newcomer.txt"),
overflow[0].as_path().ends_with("newcomer.txt"),
"Overflow file should be newcomer.txt"
);
}
+17 -21
View File
@@ -342,15 +342,11 @@ fn plain_text_binary_files_are_skipped() {
// In production, binary detection by content happens during bigram build
// and sets is_binary = true. Simulate that here with new_raw.
let meta = fs::metadata(&binary_path).unwrap();
let binary_file = FileItem::new_raw(
binary_path,
"binary.dat".to_string(),
"binary.dat".to_string(),
meta.len(),
0,
None,
true,
);
let binary_file = {
let p = binary_path.to_string_lossy().into_owned();
let rs = (p.len() - "binary.dat".len()) as u16;
FileItem::new_raw(p, rs, rs, meta.len(), 0, None, true)
};
let text_file = create_file(tmp.path(), "text.txt", "match this text\n");
@@ -369,7 +365,7 @@ fn plain_text_binary_files_are_skipped() {
// Only the text file should be searched, not the binary one
assert_eq!(result.files.len(), 1);
assert!(result.files[0].relative_path.contains("text.txt"));
assert!(result.files[0].relative_path().contains("text.txt"));
}
#[test]
@@ -1028,9 +1024,9 @@ fn grep_with_extension_constraint() {
// Should only search .rs files
for file in &result.files {
assert!(
file.relative_path.ends_with(".rs"),
file.relative_path().ends_with(".rs"),
"should only match .rs files, got: {}",
file.relative_path
file.relative_path()
);
}
assert!(
@@ -1185,7 +1181,7 @@ fn grep_with_path_constraint() {
);
assert_eq!(result.matches.len(), 1);
assert!(result.files[0].relative_path.starts_with("src/"));
assert!(result.files[0].relative_path().starts_with("src/"));
}
// ── Negated constraint tests ───────────────────────────────────────────
@@ -1218,9 +1214,9 @@ fn grep_with_negated_extension_constraint() {
result.matches.len()
);
assert!(
result.files[0].relative_path.ends_with(".ts"),
result.files[0].relative_path().ends_with(".ts"),
"should only match .ts file, got: {}",
result.files[0].relative_path
result.files[0].relative_path()
);
}
@@ -1252,9 +1248,9 @@ fn grep_with_negated_path_constraint() {
result.matches.len()
);
assert!(
result.files[0].relative_path.starts_with("tests/"),
result.files[0].relative_path().starts_with("tests/"),
"should only match tests/ file, got: {}",
result.files[0].relative_path
result.files[0].relative_path()
);
}
@@ -1288,9 +1284,9 @@ fn grep_with_negated_text_constraint() {
);
for file in &result.files {
assert!(
!file.relative_path.contains("test"),
!file.relative_path().contains("test"),
"should not match files with 'test' in path, got: {}",
file.relative_path
file.relative_path()
);
}
}
@@ -1666,9 +1662,9 @@ fn fuzzy_with_extension_constraint() {
// Should only search .rs files
for file in &result.files {
assert!(
file.relative_path.ends_with(".rs"),
file.relative_path().ends_with(".rs"),
"should only match .rs files, got: {}",
file.relative_path
file.relative_path()
);
}
}
+11 -11
View File
@@ -252,10 +252,10 @@ impl GrepFormatter<'_> {
let mut content_first_file = "";
for fm in &file_preview {
if content_first_file.is_empty() {
content_first_file = &fm.file.relative_path;
content_first_file = fm.file.relative_path();
}
if content_def_file.is_empty() && fm.is_definition {
content_def_file = &fm.file.relative_path;
content_def_file = fm.file.relative_path();
}
}
@@ -310,8 +310,8 @@ impl GrepFormatter<'_> {
let file = files[m.file_index];
let mut match_lines: Vec<String> = Vec::new();
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
if file.relative_path() != current_file {
current_file = file.relative_path();
match_lines.push(current_file.to_string());
}
@@ -362,14 +362,14 @@ impl GrepFormatter<'_> {
&& !show_context
&& m.is_definition
&& !m.context_after.is_empty()
&& !def_expanded_files.contains(file.relative_path.as_str())
&& !def_expanded_files.contains(file.relative_path())
{
let expand_limit = if def_expanded_files.is_empty() {
MAX_DEF_EXPAND_FIRST
} else {
MAX_DEF_EXPAND
};
def_expanded_files.insert(file.relative_path.as_str());
def_expanded_files.insert(file.relative_path());
let start_line = m.line_number + 1;
for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
if ctx.trim().is_empty() {
@@ -419,10 +419,10 @@ fn format_files_with_matches(
let mut first_file = "";
for fm in &file_map {
if first_file.is_empty() {
first_file = &fm.file.relative_path;
first_file = fm.file.relative_path();
}
if first_def_file.is_empty() && fm.is_definition {
first_def_file = &fm.file.relative_path;
first_def_file = fm.file.relative_path();
}
}
let suggest_path = if !first_def_file.is_empty() {
@@ -456,7 +456,7 @@ fn format_files_with_matches(
let def_tag = if is_def { " [def]" } else { "" };
lines.push(format!(
"{}{}{}",
fm.file.relative_path,
fm.file.relative_path(),
def_tag,
size_tag(fm.file.size)
));
@@ -526,7 +526,7 @@ fn format_count(
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
let mut order: Vec<&str> = Vec::new();
for m in items {
let path = files[m.file_index].relative_path.as_str();
let path = files[m.file_index].relative_path();
let count = counts.entry(path).or_insert_with(|| {
order.push(path);
0
@@ -550,7 +550,7 @@ fn collect_file_preview<'a>(items: &[GrepMatch], files: &[&'a FileItem]) -> Vec<
let mut seen = std::collections::HashSet::new();
for m in items {
let file = files[m.file_index];
if seen.insert(&file.relative_path) {
if seen.insert(file.relative_path()) {
file_preview.push(FileMeta {
file,
line_number: m.line_number,
+10 -7
View File
@@ -313,8 +313,8 @@ impl FffServer {
let mut current_file = "";
for m in fuzzy_result.matches.iter().take(3) {
let file = fuzzy_result.files[m.file_index];
if file.relative_path.as_str() != current_file {
current_file = &file.relative_path;
if file.relative_path() != current_file {
current_file = file.relative_path();
lines.push(current_file.to_string());
}
lines.push(format!(" {}: {}", m.line_number, m.line_content));
@@ -349,7 +349,7 @@ impl FffServer {
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
top.relative_path()
))]));
}
}
@@ -470,11 +470,14 @@ impl FffServer {
if page_offset == 0 {
if is_exact_match {
lines.push(format!("→ Read {} (exact match!)", top_item.relative_path));
lines.push(format!(
"→ Read {} (exact match!)",
top_item.relative_path()
));
} else if scores.len() < 2 || scores[0].total > scores[1].total.saturating_mul(2) {
lines.push(format!(
"→ Read {} (best match — Read this file directly)",
top_item.relative_path
top_item.relative_path()
));
}
}
@@ -489,8 +492,8 @@ impl FffServer {
for item in &items {
lines.push(format!(
"{}{}",
item.relative_path,
file_suffix(item.git_status, item.total_frecency_score)
item.relative_path(),
file_suffix(item.git_status, item.total_frecency_score())
));
}
+2 -2
View File
@@ -75,7 +75,7 @@ fn build_bigram(files: &mut [fff::FileItem]) -> fff::BigramFilter {
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
for &i in &binary_indices {
files[i].is_binary = true;
files[i].set_binary(true);
}
index
@@ -125,7 +125,7 @@ fn main() {
eprint!("[1/3] Scanning files... ");
let t = Instant::now();
let mut files = fff::scan_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"{} files in {:.2}s ({} non-binary)",
files.len(),
+9 -4
View File
@@ -35,12 +35,17 @@ fn main() {
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
entry.metadata().ok().map_or(0, |m| m.len()),
0,
None,
+11 -6
View File
@@ -31,14 +31,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -110,7 +115,7 @@ fn run_fuzzy_query(files: &[FileItem], query: &str, label: &str) {
if m.file_index != current_file_idx {
current_file_idx = m.file_index;
let file = &result.files[m.file_index];
eprintln!("\n ┌─ {}", file.relative_path);
eprintln!("\n ┌─ {}", file.relative_path());
}
// Truncate long lines for display
@@ -181,7 +186,7 @@ fn main() {
eprintln!("Loading files...");
let load_start = Instant::now();
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(
"Loaded {} files ({} non-binary) in {:.2}s\n",
files.len(),
+11 -6
View File
@@ -37,14 +37,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -189,7 +194,7 @@ fn build_bigram(files: &mut [FileItem]) -> BigramFilter {
let (index, binary_indices) = fff::build_bigram_index(files, &budget);
for &i in &binary_indices {
files[i].is_binary = true;
files[i].set_binary(true);
}
index
@@ -260,7 +265,7 @@ fn main() {
let load_start = Instant::now();
let mut files = load_files(&canonical);
let load_time = load_start.elapsed();
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
let large_files = files.iter().filter(|f| f.size > 10 * 1024 * 1024).count();
eprintln!(
" Loaded {} files in {:.2}s ({} non-binary, {} >10MB skipped)\n",
+10 -5
View File
@@ -48,14 +48,19 @@ fn load_files(base_path: &Path) -> Vec<FileItem> {
let path = entry.path().to_path_buf();
let relative = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
let size = entry.metadata().ok().map_or(0, |m| m.len());
let is_binary = detect_binary(&path, size);
let path_string = path.to_string_lossy().into_owned();
let relative_start = (path_string.len() - relative_path.len()) as u16;
let filename_start = path_string
.rfind('/')
.map(|i| i + 1)
.unwrap_or(relative_start as usize) as u16;
files.push(FileItem::new_raw(
path,
relative_path,
file_name,
path_string,
relative_start,
filename_start,
size,
0,
None,
@@ -365,7 +370,7 @@ fn main() {
eprintln!("[1/5] Indexing files...");
let files = load_files(&canonical);
let non_binary = files.iter().filter(|f| !f.is_binary).count();
let non_binary = files.iter().filter(|f| !f.is_binary()).count();
eprintln!(" {} files ({} searchable)\n", files.len(), non_binary);
eprintln!("[2/5] Warming caches (fff mmap + OS page cache)...");
+1 -1
View File
@@ -144,7 +144,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !files.is_empty() {
println!("Sample files:");
for (i, file) in files.iter().take(5).enumerate() {
println!(" {}. {}", i + 1, file.relative_path);
println!(" {}. {}", i + 1, file.relative_path());
}
}
files.len()
+3 -3
View File
@@ -68,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} ({})",
i + 1,
file.relative_path,
file.relative_path(),
format_git_status(file.git_status)
);
}
@@ -110,7 +110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let files = guard.as_ref().unwrap().get_files();
let newest_files = files.iter().rev().take(added.min(3));
for file in newest_files {
println!(" {}", file.relative_path);
println!(" {}", file.relative_path());
}
} else {
let removed = last_count - current_count;
@@ -186,7 +186,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
" {}. {} (score: {})",
i + 1,
file.relative_path,
file.relative_path(),
score.total
);
}
+1 -1
View File
@@ -244,7 +244,7 @@ pub fn fuzzy_search_files(
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)) {
if let Ok(idx) = files.binary_search_by(|f| f.as_path().cmp(&path)) {
let found = SearchResult {
items: vec![&files[idx]],
scores: vec![Score {
+10 -10
View File
@@ -35,9 +35,9 @@ impl IntoLua for LuaPosition {
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("path", item.path.to_string_lossy().to_string())?;
table.set("relative_path", item.relative_path.clone())?;
table.set("name", item.file_name.clone())?;
table.set("path", item.path_str())?;
table.set("relative_path", item.relative_path())?;
table.set("name", item.file_name())?;
table.set("size", item.size)?;
table.set("modified", item.modified)?;
table.set("access_frecency_score", item.access_frecency_score)?;
@@ -45,9 +45,9 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
"modification_frecency_score",
item.modification_frecency_score,
)?;
table.set("total_frecency_score", item.total_frecency_score)?;
table.set("total_frecency_score", item.total_frecency_score())?;
table.set("git_status", format_git_status(item.git_status))?;
table.set("is_binary", item.is_binary)?;
table.set("is_binary", item.is_binary())?;
Ok(LuaValue::Table(table))
}
@@ -122,14 +122,14 @@ impl IntoLua for GrepResultLua<'_> {
// File metadata from the deduplicated files vec
let file = self.inner.files[m.file_index];
item.set("path", file.path.to_string_lossy().to_string())?;
item.set("relative_path", file.relative_path.as_str())?;
item.set("name", file.file_name.as_str())?;
item.set("is_binary", file.is_binary)?;
item.set("path", file.path_str())?;
item.set("relative_path", file.relative_path())?;
item.set("name", file.file_name())?;
item.set("is_binary", file.is_binary())?;
item.set("git_status", format_git_status(file.git_status))?;
item.set("size", file.size)?;
item.set("modified", file.modified)?;
item.set("total_frecency_score", file.total_frecency_score)?;
item.set("total_frecency_score", file.total_frecency_score())?;
item.set("access_frecency_score", file.access_frecency_score)?;
item.set(
"modification_frecency_score",
+1 -1
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 April 03
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 April 06
==============================================================================
Table of Contents *fff.nvim-table-of-contents*