From 48d8f15b1fd91b10537106ee15fbf72e56047051 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:43:59 -0700 Subject: [PATCH 1/2] fix(grep): align preview highlight stripping with Rust query parser The Lua heuristic in highlight_grep_matches used a simple prefix check (^[*!/] or ^.) to strip constraints. This diverged from the Rust GrepConfig parser in several ways: - Multi-word queries like 'foo bar *.rs' only highlighted 'foo' - Constraint prefixes like type:rust were not stripped - Tokens starting with '.' were incorrectly treated as constraints - Escaped constraint tokens (e.g. \*.config) were not handled Replace the heuristic with _is_grep_constraint() that matches the Rust parser's actual GrepConfig rules: extensions (*.rs), path segments (/src/), exclusions (!test), type filters (type:rust), and path-oriented globs. Use all text parts joined with space for highlighting, matching grep_text() on the Rust side. Fixes #331 --- lua/fff/location_utils.lua | 57 ++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/lua/fff/location_utils.lua b/lua/fff/location_utils.lua index 0cc78d60..ac750e1b 100644 --- a/lua/fff/location_utils.lua +++ b/lua/fff/location_utils.lua @@ -134,6 +134,45 @@ function M.highlight_location(bufnr, location, namespace) return #extmarks > 0 and extmarks or nil end +--- Check if a token is a grep constraint (matches Rust GrepConfig parser rules). +--- Keeps highlight stripping aligned with the actual query parser so preview +--- highlights match the grep results. +--- @param token string A single whitespace-delimited query token +--- @return boolean +function M._is_grep_constraint(token) + if token == '' then return false end + + -- Escaped tokens (leading \) are never constraints in the Rust parser + if token:sub(1, 1) == '\\' and #token > 1 then return false end + + local first = token:sub(1, 1) + + -- Extension: *.rs, *.{ts,tsx} (but not bare "*" or "*.") + if first == '*' then + if token == '*' or token == '*.' then return false end + if token:match('^%*%.') then return true end + return false + end + + -- Exclusion: !foo, !*.rs + if first == '!' and #token > 1 then return true end + + -- Path segment with leading slash: /src/, /lib + if first == '/' then return true end + + -- Path segment with trailing slash: src/, lib/ + if token:sub(-1) == '/' then return true end + + -- Glob: tokens containing / or brace expansion with letters like {ts,tsx} + if token:find('/') then return true end + if token:match('{%a.*,%a.*}') then return true end + + -- Type filter: type:rust + if token:match('^type:') then return true end + + return false +end + --- Highlight all occurrences of a grep pattern in the preview buffer. --- For plain text and regex modes: highlights every match on all loaded lines --- using Lua string.find with the query text. @@ -178,16 +217,22 @@ function M.highlight_grep_matches(bufnr, location, namespace) local query = location.grep_query - -- Extract the actual search text from the grep query (strip file constraints like *.rs /src/) - -- The query parser uses space-separated tokens; the first non-constraint token is the pattern. - -- Simple heuristic: strip tokens that look like constraints (start with *, /, or !) - local search_text = query + -- Extract search text by stripping constraint tokens, matching the Rust + -- query parser's GrepConfig rules (extensions, path segments, globs, + -- exclusions, type filters). Escaped leading backslash means "not a + -- constraint" in the Rust parser, so we keep those tokens too. local parts = vim.split(query, '%s+') local text_parts = {} for _, part in ipairs(parts) do - if part ~= '' and not part:match('^[%*!/]') and not part:match('^%.') then table.insert(text_parts, part) end + if part ~= '' and not M._is_grep_constraint(part) then + -- Strip leading backslash when it escapes a constraint trigger (*, /, !) + -- so that e.g. \*.config highlights "*.config" not "\*.config" + if #part > 1 and part:sub(1, 1) == '\\' and part:sub(2, 2):match('[%*!/]') then part = part:sub(2) end + table.insert(text_parts, part) + end end - if #text_parts > 0 then search_text = text_parts[1] end + local search_text = table.concat(text_parts, ' ') + if search_text == '' then search_text = query end if not search_text or search_text == '' then return nil end From d02642b499b5b8946d461f8f6c3e164487415ce9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:33:32 -0700 Subject: [PATCH 2/2] refactor(grep): expose parsed query to lua via Rust function Replace the Lua-side constraint detection (_is_grep_constraint) with a new parse_grep_query() function that delegates to the Rust GrepConfig parser. This keeps the Rust parser as the single source of truth for query parsing, avoiding drift when new token types are added. The new function is exposed to Lua as fff.parse_grep_query(query) and returns a table with the grep_text field (the search text with all constraints stripped). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/fff-nvim/src/lib.rs | 18 ++++++++++-- lua/fff/fuzzy.lua | 1 + lua/fff/location_utils.lua | 60 ++++---------------------------------- 3 files changed, 23 insertions(+), 56 deletions(-) diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 8e31c38b..0ca44294 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -5,8 +5,9 @@ use fff::frecency::FrecencyTracker; use fff::path_utils::expand_tilde; use fff::query_tracker::QueryTracker; use fff::{ - DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, PaginationArgs, - QueryParser, Score, SearchResult, SharedFrecency, SharedPicker, SharedQueryTracker, + DbHealthChecker, Error, FFFMode, FileSearchConfig, FuzzySearchOptions, GrepConfig, + PaginationArgs, QueryParser, Score, SearchResult, SharedFrecency, SharedPicker, + SharedQueryTracker, }; use mimalloc::MiMalloc; use mlua::prelude::*; @@ -582,6 +583,18 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult LuaResult { + let parser = QueryParser::new(GrepConfig); + let parsed = parser.parse(&query); + let table = lua.create_table()?; + table.set("grep_text", parsed.grep_text())?; + Ok(table) +} + pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option) -> LuaResult { // Extract the scan signal Arc WITHOUT holding the read lock, so the // scan thread can acquire the write lock to store its results. @@ -822,6 +835,7 @@ fn create_exports(lua: &Lua) -> LuaResult { exports.set("health_check", lua.create_function(health_check)?)?; exports.set("shorten_path", lua.create_function(shorten_path)?)?; exports.set("hex_dump", lua.create_function(hex_dump::hex_dump)?)?; + exports.set("parse_grep_query", lua.create_function(parse_grep_query)?)?; Ok(exports) } diff --git a/lua/fff/fuzzy.lua b/lua/fff/fuzzy.lua index 78900c03..e28c4202 100644 --- a/lua/fff/fuzzy.lua +++ b/lua/fff/fuzzy.lua @@ -46,6 +46,7 @@ M.get_git_root = rust_module.get_git_root -- Grep functions M.live_grep = rust_module.live_grep +M.parse_grep_query = rust_module.parse_grep_query -- Utility functions M.health_check = rust_module.health_check diff --git a/lua/fff/location_utils.lua b/lua/fff/location_utils.lua index ac750e1b..662af73e 100644 --- a/lua/fff/location_utils.lua +++ b/lua/fff/location_utils.lua @@ -134,45 +134,6 @@ function M.highlight_location(bufnr, location, namespace) return #extmarks > 0 and extmarks or nil end ---- Check if a token is a grep constraint (matches Rust GrepConfig parser rules). ---- Keeps highlight stripping aligned with the actual query parser so preview ---- highlights match the grep results. ---- @param token string A single whitespace-delimited query token ---- @return boolean -function M._is_grep_constraint(token) - if token == '' then return false end - - -- Escaped tokens (leading \) are never constraints in the Rust parser - if token:sub(1, 1) == '\\' and #token > 1 then return false end - - local first = token:sub(1, 1) - - -- Extension: *.rs, *.{ts,tsx} (but not bare "*" or "*.") - if first == '*' then - if token == '*' or token == '*.' then return false end - if token:match('^%*%.') then return true end - return false - end - - -- Exclusion: !foo, !*.rs - if first == '!' and #token > 1 then return true end - - -- Path segment with leading slash: /src/, /lib - if first == '/' then return true end - - -- Path segment with trailing slash: src/, lib/ - if token:sub(-1) == '/' then return true end - - -- Glob: tokens containing / or brace expansion with letters like {ts,tsx} - if token:find('/') then return true end - if token:match('{%a.*,%a.*}') then return true end - - -- Type filter: type:rust - if token:match('^type:') then return true end - - return false -end - --- Highlight all occurrences of a grep pattern in the preview buffer. --- For plain text and regex modes: highlights every match on all loaded lines --- using Lua string.find with the query text. @@ -217,21 +178,12 @@ function M.highlight_grep_matches(bufnr, location, namespace) local query = location.grep_query - -- Extract search text by stripping constraint tokens, matching the Rust - -- query parser's GrepConfig rules (extensions, path segments, globs, - -- exclusions, type filters). Escaped leading backslash means "not a - -- constraint" in the Rust parser, so we keep those tokens too. - local parts = vim.split(query, '%s+') - local text_parts = {} - for _, part in ipairs(parts) do - if part ~= '' and not M._is_grep_constraint(part) then - -- Strip leading backslash when it escapes a constraint trigger (*, /, !) - -- so that e.g. \*.config highlights "*.config" not "\*.config" - if #part > 1 and part:sub(1, 1) == '\\' and part:sub(2, 2):match('[%*!/]') then part = part:sub(2) end - table.insert(text_parts, part) - end - end - local search_text = table.concat(text_parts, ' ') + -- Use the Rust GrepConfig parser as the single source of truth for + -- stripping constraint tokens. This avoids duplicating constraint + -- detection in Lua, which would break whenever a new token type is added. + local fuzzy = require('fff.fuzzy') + local parsed = fuzzy.parse_grep_query(query) + local search_text = parsed.grep_text if search_text == '' then search_text = query end if not search_text or search_text == '' then return nil end