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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ Both find and grep accept these tokens to refine a query:

- `git:modified`. One of `modified`, `staged`, `deleted`, `renamed`, `untracked`, `ignored`.
- `test/`. Any deeply nested children of `test/`.
- `!something`, `!test/`, `!git:modified`. Exclusion.
- `!something`, `!test/`, `!git:modified`. Exclusion. Text exclusions need at least 3 alphanumeric-containing characters, so operators like `!=` or `!==` work.
- `./**/*.{rs,lua}`. Any valid glob, powered by [zlob](https://github.com/dmtrKovalenko/zlob).

Grep-only:
Expand Down
70 changes: 70 additions & 0 deletions crates/fff-core/src/grep/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,75 @@ pub(crate) fn grep_search<'a>(
base_path: &Path,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> GrepResult<'a> {
let result = grep_search_parsed(
files,
query,
options,
budget,
bigram_index,
bigram_overlay,
abort_signal,
base_path,
arena,
overflow_arena,
);

// Constraint parsing can swallow tokens the user meant literally (e.g. `!=`
// becoming an exclusion). If the constrained search scanned everything and
// found nothing, retry the whole raw query as literal text. This also holds
// for later pages: an empty full scan at offset 0 stays empty at any offset,
// so paging offsets consistently index the literal search's file list.
let full_scan_empty = result.matches.is_empty() && result.next_file_offset == 0;
if !full_scan_empty || query.constraints.is_empty() || abort_signal.load(Ordering::Relaxed) {
return result;
}

let raw = query.raw_query.trim();
if raw.is_empty() {
return result;
}

let literal_query = FFFQuery {
raw_query: query.raw_query,
constraints: Vec::new(),
fuzzy_query: fff_query_parser::FuzzyQuery::Text(raw),
location: None,
};

let mut fallback = grep_search_parsed(
files,
&literal_query,
options,
budget,
bigram_index,
bigram_overlay,
abort_signal,
base_path,
arena,
overflow_arena,
);

if fallback.matches.is_empty() {
result
} else {
fallback.literal_fallback = true;
fallback
}
}

#[allow(clippy::too_many_arguments)]
fn grep_search_parsed<'a>(
files: &'a [FileItem],
query: &FFFQuery<'_>,
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram_index: Option<&BigramFilter>,
bigram_overlay: Option<&BigramOverlay>,
abort_signal: &AtomicBool,
base_path: &Path,
arena: crate::simd_path::ArenaPtr,
overflow_arena: crate::simd_path::ArenaPtr,
) -> GrepResult<'a> {
let total_files = files.live_count();
let constraints_from_query = &query.constraints[..];
Expand Down Expand Up @@ -622,5 +691,6 @@ where
filtered_file_count: ctx.filtered_file_count,
next_file_offset,
regex_fallback_error: None,
literal_fallback: false,
}
}
4 changes: 4 additions & 0 deletions crates/fff-core/src/grep/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ pub struct GrepResult<'a> {
/// literal matching and this field contains the compilation error message.
/// The UI can display this to inform the user their regex was invalid.
pub regex_fallback_error: Option<String>,
/// Set to `true` if the constrained query found nothing and the results come from
/// retrying the whole raw query as literal text (ignored all the inferred constraints)
pub literal_fallback: bool,
}

impl<'a> GrepResult<'a> {
Expand Down Expand Up @@ -234,6 +237,7 @@ impl<'a> GrepResult<'a> {
filtered_file_count,
next_file_offset,
regex_fallback_error: None,
literal_fallback: false,
}
}
}
61 changes: 61 additions & 0 deletions crates/fff-core/tests/grep_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1835,3 +1835,64 @@ fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() {
"lowercase query should case-insensitively match 'VFIO-KVM'"
);
}

/// Bug pinning: `!=` was parsed as a Not("=") exclusion constraint, dropping it
/// from the needle. Operator tokens must stay literal search text.
#[test]
fn plain_text_not_equals_operator_is_literal() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(
tmp.path(),
&[(
"watch.rs",
"if delivery.sub.epoch.load(Ordering::Acquire) != delivery.epoch {\n",
)],
);

let parsed = parse_grep_query("Ordering::Acquire) != delivery.epoch");
let result = picker.grep(&parsed, &plain_opts());

assert_eq!(
result.matches.len(),
1,
"operator `!=` must match literally"
);
assert!(!result.literal_fallback, "no fallback should be needed");
assert!(result.matches[0].line_content.contains("!= delivery.epoch"));
}

#[test]
fn literal_fallback_when_constraints_find_nothing() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(tmp.path(), &[("a.txt", "foo !bar_baz qux\n")]);

// `!bar_baz` becomes Not(Text) so the constrained needle is "foo qux" → no
// match; the search must retry the raw query as literal text.
let parsed = parse_grep_query("foo !bar_baz qux");
let result = picker.grep(&parsed, &plain_opts());

assert_eq!(result.matches.len(), 1);
assert!(
result.literal_fallback,
"literal fallback should be flagged"
);
assert!(result.matches[0].line_content.contains("!bar_baz"));
}

#[test]
fn literal_fallback_not_triggered_when_constraints_match() {
let tmp = TempDir::new().unwrap();
let picker = create_picker(
tmp.path(),
&[
("src/lib.rs", "needle here\n"),
("test/lib.rs", "needle here\n"),
],
);

let parsed = parse_grep_query("needle !test");
let result = picker.grep(&parsed, &plain_opts());

assert_eq!(result.matches.len(), 1, "exclusion should still apply");
assert!(!result.literal_fallback);
}
39 changes: 37 additions & 2 deletions crates/fff-query-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,11 @@ fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Con
return Some(Constraint::Not(Box::new(inner_constraint)));
}

// If it's not a special constraint, treat it as negated text
// For backward compatibility with !test syntax
// Negated text (!test) requires ≥3 inner chars with at least one alphanumeric,
// so operators like `!=`, `!==`, `!!` stay literal search text.
if inner_token.len() < 3 || !inner_token.chars().any(|c| c.is_alphanumeric()) {
return None;
}
Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
}

Expand Down Expand Up @@ -612,6 +615,38 @@ mod tests {
}
}

#[test]
fn test_negation_operators_stay_literal() {
let parser = QueryParser::new(GrepConfig);
// Operator-like tokens must not become exclusion constraints
for query in [
"Ordering::Acquire) != delivery.epoch",
"a !== b",
"x !! y",
"foo !~ bar",
] {
let result = parser.parse(query);
assert!(
result.constraints.is_empty(),
"{query:?} produced constraints {:?}",
result.constraints
);
assert_eq!(result.grep_text(), query, "grep text must equal raw query");
}
}

#[test]
fn test_negation_short_text_stays_literal() {
let parser = QueryParser::new(FileSearchConfig);
// Inner text < 3 chars is not a Not constraint
let result = parser.parse("!ab foo");
assert!(result.constraints.is_empty());
// Inner text >= 3 chars still is
let result = parser.parse("!abc foo");
assert_eq!(result.constraints.len(), 1);
assert!(matches!(&result.constraints[0], Constraint::Not(_)));
}

#[test]
fn test_negation_extension() {
let parser = QueryParser::new(FileSearchConfig);
Expand Down
2 changes: 0 additions & 2 deletions lua/fff/picker_ui/grep_renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ local fuzzy = require('fff.fuzzy')
local file_renderer = require('fff.picker_ui.file_renderer')
local tresitter_highlight = require('fff.treesitter_hl')

-- ===== Search Bridge =====

---@class fff.grep.SearchResult
---@field items table[] Array of grep match items
---@field total_matched number Total matches found in this call
Expand Down
Loading