1use std::path::Path;
13
14use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
15use nucleo::{Matcher, Utf32Str};
16
17const MAX_FILES: usize = 5000;
19pub const MAX_RESULTS: usize = 20;
21const MAX_EMPTY_QUERY: usize = 12;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct FileSearchResult {
27 pub path: String,
29 pub score: u32,
31}
32
33#[derive(Clone, Debug)]
36pub struct FileSearchState {
37 pub query: String,
39 pub at_offset: usize,
41 pub hidden_mode: bool,
43 pub results: Vec<FileSearchResult>,
45 pub selected: usize,
47 pub index: Vec<String>,
49 pub line_mode: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct AtToken {
57 pub at_offset: usize,
59 pub path_query: String,
61 pub line_range: Option<(usize, usize)>,
63 pub hidden_request: bool,
65}
66
67pub fn parse_at_cursor(buffer: &str, cursor: usize) -> Option<AtToken> {
80 let cursor = cursor.min(buffer.len());
81 let before = &buffer[..cursor];
82
83 let at_rel = before.rfind('@')?;
85 let at_offset = at_rel;
86
87 if at_offset > 0 {
90 let prev = before[..at_offset].chars().last().unwrap_or(' ');
91 if prev.is_alphanumeric() || prev == '_' {
92 return None;
93 }
94 }
95
96 let after_at = &buffer[at_offset + 1..cursor];
99 if after_at.chars().any(|c| c.is_whitespace()) {
100 return None;
101 }
102
103 let (hidden_request, rest) = if let Some(stripped) = after_at.strip_prefix('!') {
105 (true, stripped)
106 } else {
107 (false, after_at)
108 };
109
110 let (path_query, line_range) = split_path_and_range(rest);
112
113 Some(AtToken {
114 at_offset,
115 path_query,
116 line_range,
117 hidden_request,
118 })
119}
120
121fn split_path_and_range(rest: &str) -> (String, Option<(usize, usize)>) {
124 if let Some(colon) = rest.rfind(':')
129 && rest[colon + 1..]
130 .chars()
131 .all(|c| c.is_ascii_digit() || c == '-')
132 {
133 let path_part = &rest[..colon];
134 let range_part = &rest[colon + 1..];
135 if let Some(range) = parse_line_range(range_part) {
138 return (path_part.to_string(), Some(range));
139 }
140 }
141 (rest.to_string(), None)
142}
143
144fn parse_line_range(s: &str) -> Option<(usize, usize)> {
146 if s.is_empty() {
147 return None;
148 }
149 if let Some(dash) = s.find('-') {
150 let start: usize = s[..dash].parse().ok()?;
151 let end: usize = s[dash + 1..].parse().ok()?;
152 (start > 0 && end >= start).then_some((start, end))
153 } else {
154 let n: usize = s.parse().ok()?;
155 (n > 0).then_some((n, n))
156 }
157}
158
159pub fn build_index(cwd: &Path, hidden: bool) -> Vec<String> {
165 let mut files = Vec::new();
166 let mut builder = ignore::WalkBuilder::new(cwd);
167 builder
168 .hidden(!hidden) .git_ignore(true)
170 .git_global(true)
171 .git_exclude(true)
172 .ignore(true)
173 .parents(true)
174 .threads(2);
175
176 let walker = builder.build();
179 for entry in walker.flatten() {
180 if files.len() >= MAX_FILES {
181 break;
182 }
183 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
184 continue;
185 }
186 if let Ok(rel) = entry.path().strip_prefix(cwd) {
187 if rel.starts_with(".git") {
189 continue;
190 }
191 if let Some(s) = rel.to_str() {
192 files.push(s.to_string());
193 }
194 }
195 }
196 files
197}
198
199pub fn search(index: &[String], query: &str, max: usize) -> Vec<FileSearchResult> {
204 if query.is_empty() {
205 let mut roots: Vec<&String> = index
206 .iter()
207 .filter(|p| !p.contains(std::path::MAIN_SEPARATOR))
208 .collect();
209 roots.sort();
210 return roots
211 .into_iter()
212 .take(max.min(MAX_EMPTY_QUERY))
213 .map(|p| FileSearchResult {
214 path: p.clone(),
215 score: 0,
216 })
217 .collect();
218 }
219
220 let pattern = Pattern::new(
221 query,
222 CaseMatching::Smart,
223 Normalization::Smart,
224 AtomKind::Fuzzy,
225 );
226 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
227
228 let mut scored: Vec<FileSearchResult> = index
229 .iter()
230 .filter_map(|path| {
231 let haystack = Utf32Str::Ascii(path.as_bytes());
232 let score = pattern.score(haystack, &mut matcher)?;
233 Some(FileSearchResult {
234 path: path.clone(),
235 score,
236 })
237 })
238 .collect();
239
240 scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
241 scored.truncate(max);
242 scored
243}
244
245pub fn insertion_text(path: &str, line_range: Option<(usize, usize)>, line_mode: bool) -> String {
250 if line_mode {
251 match line_range {
252 Some((start, end)) if start == end => format!("@{path}:{start} "),
253 Some((start, end)) => format!("@{path}:{start}-{end} "),
254 None => format!("@{path}:"),
255 }
256 } else {
257 format!("@{path} ")
258 }
259}
260
261pub fn open(cwd: &Path, at_offset: usize, hidden_mode: bool) -> FileSearchState {
264 let index = build_index(cwd, hidden_mode);
265 let results = search(&index, "", MAX_RESULTS);
266 FileSearchState {
267 query: String::new(),
268 at_offset,
269 hidden_mode,
270 results,
271 selected: 0,
272 index,
273 line_mode: false,
274 }
275}
276
277impl FileSearchState {
278 pub fn refresh(&mut self, query: &str) {
280 self.query = query.to_string();
281 self.results = search(&self.index, query, MAX_RESULTS);
282 self.selected = 0;
283 }
284
285 pub fn up(&mut self) {
287 if !self.results.is_empty() {
288 self.selected = if self.selected == 0 {
289 self.results.len() - 1
290 } else {
291 self.selected - 1
292 };
293 }
294 }
295
296 pub fn down(&mut self) {
298 if !self.results.is_empty() {
299 self.selected = if self.selected + 1 >= self.results.len() {
300 0
301 } else {
302 self.selected + 1
303 };
304 }
305 }
306
307 pub fn selected_result(&self) -> Option<&FileSearchResult> {
309 self.results.get(self.selected)
310 }
311
312 pub fn toggle_hidden(&mut self, cwd: &Path) {
314 self.hidden_mode = !self.hidden_mode;
315 self.index = build_index(cwd, self.hidden_mode);
316 self.refresh(&self.query.clone());
317 }
318}
319
320#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
331 fn at_at_buffer_start_triggers() {
332 let tok = parse_at_cursor("@foo", 4).unwrap();
333 assert_eq!(tok.at_offset, 0);
334 assert_eq!(tok.path_query, "foo");
335 assert_eq!(tok.line_range, None);
336 assert!(!tok.hidden_request);
337 }
338
339 #[test]
340 fn at_after_space_triggers() {
341 let tok = parse_at_cursor("hello @foo", 10).unwrap();
342 assert_eq!(tok.at_offset, 6);
343 assert_eq!(tok.path_query, "foo");
344 }
345
346 #[test]
347 fn email_is_not_a_trigger() {
348 assert!(parse_at_cursor("foo@bar.com", 11).is_none());
349 assert!(parse_at_cursor("user_name@host", 14).is_none());
350 }
351
352 #[test]
353 fn at_after_underscore_blocked() {
354 assert!(parse_at_cursor("_@foo", 5).is_none());
355 }
356
357 #[test]
358 fn at_after_punctuation_triggers() {
359 let tok = parse_at_cursor("(@foo", 5).unwrap();
361 assert_eq!(tok.path_query, "foo");
362 }
363
364 #[test]
365 fn whitespace_closes_token() {
366 assert!(parse_at_cursor("@foo bar", 8).is_none());
368 }
369
370 #[test]
371 fn hidden_toggle_detected() {
372 let tok = parse_at_cursor("@!foo", 5).unwrap();
373 assert!(tok.hidden_request);
374 assert_eq!(tok.path_query, "foo");
375 }
376
377 #[test]
378 fn hidden_toggle_alone() {
379 let tok = parse_at_cursor("@!", 2).unwrap();
380 assert!(tok.hidden_request);
381 assert!(tok.path_query.is_empty());
382 }
383
384 #[test]
385 fn line_range_single() {
386 let tok = parse_at_cursor("@foo:42", 7).unwrap();
387 assert_eq!(tok.path_query, "foo");
388 assert_eq!(tok.line_range, Some((42, 42)));
389 }
390
391 #[test]
392 fn line_range_multi() {
393 let tok = parse_at_cursor("@foo:10-25", 10).unwrap();
394 assert_eq!(tok.path_query, "foo");
395 assert_eq!(tok.line_range, Some((10, 25)));
396 }
397
398 #[test]
399 fn line_range_invalid_zero() {
400 let tok = parse_at_cursor("@foo:0", 6).unwrap();
401 assert_eq!(tok.path_query, "foo:0");
402 assert_eq!(tok.line_range, None);
403 }
404
405 #[test]
406 fn line_range_inverted_rejected() {
407 let tok = parse_at_cursor("@foo:25-10", 10).unwrap();
408 assert_eq!(tok.path_query, "foo:25-10");
409 assert_eq!(tok.line_range, None);
410 }
411
412 #[test]
413 fn no_at_symbol_no_trigger() {
414 assert!(parse_at_cursor("hello world", 11).is_none());
415 }
416
417 #[test]
418 fn bare_at_symbol_triggers_empty_query() {
419 let tok = parse_at_cursor("@", 1).unwrap();
420 assert!(tok.path_query.is_empty());
421 assert!(!tok.hidden_request);
422 }
423
424 #[test]
427 fn insertion_normal() {
428 assert_eq!(insertion_text("src/foo.rs", None, false), "@src/foo.rs ");
429 }
430
431 #[test]
432 fn insertion_line_mode_no_range() {
433 assert_eq!(insertion_text("foo.rs", None, true), "@foo.rs:");
434 }
435
436 #[test]
437 fn insertion_line_mode_single() {
438 assert_eq!(
439 insertion_text("foo.rs", Some((10, 10)), true),
440 "@foo.rs:10 "
441 );
442 }
443
444 #[test]
445 fn insertion_line_mode_range() {
446 assert_eq!(
447 insertion_text("foo.rs", Some((10, 25)), true),
448 "@foo.rs:10-25 "
449 );
450 }
451
452 #[test]
455 fn empty_query_returns_root_files() {
456 let index = vec![
457 "src/main.rs".into(),
458 "README.md".into(),
459 "Cargo.toml".into(),
460 "src/lib.rs".into(),
461 ];
462 let results = search(&index, "", 20);
463 let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
465 assert_eq!(paths, vec!["Cargo.toml", "README.md"]);
466 }
467
468 #[test]
469 fn fuzzy_query_ranks_by_score() {
470 let index = vec![
471 "src/main.rs".into(),
472 "src/maine.rs".into(),
473 "README.md".into(),
474 ];
475 let results = search(&index, "main", 20);
476 assert!(!results.is_empty());
477 assert_eq!(results[0].path, "src/main.rs");
479 }
480
481 #[test]
482 fn search_truncates_to_max() {
483 let index: Vec<String> = (0..100).map(|i| format!("file_{i}.rs")).collect();
484 let results = search(&index, "file", 5);
485 assert_eq!(results.len(), 5);
486 }
487
488 #[test]
489 fn no_matches_returns_empty() {
490 let index = vec!["foo.rs".into()];
491 let results = search(&index, "zzzzzzzzz", 20);
492 assert!(results.is_empty());
493 }
494
495 #[test]
498 fn nav_down_wraps() {
499 let mut state = FileSearchState {
500 query: String::new(),
501 at_offset: 0,
502 hidden_mode: false,
503 results: vec![
504 FileSearchResult {
505 path: "a".into(),
506 score: 0,
507 },
508 FileSearchResult {
509 path: "b".into(),
510 score: 0,
511 },
512 FileSearchResult {
513 path: "c".into(),
514 score: 0,
515 },
516 ],
517 selected: 0,
518 index: vec![],
519 line_mode: false,
520 };
521 state.down();
522 assert_eq!(state.selected, 1);
523 state.down();
524 assert_eq!(state.selected, 2);
525 state.down();
526 assert_eq!(state.selected, 0); }
528
529 #[test]
530 fn nav_up_wraps() {
531 let mut state = FileSearchState {
532 query: String::new(),
533 at_offset: 0,
534 hidden_mode: false,
535 results: vec![
536 FileSearchResult {
537 path: "a".into(),
538 score: 0,
539 },
540 FileSearchResult {
541 path: "b".into(),
542 score: 0,
543 },
544 ],
545 selected: 0,
546 index: vec![],
547 line_mode: false,
548 };
549 state.up();
550 assert_eq!(state.selected, 1); }
552
553 #[test]
554 fn refresh_resets_selection() {
555 let mut state = FileSearchState {
556 query: String::new(),
557 at_offset: 0,
558 hidden_mode: false,
559 results: vec![],
560 selected: 5,
561 index: vec!["foo.rs".into(), "bar.rs".into()],
562 line_mode: false,
563 };
564 state.refresh("foo");
565 assert_eq!(state.selected, 0);
566 assert_eq!(state.results.len(), 1);
567 }
568}