Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions .github/workflows/external-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ env:
# 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
# e2e only needs a working binary, so skip fat LTO (same settings as the `ci`
# profile releases ship). Overriding release keeps artifacts in target/release.
CARGO_PROFILE_RELEASE_LTO: thin
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16

jobs:
lua-tests:
Expand All @@ -32,7 +36,6 @@ jobs:
- os: ubuntu-latest
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
Expand All @@ -49,20 +52,13 @@ jobs:
cache-on-failure: false
cache-key: "v2-lua-e2e"
rustflags: ""
target: ${{ matrix.target || '' }}

- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --no-default-features --features zlob

- name: Copy binary to target/release (Windows)
if: matrix.target
- name: Build Rust binary
shell: bash
run: |
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
run: make build

- name: Verify Windows DLL has no unexpected dependencies
if: matrix.target
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
# Find dumpbin via vswhere (always available on GitHub Actions Windows runners)
Expand All @@ -78,10 +74,6 @@ jobs:
exit 1
}

- name: Build Rust binary
if: ${{ !matrix.target }}
run: make build

- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
signal-hook-registry = "1.4"
zlob = { version = "=1.6.1" }
zlob = { version = "=1.6.2" }

mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.11.0", features = ["match_end_col"] }
Expand Down
225 changes: 176 additions & 49 deletions crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ use crate::types::{
ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
PaginationArgs, Score, ScoringContext, SearchResult,
};
use crate::walk::WalkOutput;
use crate::watch::BackgroundWatcher;
use fff_query_parser::FFFQuery;
use git2::{Repository, Status};
Expand Down Expand Up @@ -2020,30 +2021,38 @@ impl FileSync {
let is_git_repo = git_workdir.is_some();
let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();

let mut walk_output = crate::walk::walk_collect_files(
let WalkOutput {
dirs: mut walked_dirs,
mut pairs,
ignore_rules,
} = crate::walk::walk_collect_files(
base_path,
is_git_repo,
follow_symlinks,
bg_threads,
synced_files_count,
)?;
let ignore_rules = walk_output.ignore_rules.take().map(Arc::new);
let mut pairs = walk_output.pairs;
let ignore_rules = ignore_rules.map(Arc::new);

// Sort by (dir_part, filename). This groups files by their directory
// into contiguous runs so the linear dir-extraction pass below can
// dedupe by comparing only against the previous dir.
// group walked dirs and files with a dir part to the same order
BACKGROUND_THREAD_POOL.install(|| {
pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
// SAFETY: `filename_offset` is always at a character boundary
let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
});
rayon::join(
|| {
pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
// SAFETY: `filename_offset` is always at a character boundary
let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
});
},
|| walked_dirs.par_sort_unstable(),
);
});
walked_dirs.dedup();

let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &mut builder);
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked_dirs, &mut builder);
drop(walked_dirs);

let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
let chunked_paths = builder.finish();
Expand Down Expand Up @@ -2164,49 +2173,91 @@ pub(crate) fn warmup_mmaps(
}

