Skip to main content

fff_query_parser/
config.rs

1use crate::constraints::Constraint;
2use crate::glob_detect::has_wildcards;
3
4/// Check if a token looks like a filename or file path for use as a `FilePath` constraint.
5///
6/// A token is a filename/path if ALL of:
7/// - Does NOT end with `/` (that's a directory/PathSegment)
8/// - Does NOT contain wildcards (`*`, `?`, `{`, `[`) — those are globs
9/// - Last component (after final `/`) contains `.` with a valid-looking extension
10///   (1–10 alphanumeric chars starting with a letter, e.g. `rs`, `json`, `tsx`)
11///
12/// This covers both bare filenames (`score.rs`) and path-prefixed ones (`src/main.rs`).
13#[inline]
14fn is_filename_constraint_token(token: &str) -> bool {
15    let bytes = token.as_bytes();
16
17    // Must NOT end with / (that's a PathSegment)
18    if bytes.last() == Some(&b'/') {
19        return false;
20    }
21
22    // Must NOT contain wildcards (those are globs)
23    if has_wildcards(token) {
24        return false;
25    }
26
27    // Get the filename component (after last /)
28    let filename = token.rsplit('/').next().unwrap_or(token);
29
30    // Extension must exist and look like a real file extension:
31    // starts with an ASCII letter (rejects version numbers like "v2.0"),
32    // followed by alphanumeric chars, max 10 chars total.
33    match filename.rfind('.') {
34        Some(dot_pos) => {
35            let ext = &filename[dot_pos + 1..];
36            !ext.is_empty()
37                && ext.len() <= 10
38                && ext.as_bytes()[0].is_ascii_alphabetic()
39                && ext.bytes().all(|b| b.is_ascii_alphanumeric())
40        }
41        None => false,
42    }
43}
44
45/// Parser configuration trait - allows different picker types to customize parsing
46pub trait ParserConfig {
47    fn enable_glob(&self) -> bool {
48        true
49    }
50
51    /// Should parse extension shortcuts (e.g., *.rs)
52    fn enable_extension(&self) -> bool {
53        true
54    }
55
56    /// Should parse exclusion patterns (e.g., !test)
57    fn enable_exclude(&self) -> bool {
58        true
59    }
60
61    /// Should parse path segments (e.g., /src/)
62    fn enable_path_segments(&self) -> bool {
63        true
64    }
65
66    /// Should parse type constraints (e.g., type:rust)
67    fn enable_type_filter(&self) -> bool {
68        true
69    }
70
71    /// Should parse git status (e.g., status:modified)
72    fn enable_git_status(&self) -> bool {
73        true
74    }
75
76    /// Should parse location suffixes (e.g., file:12, file:12:4)
77    /// Disabled for grep modes where colon-number patterns like localhost:8080
78    /// are search text, not file locations.
79    fn enable_location(&self) -> bool {
80        true
81    }
82
83    /// Determine whether a token should be treated as a glob constraint.
84    ///
85    /// The default implementation delegates to `zlob::has_wildcards` with
86    /// `RECOMMENDED` flags, which recognises `*`, `?`, `[`, `{…}` etc.
87    ///
88    /// Override this in configs where some wildcard characters are common
89    /// in search text (e.g. grep mode where `?` and `[` appear in code).
90    fn is_glob_pattern(&self, token: &str) -> bool {
91        has_wildcards(token)
92    }
93
94    /// If `true`, a PathSegment constraint that is the ONLY token in the
95    /// query is demoted to fuzzy text to avoid over filtering
96    fn treat_lone_path_as_text(&self) -> bool {
97        true
98    }
99
100    /// Custom constraint parsers for picker-specific needs
101    fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
102        None
103    }
104}
105
106/// Default configuration for file picker - all features enabled
107#[derive(Debug, Clone, Copy, Default)]
108pub struct FileSearchConfig;
109
110impl ParserConfig for FileSearchConfig {
111    /// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
112    /// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
113    /// filter by filename first, then fuzzy-match the remaining text against the path.
114    fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
115        if is_filename_constraint_token(token) {
116            Some(Constraint::FilePath(token))
117        } else {
118            None
119        }
120    }
121}
122
123/// Configuration for full-text search (grep) - file constraints enabled for
124/// filtering which files to search, git status disabled since it's not useful
125/// when searching file contents.
126///
127/// Glob detection is narrowed: only patterns containing a path separator (`/`)
128/// or brace expansion (`{…}`) are treated as globs. Characters like `?` and
129/// `[` are extremely common in source code and must remain literal search text.
130#[derive(Debug, Clone, Copy, Default)]
131pub struct GrepConfig;
132
133impl ParserConfig for GrepConfig {
134    fn enable_path_segments(&self) -> bool {
135        true
136    }
137
138    fn enable_git_status(&self) -> bool {
139        false
140    }
141
142    fn enable_location(&self) -> bool {
143        false
144    }
145
146    /// Only recognise globs that are clearly directory/path oriented.
147    ///
148    /// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
149    /// common in source code (`foo?`, `arr[0]`, `*ptr`) and must NOT be
150    /// consumed as glob constraints. We only treat a token as a glob when
151    /// it contains path-oriented patterns:
152    ///
153    /// - Contains `/` → path glob (e.g. `src/**/*.rs`, `*/tests/*`)
154    /// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
155    fn is_glob_pattern(&self, token: &str) -> bool {
156        // Must contain at least one glob wildcard character
157        if !has_wildcards(token) {
158            return false;
159        }
160
161        let bytes = token.as_bytes();
162
163        // Contains path separator → clearly a path glob
164        if bytes.contains(&b'/') {
165            return true;
166        }
167
168        // Brace expansion → useful for directory alternatives.
169        // Require a comma between `{` and `}` AND at least one letter to
170        // distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
171        // from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
172        if let Some(open) = bytes.iter().position(|&b| b == b'{')
173            && let Some(close) = bytes.iter().rposition(|&b| b == b'}')
174        {
175            let inner = &bytes[open + 1..close];
176            if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
177                return true;
178            }
179        }
180
181        // Everything else (?, [, bare * without /) → treat as literal text
182        false
183    }
184}
185
186/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
187/// automatic file-path constraint detection.
188///
189/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
190/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
191/// so the search is scoped to matching files. The caller validates the
192/// constraint against the index and drops it if no files match (fallback).
193#[derive(Debug, Clone, Copy, Default)]
194pub struct AiGrepConfig;
195
196/// Configuration for directory and mixed search modes.
197///
198/// Disables path segment parsing so that trailing `/` is kept as fuzzy text
199/// (e.g. `fff-core/` fuzzy-matches directory paths instead of becoming a
200/// `PathSegment("fff-core")` constraint with an empty query). Extension and
201/// filename constraints are also disabled since they don't apply to directories.
202#[derive(Debug, Clone, Copy, Default)]
203pub struct DirSearchConfig;
204
205impl ParserConfig for AiGrepConfig {
206    fn enable_path_segments(&self) -> bool {
207        true
208    }
209
210    fn enable_git_status(&self) -> bool {
211        false
212    }
213
214    fn enable_location(&self) -> bool {
215        false
216    }
217
218    fn is_glob_pattern(&self, token: &str) -> bool {
219        // First check GrepConfig's strict rules (path globs, brace expansion)
220        if GrepConfig.is_glob_pattern(token) {
221            return true;
222        }
223
224        // AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
225        // Recognise tokens that start AND end with `*` with non-empty text
226        // between them as glob constraints. Bare `*` or `**` are excluded.
227        if !has_wildcards(token) {
228            return false;
229        }
230        let bytes = token.as_bytes();
231        if bytes.len() >= 3
232            && bytes[0] == b'*'
233            && bytes[bytes.len() - 1] == b'*'
234            && bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
235        {
236            return true;
237        }
238
239        false
240    }
241
242    fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
243        if is_filename_constraint_token(token) {
244            Some(Constraint::FilePath(token))
245        } else {
246            None
247        }
248    }
249}
250
251impl ParserConfig for DirSearchConfig {
252    fn enable_path_segments(&self) -> bool {
253        false
254    }
255
256    fn enable_extension(&self) -> bool {
257        false
258    }
259
260    fn enable_type_filter(&self) -> bool {
261        false
262    }
263
264    fn enable_git_status(&self) -> bool {
265        false
266    }
267}
268
269/// Configuration for mixed (files + directories) search.
270///
271/// Like `DirSearchConfig`, disables path segment parsing so trailing `/`
272/// triggers dirs-only mode instead of becoming a constraint. Keeps git
273/// status and extension filters enabled since files are part of the results.
274#[derive(Debug, Clone, Copy, Default)]
275pub struct MixedSearchConfig;
276
277impl ParserConfig for MixedSearchConfig {
278    fn enable_path_segments(&self) -> bool {
279        false
280    }
281}