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
2 changes: 2 additions & 0 deletions crates/fff-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ required-features = ["zlob"]
default = ["ripgrep"]
# Enable C FFI exports
ffi = []
# Enables POC definition classification for grep result matched lines
definitions = []
# Pure-Rust filesystem walker + glob matcher (ignore + globset crates).
ripgrep = ["dep:ignore", "dep:globset", "fff-query-parser/ripgrep"]
# Call mi_collect(true) after large allocator churn (bigram build).
Expand Down
65 changes: 65 additions & 0 deletions crates/fff-core/src/background_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,13 @@ fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>)
return true;
}

// some of the git ops are not involving nethier index nor HEAD change, or sometimes
// index updates can arrive too late after the change - that's why we track the log
// the actual user action, once user
if path_in_git_dir == Path::new("logs/HEAD") {
return true;
}

if let Some(fname) = path_in_git_dir.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
Expand All @@ -785,4 +792,62 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
warn!("Failed to watch .git directory: {}", e);
}

// `.git` above is non-recursive, so on Linux (per-dir inotify watches)
// events for `logs/HEAD` — the commit-finished signal used by
// `is_dotgit_change_affecting_status` — would never be delivered without
// watching `.git/logs` itself. On macOS/Windows the recursive base watch
// already covers it; an extra watch is harmless there.
let logs_dir = git_dir.join("logs");
if logs_dir.is_dir()
&& let Err(e) = debouncer.watch(&logs_dir, RecursiveMode::NonRecursive)
{
warn!("Failed to watch .git/logs directory: {}", e);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn dotgit_status_filter_matches_worktree_state_changes() {
let tmp = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
let git_dir = repo.path().to_path_buf();
let repo = Some(repo);

let affecting = ["index", "index.lock", "HEAD", "logs/HEAD", "MERGE_HEAD"];
for p in affecting {
assert!(
is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must trigger a git status rescan"
);
}

// Ref-only updates (fetch/push/tags) and commit scratch files must not.
let non_affecting = [
"refs/heads/main",
"refs/heads/main.lock",
"logs/refs/remotes/origin/main",
"COMMIT_EDITMSG",
"packed-refs",
];
for p in non_affecting {
assert!(
!is_dotgit_change_affecting_status(&git_dir.join(p), &repo),
"{p} must NOT trigger a git status rescan"
);
}

// Worktree paths outside .git never match.
assert!(!is_dotgit_change_affecting_status(
&tmp.path().join("src/main.rs"),
&repo
));
assert!(!is_dotgit_change_affecting_status(
&git_dir.join("index"),
&None
));
}
}
2 changes: 1 addition & 1 deletion crates/fff-core/src/bigram_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ pub(crate) fn fuzzy_to_bigram_query(query: &str, num_probes: usize) -> BigramQue
return BigramQuery::Any;
}

// the simpliest case, just check that every bigram is present either consec or not
// the simplest case, just check that every bigram is present either consec or not
if max_typos == 0 {
return simplify_and(
bigram_keys
Expand Down
131 changes: 131 additions & 0 deletions crates/fff-core/src/grep/classify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Definition and import line classification (vibe coded POC)
//!
//! Byte-level heuristics that tag a matched line as a code definition
//! (`struct`, `fn`, `class`, …) or an import/use statement. Used to
//! rank/annotate grep results for AI/MCP consumers. Gated behind the
//! `definitions` feature since only such consumers need it.

/// Detect if a line looks like a code definition (struct, fn, class, etc.)
pub fn is_definition_line(line: &str) -> bool {
let s = line.trim_start().as_bytes();
let s = skip_modifiers(s);
is_definition_keyword(s)
}

/// Modifier keywords that can precede a definition keyword.
/// Each must be followed by whitespace to be consumed.
const MODIFIERS: &[&[u8]] = &[
b"pub",
b"export",
b"default",
b"async",
b"abstract",
b"unsafe",
b"static",
b"protected",
b"private",
b"public",
];

/// Definition keywords to detect.
const DEF_KEYWORDS: &[&[u8]] = &[
b"struct",
b"fn",
b"enum",
b"trait",
b"impl",
b"class",
b"interface",
b"function",
b"def",
b"func",
b"type",
b"module",
b"object",
];

/// Skip zero or more modifier keywords (including `pub(crate)` style visibility).
fn skip_modifiers(mut s: &[u8]) -> &[u8] {
loop {
// Handle `pub(...)` — e.g. `pub(crate)`, `pub(super)`
if s.starts_with(b"pub(")
&& let Some(end) = s.iter().position(|&b| b == b')')
{
s = skip_ws(&s[end + 1..]);
continue;
}
let mut matched = false;
for &kw in MODIFIERS {
if s.starts_with(kw) {
let rest = &s[kw.len()..];
if rest.first().is_some_and(|b| b.is_ascii_whitespace()) {
s = skip_ws(rest);
matched = true;
break;
}
}
}
if !matched {
return s;
}
}
}

/// Check if `s` starts with a definition keyword followed by a word boundary.
fn is_definition_keyword(s: &[u8]) -> bool {
for &kw in DEF_KEYWORDS {
if s.starts_with(kw) {
let after = s.get(kw.len());
// Word boundary: end of input, or next byte is not alphanumeric/underscore
if after.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_') {
return true;
}
}
}
false
}

/// Skip ASCII whitespace.
#[inline]
fn skip_ws(s: &[u8]) -> &[u8] {
let n = s
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(s.len());
&s[n..]
}

/// Detect import/use lines — lower value than definitions or usages.
///
/// Checks if the line (after leading whitespace) starts with a common
/// import statement prefix. Pure byte-level checks, no regex.
pub fn is_import_line(line: &str) -> bool {
let s = line.trim_start().as_bytes();
s.starts_with(b"import ")
|| s.starts_with(b"import\t")
|| (s.starts_with(b"from ") && s.get(5).is_some_and(|&b| b == b'\'' || b == b'"'))
|| s.starts_with(b"use ")
|| s.starts_with(b"use\t")
|| starts_with_require(s)
|| starts_with_include(s)
}

/// Match `require(` or `require (`.
#[inline]
fn starts_with_require(s: &[u8]) -> bool {
if !s.starts_with(b"require") {
return false;
}
let rest = &s[b"require".len()..];
rest.first() == Some(&b'(') || (rest.first() == Some(&b' ') && rest.get(1) == Some(&b'('))
}

/// Match `# include ` (with optional spaces after `#`).
#[inline]
fn starts_with_include(s: &[u8]) -> bool {
if s.first() != Some(&b'#') {
return false;
}
let rest = skip_ws(&s[1..]);
rest.starts_with(b"include ") || rest.starts_with(b"include\t")
}
Loading
Loading