/// This does both thing (yes sorry all the OOP morons)
/// in one go: populates files chunked storage and creates new directories
/// in one go: populates files chunked storage and builds the dir table from
/// `walked_dirs` (every dir the walker visited: sorted, '/'-terminated,
/// deduped), merging file parents in a single lockstep sweep so dirs with no
/// files (empty subtrees, pure ancestors) are indexed and searchable too.
fn populates_dirs_files_chunked_storage<'a>(
pairs: &'a mut [(FileItem, String)],
walked_dirs: &[String],
chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
) -> Vec<DirItem> {
let mut dirs: Vec<DirItem> = Vec::new();
let mut dirs: Vec<DirItem> = Vec::with_capacity(walked_dirs.len() + 1);
let mut dir_iter = walked_dirs.iter().peekable();

// Root-level files sort first and their "" parent is never a walker dir.
if pairs
.first()
.is_some_and(|(f, _)| f.path.filename_offset == 0)
{
push_dir_item(&mut dirs, chunk_storage, "");
}

// Detects contiguous same-dir runs (pairs are sorted by dir) so the
// merge below runs once per directory, not once per file.
let mut prev_dir: &'a str = "";
let mut prev_dir_valid = false;
let mut current_dir_idx: u32 = 0;

for (file, rel) in pairs.iter_mut() {
let rel: &'a str = rel;
let dir_part: &'a str = &rel[..file.path.filename_offset as usize];

if !prev_dir_valid || prev_dir != dir_part {
let dir_string = chunk_storage.add_dir_immediate(dir_part);
if prev_dir != dir_part {
// Flush walked dirs up to and including this file's parent,
// keeping the table sorted for the find_dir_index binary search.
while let Some(dir) = dir_iter.peek()
&& dir.as_str() < dir_part
{
push_dir_item(&mut dirs, chunk_storage, dir);
dir_iter.next();
}

// Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
let last_seg = if dir_part.is_empty() {
0
} else {
let trimmed = dir_part.trim_end_matches(std::path::is_separator);
trimmed
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16
};
match dir_iter.peek() {
Some(dir) if dir.as_str() == dir_part => {
push_dir_item(&mut dirs, chunk_storage, dir);
dir_iter.next();
}
// Parents the walker reported with a non-dir kind
// (e.g. followed symlinks) aren't in the list.
_ => push_dir_item(&mut dirs, chunk_storage, dir_part),
}

dirs.push(DirItem::new(dir_string, last_seg));
current_dir_idx = (dirs.len() - 1) as u32;

prev_dir = dir_part;
prev_dir_valid = true;
}

file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
file.parent_dir_index = current_dir_idx;
}

for dir in dir_iter {
push_dir_item(&mut dirs, chunk_storage, dir);
}

dirs
}

fn push_dir_item(
dirs: &mut Vec<DirItem>,
chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
dir_part: &str,
) {
let dir_string = chunk_storage.add_dir_immediate(dir_part);

// Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
let last_seg = if dir_part.is_empty() {
0
} else {
let trimmed = dir_part.trim_end_matches(std::path::is_separator);
trimmed
.rfind(std::path::is_separator)
.map(|i| i + 1)
.unwrap_or(0) as u16
};

dirs.push(DirItem::new(dir_string, last_seg));
}

/// Fast extension-based binary detection. Avoids opening files during scan.
/// Covers the vast majority of binary files in typical repositories.
#[inline]
Expand Down Expand Up @@ -2341,13 +2392,9 @@ mod tests {
use super::*;

/// The watcher must watch every ancestor directory up to `base_path`,
/// not just the immediate parents of indexed files. Intermediate dirs
/// that contain only subdirectories (no direct files) are NOT in
/// `sync_data.dirs` — yet they must still appear in `extract_watch_dirs`
/// so Create events on new subdirectories below them fire.
///
/// Correctness regression guard for any refactor that replaces the
/// ancestor walk with a direct `sync_data.dirs` iteration.
/// not just the immediate parents of indexed files. The dir table is
/// built from the walker's visited dirs, so pure ancestors (dirs that
/// contain only subdirectories) must be present and emitted exactly once.
#[test]
fn extract_watch_dirs_includes_pure_ancestor_dirs() {
let dir = tempfile::tempdir().unwrap();
Expand All @@ -2361,17 +2408,6 @@ mod tests {
// base/src/components/button.txt (src/components has a file)
// base/src/routes/home.txt (src/routes has a file)
// base/lib/deep/nested/util.txt (lib and lib/deep have no files)
//
// `sync_data.dirs` will only contain:
// src/components/
// src/routes/
// lib/deep/nested/
//
// But the watcher also needs:
// src/ (pure ancestor — no direct files)
// lib/ (pure ancestor)
// lib/deep/ (pure ancestor)
// otherwise new siblings like `src/NewDir/x.txt` are missed.
for rel in [
"src/components/button.txt",
"src/routes/home.txt",
Expand Down Expand Up @@ -2429,6 +2465,97 @@ mod tests {
);
}

/// Regression guard for #725: dirs that are EMPTY at scan time are merged
/// into `sync_data.dirs` so they are searchable and get an inotify watch;
/// files created in them later must be detected.
#[test]
fn for_each_dir_includes_empty_directories() {
let dir = tempfile::tempdir().unwrap();
let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
let base = base_buf.as_path();

// Tree:
// base/init.lua (file directly under base)
// base/commands/ (empty at scan — the #725 repro)
// base/src/main.rs (src is indexed)
// base/src/plugins/extra/ (empty chain under an indexed dir)
std::fs::create_dir_all(base.join("commands")).unwrap();
std::fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
std::fs::write(base.join("init.lua"), b"x").unwrap();
std::fs::write(base.join("src/main.rs"), b"x").unwrap();

let mut picker = FilePicker::new(FilePickerOptions {
base_path: base.to_str().unwrap().into(),
watch: false,
..Default::default()
})
.unwrap();
picker.collect_files().unwrap();

let mut watch_dirs: Vec<PathBuf> = Vec::new();
picker.for_each_dir(|p| {
watch_dirs.push(p.to_path_buf());
std::ops::ControlFlow::Continue(())
});
let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();

for rel in ["commands", "src/plugins", "src/plugins/extra", "src"] {
assert!(
watch_set.contains(&base.join(rel)),
"expected {rel} in watch dirs, got {watch_set:?}",
);
}

// Dirs covered by indexed files must not be duplicated.
assert_eq!(
watch_dirs.len(),
watch_set.len(),
"duplicate watch dir emitted: {watch_dirs:?}",
);
}

#[test]
fn dir_table_merges_walked_dirs_with_file_parents() {
let mut pairs: Vec<(FileItem, String)> = ["src/main.rs", "src/deep/lib.rs", "root.txt"]
.iter()
.map(|p| {
let (item, rel) = FileItem::new(PathBuf::from(p), Path::new(""), None);
(item, rel)
})
.collect();
pairs.sort_by(|(a, pa), (b, pb)| {
pa[..a.path.filename_offset as usize]
.cmp(&pb[..b.path.filename_offset as usize])
.then_with(|| pa.cmp(pb))
});

// Sorted '/'-terminated walker output: file parents + an empty dir +
// a sibling sharing a prefix with a file parent.
let walked: Vec<String> = ["empty/", "src/", "src/deep/", "src/deeper/"]
.iter()
.map(|s| s.to_string())
.collect();

let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked, &mut builder);
let store = builder.finish();
let arena = store.as_arena_ptr();

let table: Vec<String> = dirs.iter().map(|d| d.relative_path(arena)).collect();
// Sorted: "" (root files) first, all walked dirs present exactly once.
assert_eq!(table, ["", "empty/", "src/", "src/deep/", "src/deeper/"]);

// Every file's parent_dir_index points at its own dir entry.
for (file, _) in &pairs {
let dir = &dirs[file.parent_dir_index as usize];
let rel = file.relative_path(arena);
assert!(
rel.starts_with(&dir.relative_path(arena)),
"file {rel} must live under its parent dir",
);
}
}

#[test]
fn common_dir_prefix_len_cases() {
assert_eq!(common_dir_prefix_len("", ""), 0);
Expand Down
2 changes: 2 additions & 0 deletions crates/fff-core/src/walk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub(crate) use ripgrep::walk_collect_files;

pub(crate) struct WalkOutput {
pub(crate) pairs: Vec<(FileItem, String)>,
/// Every non-ignored directory the walk visited, relative, ending with /
pub(crate) dirs: Vec<String>,
pub(crate) ignore_rules: Option<WalkIgnoreRules>,
}

Expand Down
Loading
Loading