From fbdf0466fbdc257be83894fea04b8a45a57e1200 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Thu, 23 Jul 2026 12:47:11 -0700 Subject: [PATCH] fix: Not finding if needle contains `!=` --- README.md | 2 +- crates/fff-core/src/grep/grep.rs | 70 +++++++++++++++++++++++ crates/fff-core/src/grep/types.rs | 4 ++ crates/fff-core/tests/grep_integration.rs | 61 ++++++++++++++++++++ crates/fff-query-parser/src/parser.rs | 39 ++++++++++++- lua/fff/picker_ui/grep_renderer.lua | 2 - 6 files changed, 173 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d0879b3a5..944186aff 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/fff-core/src/grep/grep.rs b/crates/fff-core/src/grep/grep.rs index 4c825c6b5..3f53e961c 100644 --- a/crates/fff-core/src/grep/grep.rs +++ b/crates/fff-core/src/grep/grep.rs @@ -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[..]; @@ -622,5 +691,6 @@ where filtered_file_count: ctx.filtered_file_count, next_file_offset, regex_fallback_error: None, + literal_fallback: false, } } diff --git a/crates/fff-core/src/grep/types.rs b/crates/fff-core/src/grep/types.rs index 21bb3c437..08f354fa8 100644 --- a/crates/fff-core/src/grep/types.rs +++ b/crates/fff-core/src/grep/types.rs @@ -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, + /// 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> { @@ -234,6 +237,7 @@ impl<'a> GrepResult<'a> { filtered_file_count, next_file_offset, regex_fallback_error: None, + literal_fallback: false, } } } diff --git a/crates/fff-core/tests/grep_integration.rs b/crates/fff-core/tests/grep_integration.rs index 2765de3c3..d579370ac 100644 --- a/crates/fff-core/tests/grep_integration.rs +++ b/crates/fff-core/tests/grep_integration.rs @@ -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); +} diff --git a/crates/fff-query-parser/src/parser.rs b/crates/fff-query-parser/src/parser.rs index 88a32e91f..4fd72116d 100644 --- a/crates/fff-query-parser/src/parser.rs +++ b/crates/fff-query-parser/src/parser.rs @@ -364,8 +364,11 @@ fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option= 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); diff --git a/lua/fff/picker_ui/grep_renderer.lua b/lua/fff/picker_ui/grep_renderer.lua index ef022afcd..7bebe9bee 100644 --- a/lua/fff/picker_ui/grep_renderer.lua +++ b/lua/fff/picker_ui/grep_renderer.lua @@ -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