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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ require('fff').setup({
time_budget_ms = 150,
modes = { 'plain', 'regex', 'fuzzy' },
trim_whitespace = false,
enable_filename_constraint = false, -- treat filename-like tokens (e.g. `score.rs`) in a grep query as a file-path filter scoping the search; off = searched as literal text
location_format = ':%d:%d', -- printf format for line:col prefix in grep results, e.g. ':%d' for line-only
},
debug = {
Expand Down
36 changes: 23 additions & 13 deletions crates/fff-nvim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use fff::frecency::FrecencyTracker;
use fff::path_utils::expand_tilde;
use fff::query_tracker::QueryTracker;
use fff::{
DbHealthChecker, DirSearchConfig, Error, FFFMode, FileSearchConfig, FuzzySearchOptions,
GrepConfig, MixedSearchConfig, PaginationArgs, QueryParser, Score, SearchResult,
DbHealthChecker, DirSearchConfig, Error, FFFMode, FFFQuery, FileSearchConfig,
FuzzySearchOptions, MixedSearchConfig, PaginationArgs, QueryParser, Score, SearchResult,
SharedFilePicker, SharedFrecency, SharedQueryTracker,
};
use mimalloc::MiMalloc;
Expand All @@ -15,12 +15,14 @@ use once_cell::sync::Lazy;
use path_shortening::PathShortenStrategy;
use std::path::{Path, PathBuf};
use std::time::Duration;
use user_config::{NvimGrepConfig, UserConfigOptions, set_global_user_config};

mod error;
mod hex_dump;
mod log;
mod lua_types;
mod path_shortening;
mod user_config;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
Expand Down Expand Up @@ -65,6 +67,7 @@ struct PickerInitOpts {
follow_symlinks: bool,
enable_fs_root_scanning: bool,
enable_home_dir_scanning: bool,
enable_filename_constraint: bool,
}

impl PickerInitOpts {
Expand All @@ -86,6 +89,9 @@ impl PickerInitOpts {
enable_home_dir_scanning: t
.get::<Option<bool>>("enable_home_dir_scanning")?
.unwrap_or(false),
enable_filename_constraint: t
.get::<Option<bool>>("enable_filename_constraint")?
.unwrap_or(false),
}),
other => Err(LuaError::RuntimeError(format!(
"init opts must be a table, boolean, or nil — got {}",
Expand All @@ -107,6 +113,9 @@ pub fn init_file_picker(
}

let opts = PickerInitOpts::from_lua_value(opts)?;
set_global_user_config(UserConfigOptions {
enable_filename_constraint: opts.enable_filename_constraint,
});

FilePicker::new_with_shared_state(
FILE_PICKER.clone(),
Expand Down Expand Up @@ -144,6 +153,9 @@ pub fn restart_index_in_path(
})?;

let opts = PickerInitOpts::from_lua_value(opts)?;
set_global_user_config(UserConfigOptions {
enable_filename_constraint: opts.enable_filename_constraint,
});

// Spawn a background thread BEFORE touching the picker lock. The
// same-dir short-circuit previously called `FILE_PICKER.read()` on
Expand Down Expand Up @@ -277,11 +289,9 @@ pub fn fuzzy_search_files(
"Fuzzy search parameters"
);

let parser = QueryParser::new(FileSearchConfig);
let parsed = parser.parse(&query);

let parsed_query = FFFQuery::parse(&query, FileSearchConfig);
let results = picker.fuzzy_search(
&parsed,
&parsed_query,
query_tracker_guard.as_ref(),
FuzzySearchOptions {
max_threads,
Expand All @@ -297,7 +307,7 @@ pub fn fuzzy_search_files(
);

if results.items.is_empty() && query.contains(std::path::MAIN_SEPARATOR) {
let pure_query = match &parsed.fuzzy_query {
let pure_query = match &parsed_query.fuzzy_query {
fff_query_parser::FuzzyQuery::Text(t) => t.trim(),
_ => query.trim(),
};
Expand All @@ -314,7 +324,7 @@ pub fn fuzzy_search_files(
}],
total_matched: 1,
total_files: results.total_files,
location: parsed.location,
location: parsed_query.location,
};

return lua_types::SearchResultLua::new(found, picker).into_lua(lua);
Expand Down Expand Up @@ -443,7 +453,7 @@ pub fn live_grep(
return Err(error::to_lua_error(Error::FilePickerMissing));
};

let parsed = fff::grep::parse_grep_query(&query);
let parsed_query = FFFQuery::parse(&query, NvimGrepConfig);
let mode = match grep_mode.as_deref() {
Some("regex") => fff::GrepMode::Regex,
Some("fuzzy") => fff::GrepMode::Fuzzy,
Expand All @@ -465,7 +475,7 @@ pub fn live_grep(
abort_signal: None,
};

let result = picker.grep(&parsed, &options);
let result = picker.grep(&parsed_query, &options);
lua_types::GrepResultLua::new(result, picker).into_lua(lua)
}

Expand Down Expand Up @@ -773,10 +783,10 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str

/// Parse a grep query string and return its text portion (with constraints stripped).
///
/// Uses the Rust `GrepConfig` parser as the single source of truth, so Lua
/// code never needs to re-implement constraint detection.
/// Uses the Neovim grep parser config so filename-constraint detection follows
/// the user's setting, keeping Lua from re-implementing constraint detection.
pub fn parse_grep_query(lua: &Lua, query: String) -> LuaResult<LuaTable> {
let parser = QueryParser::new(GrepConfig);
let parser = QueryParser::new(NvimGrepConfig);
let parsed = parser.parse(&query);
let table = lua.create_table()?;
table.set("grep_text", parsed.grep_text())?;
Expand Down
47 changes: 47 additions & 0 deletions crates/fff-nvim/src/user_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use fff::{GrepConfig, ParserConfig};
use std::sync::RwLock;

#[derive(Debug, Clone, Copy, Default)]
pub struct UserConfigOptions {
pub enable_filename_constraint: bool,
}

static USER_CONFIG: RwLock<UserConfigOptions> = RwLock::new(UserConfigOptions {
enable_filename_constraint: false,
});

pub fn set_global_user_config(config: UserConfigOptions) {
if let Ok(mut guard) = USER_CONFIG.write() {
*guard = config;
}
}

fn get_global_user_config() -> UserConfigOptions {
USER_CONFIG.read().map(|c| *c).unwrap_or_default()
}

/// Grep query parser config for Neovim configured by user
/// Same as `GrepConfig` but lets the user configure some behavior of query parsing
pub struct NvimGrepConfig;

impl ParserConfig for NvimGrepConfig {
fn enable_path_segments(&self) -> bool {
true
}

fn enable_git_status(&self) -> bool {
false
}

fn enable_location(&self) -> bool {
false
}

fn enable_filename_constraint(&self) -> bool {
get_global_user_config().enable_filename_constraint
}

fn is_glob_pattern(&self, token: &str) -> bool {
GrepConfig.is_glob_pattern(token)
}
}
87 changes: 26 additions & 61 deletions crates/fff-query-parser/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,45 +1,10 @@
use crate::constraints::Constraint;
use crate::glob_detect::has_wildcards;

/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
///
/// A token is a filename/path if ALL of:
/// - Does NOT end with `/` (that's a directory/PathSegment)
/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
/// - Last component (after final `/`) contains `.` with a valid-looking extension
/// (1–10 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
///
/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
#[inline]
fn is_filename_constraint_token(token: &str) -> bool {
let bytes = token.as_bytes();

// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}

// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}

// Get the filename component (after last /)
let filename = token.rsplit('/').next().unwrap_or(token);

// Extension must exist and look like a real file extension:
// starts with an ASCII letter (rejects version numbers like "v2.0"),
// followed by alphanumeric chars, max 10 chars total.
match filename.rfind('.') {
Some(dot_pos) => {
let ext = &filename[dot_pos + 1..];
!ext.is_empty()
&& ext.len() <= 10
&& ext.as_bytes()[0].is_ascii_alphabetic()
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
}
None => false,
}
/// Check if a token looks like a filename/path for use as a `FilePath` constraint.
#[deprecated(note = "use `Constraint::is_filename_constraint_token`")]
pub fn is_filename_constraint_token(token: &str) -> bool {
Constraint::is_filename_constraint_token(token)
}

/// Parser configuration trait - allows different picker types to customize parsing
Expand Down Expand Up @@ -97,28 +62,32 @@ pub trait ParserConfig {
true
}

/// Custom constraint parsers for picker-specific needs
/// If `true`, tokens that look like filenames (`score.rs`, `src/main.rs`)
/// become `FilePath` constraints that scope the search to matching paths.
/// Off by default: a partial filename like `vite.conf` would otherwise
/// filter out `vite.config.ts` and yield zero results.
fn enable_filename_constraint(&self) -> bool {
false
}

/// Custom constraint parsers for picker-specific needs.
///
/// Returns `None` by default. Filename-token detection is handled
/// separately by the parser via `enable_filename_constraint`.
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
None
}
}

/// Default configuration for file picker - all features enabled
/// Default configuration for the file picker.
///
/// Filename-constraint detection is off (trait default), so partial filenames
/// like `vite.conf` don't silently filter out fuzzy matches. The Neovim layer
/// overrides `enable_filename_constraint` to opt in based on user config.
#[derive(Debug, Clone, Copy, Default)]
pub struct FileSearchConfig;

impl ParserConfig for FileSearchConfig {
/// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
/// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
/// filter by filename first, then fuzzy-match the remaining text against the path.
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
impl ParserConfig for FileSearchConfig {}

/// Configuration for full-text search (grep) - file constraints enabled for
/// filtering which files to search, git status disabled since it's not useful
Expand Down Expand Up @@ -217,6 +186,10 @@ impl ParserConfig for AiGrepConfig {
false
}

fn enable_filename_constraint(&self) -> bool {
true
}

fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
Expand All @@ -240,14 +213,6 @@ impl ParserConfig for AiGrepConfig {

false
}

fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}

impl ParserConfig for DirSearchConfig {
Expand Down
36 changes: 36 additions & 0 deletions crates/fff-query-parser/src/constraints.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use crate::glob_detect::has_wildcards;

/// Constraint types that can be extracted from a query
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Constraint<'a> {
Expand Down Expand Up @@ -35,6 +37,40 @@ pub enum Constraint<'a> {
Not(Box<Constraint<'a>>),
}

impl Constraint<'_> {
#[inline(always)]
pub fn is_filename_constraint_token(token: &str) -> bool {
let bytes = token.as_bytes();

// Must NOT end with / or .
if token.is_empty() || (bytes.last() == Some(&b'/') && bytes.first() != Some(&b'.')) {
return false;
}

// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}

// Get the filename component (after last /)
let filename = token.rsplit('/').next().unwrap_or(token);

// Extension must exist and look like a real file extension:
// starts with an ASCII letter (rejects version numbers like "v2.0"),
// followed by alphanumeric chars, max 10 chars total.
match filename.rfind('.') {
None => false,
Some(dot_idx) => {
let extension = &filename[dot_idx + 1..];

!extension.is_empty()
&& extension.len() <= 10 // just an sassumption
&& extension.bytes().all(|b| b.is_ascii_alphanumeric())
}
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitStatusFilter {
Modified,
Expand Down
2 changes: 2 additions & 0 deletions crates/fff-query-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub mod glob_detect;
pub mod location;
mod parser;

#[allow(deprecated)]
pub use config::is_filename_constraint_token;
pub use config::{
AiGrepConfig, DirSearchConfig, FileSearchConfig, GrepConfig, MixedSearchConfig, ParserConfig,
};
Expand Down
Loading
Loading