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 /// Custom constraint parsers for picker-specific needs
95 fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
96 None
97 }
98}
99
100/// Default configuration for file picker - all features enabled
101#[derive(Debug, Clone, Copy, Default)]
102pub struct FileSearchConfig;
103
104impl ParserConfig for FileSearchConfig {
105 /// Detect bare filenames (`score.rs`) and path-prefixed filenames (`src/main.rs`)
106 /// as `FilePath` constraints so that multi-token queries like `score.rs file_picker`
107 /// filter by filename first, then fuzzy-match the remaining text against the path.
108 fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
109 if is_filename_constraint_token(token) {
110 Some(Constraint::FilePath(token))
111 } else {
112 None
113 }
114 }
115}
116
117/// Configuration for full-text search (grep) - file constraints enabled for
118/// filtering which files to search, git status disabled since it's not useful
119/// when searching file contents.
120///
121/// Glob detection is narrowed: only patterns containing a path separator (`/`)
122/// or brace expansion (`{…}`) are treated as globs. Characters like `?` and
123/// `[` are extremely common in source code and must remain literal search text.
124#[derive(Debug, Clone, Copy, Default)]
125pub struct GrepConfig;
126
127impl ParserConfig for GrepConfig {
128 fn enable_path_segments(&self) -> bool {
129 true
130 }
131
132 fn enable_git_status(&self) -> bool {
133 false
134 }
135
136 fn enable_location(&self) -> bool {
137 false
138 }
139
140 /// Only recognise globs that are clearly directory/path oriented.
141 ///
142 /// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
143 /// common in source code (`foo?`, `arr[0]`, `*ptr`) and must NOT be
144 /// consumed as glob constraints. We only treat a token as a glob when
145 /// it contains path-oriented patterns:
146 ///
147 /// - Contains `/` → path glob (e.g. `src/**/*.rs`, `*/tests/*`)
148 /// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
149 fn is_glob_pattern(&self, token: &str) -> bool {
150 // Must contain at least one glob wildcard character
151 if !has_wildcards(token) {
152 return false;
153 }
154
155 let bytes = token.as_bytes();
156
157 // Contains path separator → clearly a path glob
158 if bytes.contains(&b'/') {
159 return true;
160 }
161
162 // Brace expansion → useful for directory alternatives.
163 // Require a comma between `{` and `}` AND at least one letter to
164 // distinguish real glob expansions like `{src,lib}` or `*.{ts,tsx}`
165 // from code patterns like `format!("{}")` and regex quantifiers `{2,3}`.
166 if let Some(open) = bytes.iter().position(|&b| b == b'{')
167 && let Some(close) = bytes.iter().rposition(|&b| b == b'}')
168 {
169 let inner = &bytes[open + 1..close];
170 if inner.contains(&b',') && inner.iter().any(|b| b.is_ascii_alphabetic()) {
171 return true;
172 }
173 }
174
175 // Everything else (?, [, bare * without /) → treat as literal text
176 false
177 }
178}
179
180/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
181/// automatic file-path constraint detection.
182///
183/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
184/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
185/// so the search is scoped to matching files. The caller validates the
186/// constraint against the index and drops it if no files match (fallback).
187#[derive(Debug, Clone, Copy, Default)]
188pub struct AiGrepConfig;
189
190/// Configuration for directory and mixed search modes.
191///
192/// Disables path segment parsing so that trailing `/` is kept as fuzzy text
193/// (e.g. `fff-core/` fuzzy-matches directory paths instead of becoming a
194/// `PathSegment("fff-core")` constraint with an empty query). Extension and
195/// filename constraints are also disabled since they don't apply to directories.
196#[derive(Debug, Clone, Copy, Default)]
197pub struct DirSearchConfig;
198
199impl ParserConfig for AiGrepConfig {
200 fn enable_path_segments(&self) -> bool {
201 true
202 }
203
204 fn enable_git_status(&self) -> bool {
205 false
206 }
207
208 fn enable_location(&self) -> bool {
209 false
210 }
211
212 fn is_glob_pattern(&self, token: &str) -> bool {
213 // First check GrepConfig's strict rules (path globs, brace expansion)
214 if GrepConfig.is_glob_pattern(token) {
215 return true;
216 }
217
218 // AI agents use `*text*` to scope file searches (e.g. `*quote* TODO`).
219 // Recognise tokens that start AND end with `*` with non-empty text
220 // between them as glob constraints. Bare `*` or `**` are excluded.
221 if !has_wildcards(token) {
222 return false;
223 }
224 let bytes = token.as_bytes();
225 if bytes.len() >= 3
226 && bytes[0] == b'*'
227 && bytes[bytes.len() - 1] == b'*'
228 && bytes[1..bytes.len() - 1].iter().all(|&b| b != b'*')
229 {
230 return true;
231 }
232
233 false
234 }
235
236 fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
237 if is_filename_constraint_token(token) {
238 Some(Constraint::FilePath(token))
239 } else {
240 None
241 }
242 }
243}
244
245impl ParserConfig for DirSearchConfig {
246 fn enable_path_segments(&self) -> bool {
247 false
248 }
249
250 fn enable_extension(&self) -> bool {
251 false
252 }
253
254 fn enable_type_filter(&self) -> bool {
255 false
256 }
257
258 fn enable_git_status(&self) -> bool {
259 false
260 }
261}
262
263/// Configuration for mixed (files + directories) search.
264///
265/// Like `DirSearchConfig`, disables path segment parsing so trailing `/`
266/// triggers dirs-only mode instead of becoming a constraint. Keeps git
267/// status and extension filters enabled since files are part of the results.
268#[derive(Debug, Clone, Copy, Default)]
269pub struct MixedSearchConfig;
270
271impl ParserConfig for MixedSearchConfig {
272 fn enable_path_segments(&self) -> bool {
273 false
274 }
275}