1use anyhow::Result;
2use ck_core::{CkError, IncludePattern, SearchMode, SearchOptions, SearchResult, Span};
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use rayon::prelude::*;
5use regex::{Regex, RegexBuilder};
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf as StdPathBuf;
9use std::path::{Path, PathBuf};
10use tantivy::collector::TopDocs;
11use tantivy::query::{Query, QueryParser};
12use tantivy::schema::{STORED, Schema, TEXT, Value};
13use tantivy::{Index, ReloadPolicy, TantivyDocument, doc};
14use walkdir::WalkDir;
15
16mod semantic_v3;
17pub use semantic_v3::{semantic_search_v3, semantic_search_v3_with_progress};
18
19pub type SearchProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
20pub type IndexingProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
21pub type DetailedIndexingProgressCallback = Box<dyn Fn(ck_index::EmbeddingProgress) + Send + Sync>;
22
23fn resolve_content_path(file_path: &Path, repo_root: &Path) -> Result<PathBuf> {
27 if ck_core::pdf::is_pdf_file(file_path) {
28 let cache_path = ck_core::pdf::get_content_cache_path(repo_root, file_path);
30 if !cache_path.exists() {
31 return Err(anyhow::anyhow!(
32 "PDF not preprocessed. Run 'ck --index' first."
33 ));
34 }
35 Ok(cache_path)
36 } else {
37 Ok(file_path.to_path_buf())
39 }
40}
41
42fn read_file_content(file_path: &Path, repo_root: &Path) -> Result<String> {
46 let content_path = resolve_content_path(file_path, repo_root)?;
47 Ok(fs::read_to_string(content_path)?)
48}
49
50async fn extract_content_from_span(file_path: &Path, span: &ck_core::Span) -> Result<String> {
52 let repo_root = find_nearest_index_root(file_path)
54 .unwrap_or_else(|| file_path.parent().unwrap_or(file_path).to_path_buf());
55
56 let content_path = resolve_content_path(file_path, &repo_root)?;
58
59 extract_lines_from_file(&content_path, span.line_start, span.line_end)
61}
62
63fn extract_lines_from_file(file_path: &Path, line_start: usize, line_end: usize) -> Result<String> {
65 use std::io::{BufRead, BufReader};
66
67 if line_start == 0 {
68 return Ok(String::new());
69 }
70
71 let file = fs::File::open(file_path)?;
72 let reader = BufReader::new(file);
73 let mut result = Vec::new();
74
75 let start_idx = line_start.saturating_sub(1);
77 let end_idx = line_end.saturating_sub(1);
78
79 for (current_line, line_result) in reader.lines().enumerate() {
80 if current_line > end_idx {
81 break; }
83
84 let line = line_result?;
85
86 if current_line >= start_idx {
87 result.push(line);
88 }
89 }
90
91 if result.is_empty() && line_start > 0 {
93 return Ok(String::new());
94 }
95
96 Ok(result.join("\n"))
97}
98
99fn split_lines_with_endings(content: &str) -> (Vec<String>, Vec<usize>) {
102 let mut lines = Vec::new();
103 let mut endings = Vec::new();
104
105 let bytes = content.as_bytes();
106 let mut start = 0usize;
107 let mut i = 0usize;
108
109 while i < bytes.len() {
110 match bytes[i] {
111 b'\n' => {
112 lines.push(content[start..i].to_string());
113 endings.push(1);
114 i += 1;
115 start = i;
116 }
117 b'\r' => {
118 lines.push(content[start..i].to_string());
119 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
120 endings.push(2);
121 i += 2;
122 } else {
123 endings.push(1);
124 i += 1;
125 }
126 start = i;
127 }
128 _ => {
129 i += 1;
130 }
131 }
132 }
133
134 if start < bytes.len() {
135 lines.push(content[start..].to_string());
136 endings.push(0);
137 }
138
139 (lines, endings)
140}
141
142fn canonicalize_for_matching(path: &Path) -> PathBuf {
143 if let Ok(canonical) = path.canonicalize() {
144 return canonical;
145 }
146
147 if path.is_absolute() {
148 path.to_path_buf()
149 } else {
150 std::env::current_dir()
151 .map(|cwd| cwd.join(path))
152 .unwrap_or_else(|_| path.to_path_buf())
153 }
154}
155
156fn path_matches_include(path: &Path, include_patterns: &[IncludePattern]) -> bool {
157 if include_patterns.is_empty() {
158 return true;
159 }
160
161 let candidate = canonicalize_for_matching(path);
162 include_patterns.iter().any(|pattern| {
163 if pattern.is_dir {
164 candidate.starts_with(&pattern.path)
165 } else {
166 candidate == pattern.path
167 }
168 })
169}
170
171fn filter_files_by_include(
172 files: Vec<PathBuf>,
173 include_patterns: &[IncludePattern],
174) -> Vec<PathBuf> {
175 if include_patterns.is_empty() {
176 return files;
177 }
178
179 files
180 .into_iter()
181 .filter(|path| path_matches_include(path, include_patterns))
182 .collect()
183}
184
185fn find_nearest_index_root(path: &Path) -> Option<StdPathBuf> {
186 let mut current = if path.is_file() {
187 path.parent().unwrap_or(path)
188 } else {
189 path
190 };
191 loop {
192 if ck_core::index_exists(current) {
193 return Some(current.to_path_buf());
194 }
195 match current.parent() {
196 Some(parent) => current = parent,
197 None => return None,
198 }
199 }
200}
201
202#[derive(Clone, Debug)]
203pub struct ResolvedModel {
204 pub alias: String,
205 pub config: ck_models::ModelConfig,
206}
207
208impl ResolvedModel {
209 pub fn canonical_name(&self) -> &str {
210 self.config.name.as_str()
211 }
212
213 pub fn dimensions(&self) -> usize {
214 self.config.dimensions
215 }
216}
217
218fn legacy_model_config(name: &str, dimensions: usize) -> ck_models::ModelConfig {
219 ck_models::ModelConfig {
220 name: name.to_string(),
221 provider: "fastembed".to_string(),
222 dimensions,
223 max_tokens: 8192,
224 description: "Legacy ck embedding model preserved for backwards compatibility".to_string(),
225 }
226}
227
228pub(crate) fn resolve_model_from_root(
229 index_root: &Path,
230 cli_model: Option<&str>,
231) -> Result<ResolvedModel> {
232 use ck_models::ModelRegistry;
233
234 let registry = ModelRegistry::default();
235 let index_dir = ck_core::index_dir(index_root);
236 let manifest_path = index_dir.join("manifest.json");
237
238 if manifest_path.exists() {
239 let data = std::fs::read(&manifest_path)?;
240 let manifest: ck_index::IndexManifest = serde_json::from_slice(&data)?;
241
242 if let Some(existing_model) = manifest.embedding_model {
243 let dims_hint = manifest.embedding_dimensions.unwrap_or(384);
244 let resolved_existing = match registry.resolve(Some(existing_model.as_str())) {
245 Ok((alias, config)) => ResolvedModel { alias, config },
246 Err(_) => ResolvedModel {
247 alias: existing_model.clone(),
248 config: legacy_model_config(&existing_model, dims_hint),
249 },
250 };
251
252 if let Some(requested) = cli_model {
253 let (requested_alias, requested_config) = registry
254 .resolve(Some(requested))
255 .map_err(|e| CkError::Embedding(e.to_string()))?;
256
257 if requested_config.name != resolved_existing.config.name {
258 let suggested_alias = resolved_existing.alias.clone();
259 return Err(CkError::Embedding(format!(
260 "Index was built with embedding model '{}' (alias '{}'), but '--model {}' was requested. To switch models run `ck --clean .` then `ck --index --model {}`. To keep using this index rerun your command with '--model {}'.",
261 resolved_existing.config.name,
262 suggested_alias,
263 requested,
264 requested,
265 suggested_alias
266 ))
267 .into());
268 }
269
270 return Ok(ResolvedModel {
271 alias: requested_alias,
272 config: requested_config,
273 });
274 }
275
276 return Ok(resolved_existing);
277 }
278 }
279
280 let (alias, config) = registry
281 .resolve(cli_model)
282 .map_err(|e| CkError::Embedding(e.to_string()))?;
283
284 Ok(ResolvedModel { alias, config })
285}
286
287pub fn resolve_model_for_path(path: &Path, cli_model: Option<&str>) -> Result<ResolvedModel> {
288 let index_root = find_nearest_index_root(path).unwrap_or_else(|| {
289 if path.is_file() {
290 path.parent().unwrap_or(path).to_path_buf()
291 } else {
292 path.to_path_buf()
293 }
294 });
295 resolve_model_from_root(&index_root, cli_model)
296}
297
298pub async fn search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
299 let results = search_enhanced(options).await?;
300 Ok(results.matches)
301}
302
303pub async fn search_with_progress(
304 options: &SearchOptions,
305 progress_callback: Option<SearchProgressCallback>,
306) -> Result<Vec<SearchResult>> {
307 let results = search_enhanced_with_progress(options, progress_callback).await?;
308 Ok(results.matches)
309}
310
311pub async fn search_enhanced(options: &SearchOptions) -> Result<ck_core::SearchResults> {
313 search_enhanced_with_progress(options, None).await
314}
315
316pub async fn search_enhanced_with_progress(
318 options: &SearchOptions,
319 progress_callback: Option<SearchProgressCallback>,
320) -> Result<ck_core::SearchResults> {
321 search_enhanced_with_indexing_progress(options, progress_callback, None, None).await
322}
323
324#[derive(Debug, Clone, Default)]
327pub struct IndexUpdate {
328 pub files_indexed: usize,
329 pub orphaned_files_removed: usize,
330 pub duration_ms: u64,
333}
334
335impl IndexUpdate {
336 pub fn did_work(&self) -> bool {
339 self.files_indexed > 0 || self.orphaned_files_removed > 0
340 }
341}
342
343#[derive(Debug)]
346pub struct SearchOutcome {
347 pub results: ck_core::SearchResults,
348 pub index_update: Option<IndexUpdate>,
350}
351
352pub async fn search_enhanced_with_indexing_progress(
353 options: &SearchOptions,
354 progress_callback: Option<SearchProgressCallback>,
355 indexing_progress_callback: Option<IndexingProgressCallback>,
356 detailed_indexing_progress_callback: Option<DetailedIndexingProgressCallback>,
357) -> Result<ck_core::SearchResults> {
358 let outcome = search_enhanced_with_outcome(
359 options,
360 progress_callback,
361 indexing_progress_callback,
362 detailed_indexing_progress_callback,
363 )
364 .await?;
365 Ok(outcome.results)
366}
367
368pub async fn search_enhanced_with_outcome(
369 options: &SearchOptions,
370 progress_callback: Option<SearchProgressCallback>,
371 indexing_progress_callback: Option<IndexingProgressCallback>,
372 detailed_indexing_progress_callback: Option<DetailedIndexingProgressCallback>,
373) -> Result<SearchOutcome> {
374 if !options.path.exists() {
376 return Err(ck_core::CkError::Search(format!(
377 "Path does not exist: {}",
378 options.path.display()
379 ))
380 .into());
381 }
382
383 let mut index_update = None;
385 if !matches!(options.mode, SearchMode::Regex) {
386 let need_embeddings = matches!(options.mode, SearchMode::Semantic | SearchMode::Hybrid);
387 let file_options = ck_core::FileCollectionOptions::from(options);
388 let started = std::time::Instant::now();
389 let update_stats = ensure_index_updated_with_progress(
390 &options.path,
391 options.reindex,
392 need_embeddings,
393 indexing_progress_callback,
394 detailed_indexing_progress_callback,
395 &file_options,
396 options.embedding_model.as_deref(),
397 )
398 .await?;
399 index_update = Some(IndexUpdate {
400 files_indexed: update_stats
401 .as_ref()
402 .map(|s| s.files_indexed)
403 .unwrap_or_default(),
404 orphaned_files_removed: update_stats
405 .as_ref()
406 .map(|s| s.orphaned_files_removed)
407 .unwrap_or_default(),
408 duration_ms: started.elapsed().as_millis() as u64,
409 });
410 }
411
412 let search_results = match options.mode {
413 SearchMode::Regex => {
414 let matches = regex_search(options)?;
415 ck_core::SearchResults {
416 matches,
417 closest_below_threshold: None,
418 }
419 }
420 SearchMode::Lexical => {
421 let matches = lexical_search(options).await?;
422 ck_core::SearchResults {
423 matches,
424 closest_below_threshold: None,
425 }
426 }
427 SearchMode::Semantic => {
428 semantic_search_v3_with_progress(options, progress_callback).await?
430 }
431 SearchMode::Hybrid => {
432 let matches = hybrid_search_with_progress(options, progress_callback).await?;
433 ck_core::SearchResults {
434 matches,
435 closest_below_threshold: None,
436 }
437 }
438 };
439
440 Ok(SearchOutcome {
441 results: search_results,
442 index_update,
443 })
444}
445
446fn regex_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
447 let pattern = if options.fixed_string {
448 regex::escape(&options.query)
449 } else if options.whole_word {
450 format!(r"\b{}\b", regex::escape(&options.query))
451 } else {
452 options.query.clone()
453 };
454
455 let regex = RegexBuilder::new(&pattern)
456 .case_insensitive(options.case_insensitive)
457 .build()
458 .map_err(CkError::Regex)?;
459
460 let should_recurse = options.path.is_dir() || options.recursive;
462 let files = if should_recurse {
463 let file_options = ck_core::FileCollectionOptions {
465 respect_gitignore: options.respect_gitignore,
466 use_ckignore: options.use_ckignore,
467 exclude_patterns: options.exclude_patterns.clone(),
468 show_hidden: options.hidden,
469 };
470 let collected = ck_index::collect_files(&options.path, &file_options)?;
471 filter_files_by_include(collected, &options.include_patterns)
472 } else {
473 let collected = collect_files(&options.path, should_recurse, &options.exclude_patterns)?;
475 filter_files_by_include(collected, &options.include_patterns)
476 };
477
478 let results: Vec<Vec<SearchResult>> = files
479 .par_iter()
480 .filter_map(|file_path| match search_file(®ex, file_path, options) {
481 Ok(matches) => {
482 if matches.is_empty() {
483 None
484 } else {
485 Some(matches)
486 }
487 }
488 Err(e) => {
489 tracing::debug!("Error searching {:?}: {}", file_path, e);
490 None
491 }
492 })
493 .collect();
494
495 let mut all_results: Vec<SearchResult> = results.into_iter().flatten().collect();
496 all_results.sort_by(|a, b| {
498 let path_cmp = a.file.cmp(&b.file);
499 if path_cmp != std::cmp::Ordering::Equal {
500 return path_cmp;
501 }
502 a.span.line_start.cmp(&b.span.line_start)
503 });
504
505 if let Some(top_k) = options.top_k {
506 all_results.truncate(top_k);
507 }
508
509 Ok(all_results)
510}
511
512fn search_file(
513 regex: &Regex,
514 file_path: &Path,
515 options: &SearchOptions,
516) -> Result<Vec<SearchResult>> {
517 let repo_root = find_nearest_index_root(file_path)
519 .unwrap_or_else(|| file_path.parent().unwrap_or(file_path).to_path_buf());
520
521 if options.full_section || options.context_lines > 0 {
525 let content = read_file_content(file_path, &repo_root)?;
527 let (lines, line_ending_lengths) = split_lines_with_endings(&content);
528
529 let code_sections = if options.full_section {
531 extract_code_sections(file_path, &content)
532 } else {
533 None
534 };
535
536 search_file_in_memory(
537 regex,
538 file_path,
539 options,
540 &lines,
541 &code_sections,
542 &line_ending_lengths,
543 )
544 } else {
545 search_file_streaming(regex, file_path, &repo_root, options)
547 }
548}
549
550fn search_file_in_memory(
552 regex: &Regex,
553 file_path: &Path,
554 options: &SearchOptions,
555 lines: &[String],
556 code_sections: &Option<Vec<(usize, usize, String)>>,
557 line_ending_lengths: &[usize],
558) -> Result<Vec<SearchResult>> {
559 let mut results = Vec::new();
560 let mut byte_offset = 0;
561
562 for (line_idx, line) in lines.iter().enumerate() {
563 let line_number = line_idx + 1;
564
565 if regex.as_str().is_empty() {
568 let preview = if options.full_section {
570 if let Some(sections) = code_sections {
572 if let Some(section) = find_containing_section(sections, line_idx) {
573 section.clone()
574 } else {
575 get_context_preview(lines, line_idx, options)
577 }
578 } else {
579 get_context_preview(lines, line_idx, options)
580 }
581 } else {
582 get_context_preview(lines, line_idx, options)
583 };
584
585 results.push(SearchResult {
586 file: file_path.to_path_buf(),
587 span: Span {
588 byte_start: byte_offset,
589 byte_end: byte_offset + line.len(),
590 line_start: line_number,
591 line_end: line_number,
592 },
593 score: 1.0,
594 preview,
595 lang: ck_core::Language::from_path(file_path),
596 symbol: None,
597 chunk_hash: None,
598 index_epoch: None,
599 });
600 } else {
601 for mat in regex.find_iter(line) {
603 let preview = if options.full_section {
604 if let Some(sections) = code_sections {
606 if let Some(section) = find_containing_section(sections, line_idx) {
607 section.clone()
608 } else {
609 get_context_preview(lines, line_idx, options)
611 }
612 } else {
613 get_context_preview(lines, line_idx, options)
614 }
615 } else {
616 get_context_preview(lines, line_idx, options)
617 };
618
619 results.push(SearchResult {
620 file: file_path.to_path_buf(),
621 span: Span {
622 byte_start: byte_offset + mat.start(),
623 byte_end: byte_offset + mat.end(),
624 line_start: line_number,
625 line_end: line_number,
626 },
627 score: 1.0,
628 preview,
629 lang: ck_core::Language::from_path(file_path),
630 symbol: None,
631 chunk_hash: None,
632 index_epoch: None,
633 });
634 }
635 }
636
637 byte_offset += line.len();
639 byte_offset += line_ending_lengths.get(line_idx).copied().unwrap_or(0);
640 }
641
642 Ok(results)
643}
644
645fn search_file_streaming(
647 regex: &Regex,
648 file_path: &Path,
649 repo_root: &Path,
650 _options: &SearchOptions,
651) -> Result<Vec<SearchResult>> {
652 use std::io::{BufRead, BufReader};
653
654 let content_path = resolve_content_path(file_path, repo_root)?;
655 let file = std::fs::File::open(&content_path)?;
656 let mut reader = BufReader::new(file);
657
658 let mut results = Vec::new();
659 let mut line = String::new();
660 let mut byte_offset = 0usize;
661 let mut line_number = 1usize;
662
663 loop {
664 line.clear();
665 let bytes_read = reader.read_line(&mut line)?;
666 if bytes_read == 0 {
667 break;
668 }
669
670 let mut newline_len = 0usize;
673 if line.ends_with("\r\n") {
674 line.pop(); line.pop(); newline_len = 2;
677 } else if line.ends_with(['\n', '\r']) {
678 line.pop();
679 newline_len = 1;
680 }
681
682 let treat_cr_as_newline = line.contains('\r');
686
687 if treat_cr_as_newline {
688 let bytes = line.as_bytes();
689 let mut segment_start = 0usize;
690 while segment_start <= bytes.len() {
691 match bytes[segment_start..].iter().position(|&b| b == b'\r') {
692 Some(rel_idx) => {
693 let idx = segment_start + rel_idx;
694 let segment_bytes = &bytes[segment_start..idx];
695 let segment_str = std::str::from_utf8(segment_bytes)?;
696 process_streaming_line(
697 regex,
698 file_path,
699 segment_str,
700 line_number,
701 byte_offset,
702 &mut results,
703 );
704 byte_offset += segment_bytes.len() + 1; line_number += 1;
706 segment_start = idx + 1;
707 }
708 None => {
709 let segment_bytes = &bytes[segment_start..];
710 let segment_str = std::str::from_utf8(segment_bytes)?;
711 process_streaming_line(
712 regex,
713 file_path,
714 segment_str,
715 line_number,
716 byte_offset,
717 &mut results,
718 );
719 byte_offset += segment_bytes.len();
720 line_number += 1;
721 break;
722 }
723 }
724 }
725 byte_offset += newline_len;
726 } else {
727 let line_str = line.as_str();
728 process_streaming_line(
729 regex,
730 file_path,
731 line_str,
732 line_number,
733 byte_offset,
734 &mut results,
735 );
736 byte_offset += line_str.len() + newline_len;
737 line_number += 1;
738 }
739 }
740
741 Ok(results)
742}
743
744fn process_streaming_line(
745 regex: &Regex,
746 file_path: &Path,
747 line: &str,
748 line_number: usize,
749 byte_offset: usize,
750 results: &mut Vec<SearchResult>,
751) {
752 if regex.as_str().is_empty() {
753 results.push(SearchResult {
754 file: file_path.to_path_buf(),
755 span: Span {
756 byte_start: byte_offset,
757 byte_end: byte_offset + line.len(),
758 line_start: line_number,
759 line_end: line_number,
760 },
761 score: 1.0,
762 preview: line.to_string(),
763 lang: ck_core::Language::from_path(file_path),
764 symbol: None,
765 chunk_hash: None,
766 index_epoch: None,
767 });
768 } else {
769 for mat in regex.find_iter(line) {
770 results.push(SearchResult {
771 file: file_path.to_path_buf(),
772 span: Span {
773 byte_start: byte_offset + mat.start(),
774 byte_end: byte_offset + mat.end(),
775 line_start: line_number,
776 line_end: line_number,
777 },
778 score: 1.0,
779 preview: line.to_string(),
780 lang: ck_core::Language::from_path(file_path),
781 symbol: None,
782 chunk_hash: None,
783 index_epoch: None,
784 });
785 }
786 }
787}
788
789const TANTIVY_META_FILE: &str = "tantivy_index.meta";
792
793fn lexical_corpus_fingerprint(files: &[PathBuf]) -> String {
798 let mut entries: Vec<String> = files
799 .iter()
800 .map(|f| {
801 let (mtime, size) = fs::metadata(f)
802 .map(|m| {
803 let mtime = m
804 .modified()
805 .ok()
806 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
807 .map(|d| d.as_nanos())
808 .unwrap_or(0);
809 (mtime, m.len())
810 })
811 .unwrap_or((0, 0));
812 format!("{}\x00{}\x00{}", f.display(), mtime, size)
813 })
814 .collect();
815 entries.sort_unstable();
816
817 let mut hasher = blake3::Hasher::new();
818 for entry in &entries {
819 hasher.update(entry.as_bytes());
820 hasher.update(b"\n");
821 }
822 hasher.finalize().to_hex().to_string()
823}
824
825fn lexical_query_terms(query: &str) -> Vec<String> {
828 query
829 .split(|c: char| !c.is_alphanumeric())
830 .filter(|term| !term.is_empty())
831 .map(str::to_lowercase)
832 .collect()
833}
834
835fn locate_lexical_span(
851 file_path: &Path,
852 content: &str,
853 terms: &[String],
854 full_section: bool,
855) -> (Span, String) {
856 let whole_file = || {
857 let span = Span {
858 byte_start: 0,
859 byte_end: content.len(),
860 line_start: 1,
861 line_end: content.lines().count(),
862 };
863 let preview = if full_section {
864 content.to_string()
865 } else {
866 content.lines().take(3).collect::<Vec<_>>().join("\n")
867 };
868 (span, preview)
869 };
870
871 if terms.is_empty() {
872 return whole_file();
873 }
874
875 let lang = if ck_core::pdf::is_pdf_file(file_path) {
877 Some(ck_core::Language::Pdf)
878 } else {
879 ck_core::Language::from_path(file_path)
880 };
881
882 let chunks = match ck_chunk::chunk_text(content, lang) {
883 Ok(chunks) => chunks,
884 Err(_) => return whole_file(),
885 };
886
887 let mut best: Option<(usize, &ck_chunk::Chunk)> = None;
893 for chunk in &chunks {
894 let hits = lexical_query_terms(&chunk.text)
895 .into_iter()
896 .filter(|token| terms.contains(token))
897 .count();
898 if hits == 0 {
899 continue;
900 }
901 if best.is_none_or(|(best_hits, _)| hits > best_hits) {
902 best = Some((hits, chunk));
903 }
904 }
905
906 match best {
907 Some((_, chunk)) => {
908 let preview = if full_section {
909 content.to_string()
910 } else {
911 chunk.text.clone()
912 };
913 (chunk.span.clone(), preview)
914 }
915 None => whole_file(),
916 }
917}
918
919async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
920 let index_root = find_nearest_index_root(&options.path).unwrap_or_else(|| {
922 if options.path.is_file() {
923 options.path.parent().unwrap_or(&options.path).to_path_buf()
924 } else {
925 options.path.clone()
926 }
927 });
928
929 let index_dir = ck_core::index_dir(&index_root);
930 if !index_dir.exists() {
931 return Err(CkError::Index("No index found. Run 'ck index' first.".to_string()).into());
932 }
933 ck_core::check_index_root_marker(&index_root)?;
936
937 let tantivy_index_path = index_dir.join("tantivy_index");
938
939 let file_options = ck_core::FileCollectionOptions {
950 respect_gitignore: options.respect_gitignore,
951 use_ckignore: options.use_ckignore,
952 exclude_patterns: options.exclude_patterns.clone(),
953 show_hidden: options.hidden,
954 };
955 let corpus = ck_index::collect_files(&index_root, &file_options)?;
956 let fingerprint = lexical_corpus_fingerprint(&corpus);
957 let meta_path = index_dir.join(TANTIVY_META_FILE);
958 let is_fresh = tantivy_index_path.exists()
959 && fs::read_to_string(&meta_path)
960 .map(|stored| stored.trim() == fingerprint)
961 .unwrap_or(false);
962
963 if !is_fresh {
964 let _lock = ck_index::acquire_index_write_lock(&index_dir)?;
968 let still_stale = !tantivy_index_path.exists()
969 || fs::read_to_string(&meta_path)
970 .map(|stored| stored.trim() != fingerprint)
971 .unwrap_or(true);
972 if still_stale {
973 tracing::info!(
974 "Lexical index stale or missing for {}; rebuilding from {} files",
975 index_root.display(),
976 corpus.len()
977 );
978 build_tantivy_index(&tantivy_index_path, &corpus)?;
979 fs::write(&meta_path, &fingerprint)?;
980 }
981 }
982
983 let mut schema_builder = Schema::builder();
984 let content_field = schema_builder.add_text_field("content", TEXT | STORED);
985 let path_field = schema_builder.add_text_field("path", TEXT | STORED);
986 let _schema = schema_builder.build();
987
988 let index = Index::open_in_dir(&tantivy_index_path)
989 .map_err(|e| CkError::Index(format!("Failed to open tantivy index: {e}")))?;
990
991 let reader = index
992 .reader_builder()
993 .reload_policy(ReloadPolicy::OnCommitWithDelay)
994 .try_into()
995 .map_err(|e| CkError::Index(format!("Failed to create index reader: {e}")))?;
996
997 let searcher = reader.searcher();
998 let query_parser = QueryParser::for_index(&index, vec![content_field]);
999
1000 let (query, parse_errors) = query_parser.parse_query_lenient(&options.query);
1006 for error in &parse_errors {
1007 tracing::debug!(
1008 "lenient parse of lexical query {:?}: {error:?}",
1009 options.query
1010 );
1011 }
1012
1013 let mut span_terms: Vec<String> = Vec::new();
1018 query.query_terms(&mut |term, _| {
1019 if term.field() == content_field
1020 && let Some(text) = term.value().as_str()
1021 {
1022 let lowered = text.to_lowercase();
1023 if !span_terms.contains(&lowered) {
1024 span_terms.push(lowered);
1025 }
1026 }
1027 });
1028
1029 let top_docs = if let Some(top_k) = options.top_k {
1030 searcher.search(&query, &TopDocs::with_limit(top_k))?
1031 } else {
1032 searcher.search(&query, &TopDocs::with_limit(100))?
1033 };
1034
1035 let mut raw_results = Vec::new();
1037 for (_score, doc_address) in top_docs {
1038 let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
1039 let path_text = retrieved_doc
1040 .get_first(path_field)
1041 .map(|field_value| field_value.as_str().unwrap_or(""))
1042 .unwrap_or("");
1043 let content_text = retrieved_doc
1044 .get_first(content_field)
1045 .map(|field_value| field_value.as_str().unwrap_or(""))
1046 .unwrap_or("");
1047
1048 let file_path = PathBuf::from(path_text);
1049 if !path_matches_include(&file_path, &options.include_patterns) {
1050 continue;
1051 }
1052 let (span, preview) =
1053 locate_lexical_span(&file_path, content_text, &span_terms, options.full_section);
1054
1055 raw_results.push((
1056 _score,
1057 SearchResult {
1058 file: file_path,
1059 span,
1060 score: _score,
1061 preview,
1062 lang: ck_core::Language::from_path(&PathBuf::from(path_text)),
1063 symbol: None,
1064 chunk_hash: None,
1065 index_epoch: None,
1066 },
1067 ));
1068 }
1069
1070 let mut results = Vec::new();
1072 if !raw_results.is_empty() {
1073 let max_score = raw_results
1074 .iter()
1075 .map(|(score, _)| *score)
1076 .fold(0.0f32, f32::max);
1077 if max_score > 0.0 {
1078 for (raw_score, mut result) in raw_results {
1079 let normalized_score = raw_score / max_score;
1080
1081 if let Some(threshold) = options.threshold
1083 && normalized_score < threshold
1084 {
1085 continue;
1086 }
1087
1088 result.score = normalized_score;
1089 results.push(result);
1090 }
1091 }
1092 }
1093
1094 Ok(results)
1095}
1096
1097fn build_tantivy_index(tantivy_index_path: &Path, files: &[PathBuf]) -> Result<()> {
1106 if tantivy_index_path.exists() {
1107 fs::remove_dir_all(tantivy_index_path)?;
1108 }
1109 fs::create_dir_all(tantivy_index_path)?;
1110
1111 let mut schema_builder = Schema::builder();
1112 let content_field = schema_builder.add_text_field("content", TEXT | STORED);
1113 let path_field = schema_builder.add_text_field("path", TEXT | STORED);
1114 let schema = schema_builder.build();
1115
1116 let index = Index::create_in_dir(tantivy_index_path, schema)
1117 .map_err(|e| CkError::Index(format!("Failed to create tantivy index: {e}")))?;
1118
1119 let mut index_writer = index
1120 .writer(50_000_000)
1121 .map_err(|e| CkError::Index(format!("Failed to create index writer: {e}")))?;
1122
1123 for file_path in files {
1124 if let Ok(content) = fs::read_to_string(file_path) {
1125 let doc = doc!(
1126 content_field => content,
1127 path_field => file_path.display().to_string()
1128 );
1129 index_writer.add_document(doc)?;
1130 }
1131 }
1132
1133 index_writer
1134 .commit()
1135 .map_err(|e| CkError::Index(format!("Failed to commit index: {e}")))?;
1136
1137 Ok(())
1138}
1139
1140#[allow(dead_code)]
1141async fn hybrid_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
1142 hybrid_search_with_progress(options, None).await
1143}
1144
1145const HYBRID_STOPWORDS: &[&str] = &[
1149 "the", "and", "for", "with", "that", "this", "from", "into", "when", "where", "how", "what",
1150 "why", "who", "which", "does", "are", "was", "were", "has", "have", "had", "can", "could",
1151 "should", "would", "will", "its", "use", "uses", "used", "using", "between", "over", "under",
1152 "than", "then", "them", "they", "their", "there", "your", "our", "not", "but", "all", "any",
1153 "each", "other", "some", "such", "only", "own", "same", "more", "most", "very", "happen",
1154 "happens", "get", "gets", "code", "function", "where", "place",
1155];
1156
1157fn hybrid_query_terms(query: &str) -> Vec<String> {
1159 let mut seen = std::collections::HashSet::new();
1160 query
1161 .split(|c: char| !c.is_alphanumeric() && c != '_')
1162 .map(str::to_lowercase)
1163 .filter(|t| t.len() >= 3 && !HYBRID_STOPWORDS.contains(&t.as_str()))
1164 .filter(|t| seen.insert(t.clone()))
1165 .collect()
1166}
1167
1168fn hybrid_keyword_search(options: &SearchOptions) -> Result<(Vec<SearchResult>, bool)> {
1177 let literal = regex_search(options)?;
1178 if !literal.is_empty() || options.fixed_string {
1179 return Ok((literal, false));
1180 }
1181
1182 let terms = hybrid_query_terms(&options.query);
1183 if terms.len() < 2 {
1184 return Ok((literal, false));
1185 }
1186
1187 let mut keyword_options = options.clone();
1188 keyword_options.query = terms
1189 .iter()
1190 .map(|t| regex::escape(t))
1191 .collect::<Vec<_>>()
1192 .join("|");
1193 keyword_options.case_insensitive = true;
1194 keyword_options.top_k = None;
1198 let matches = regex_search(&keyword_options)?;
1199
1200 let lowered: Vec<String> = matches.iter().map(|r| r.preview.to_lowercase()).collect();
1206 let doc_freq: Vec<usize> = terms
1207 .iter()
1208 .map(|t| {
1209 lowered
1210 .iter()
1211 .filter(|line| line.contains(String::as_str(t)))
1212 .count()
1213 })
1214 .collect();
1215 let total = matches.len() as f32;
1216 let mut scored: Vec<(f32, SearchResult)> = matches
1220 .into_iter()
1221 .zip(lowered)
1222 .map(|(r, line)| {
1223 let weight: f32 = terms
1224 .iter()
1225 .zip(&doc_freq)
1226 .filter(|(t, _)| line.contains(String::as_str(t)))
1227 .map(|(_, &df)| ((total + 1.0) / (df as f32 + 1.0)).ln())
1228 .sum();
1229 (weight, r)
1230 })
1231 .collect();
1232 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1233 if let Some(top_k) = options.top_k {
1234 scored.truncate(top_k);
1235 }
1236 Ok((scored.into_iter().map(|(_, r)| r).collect(), true))
1237}
1238
1239fn rrf_fuse(
1253 keyword_results: &[SearchResult],
1254 semantic_results: &[SearchResult],
1255 keyword_weight: f32,
1256) -> Vec<SearchResult> {
1257 const RRF_K: f32 = 60.0;
1258
1259 struct Fused {
1260 result: SearchResult,
1261 keyword_rank: Option<usize>,
1262 semantic_rank: Option<usize>,
1263 }
1264
1265 let mut sem_spans: HashMap<String, Vec<(usize, usize, String)>> = HashMap::new();
1267 let mut combined: HashMap<String, Fused> = HashMap::new();
1268
1269 for (rank, result) in semantic_results.iter().enumerate() {
1270 let file = result.file.display().to_string();
1271 let key = format!(
1272 "{}:{}-{}",
1273 file, result.span.line_start, result.span.line_end
1274 );
1275 sem_spans.entry(file).or_default().push((
1276 result.span.line_start,
1277 result.span.line_end,
1278 key.clone(),
1279 ));
1280 combined.entry(key).or_insert(Fused {
1281 result: result.clone(),
1282 keyword_rank: None,
1283 semantic_rank: Some(rank + 1),
1284 });
1285 }
1286
1287 for (rank, result) in keyword_results.iter().enumerate() {
1288 let file = result.file.display().to_string();
1289 let key = sem_spans
1290 .get(&file)
1291 .and_then(|spans| {
1292 spans
1293 .iter()
1294 .find(|(start, end, _)| (*start..=*end).contains(&result.span.line_start))
1295 .map(|(_, _, key)| key.clone())
1296 })
1297 .unwrap_or_else(|| format!("{}:{}", file, result.span.line_start));
1298 combined
1299 .entry(key)
1300 .and_modify(|fused| {
1301 if fused.keyword_rank.is_none() {
1302 fused.keyword_rank = Some(rank + 1);
1303 }
1304 })
1305 .or_insert(Fused {
1306 result: result.clone(),
1307 keyword_rank: Some(rank + 1),
1308 semantic_rank: None,
1309 });
1310 }
1311
1312 combined
1313 .into_values()
1314 .map(|fused| {
1315 let mut result = fused.result;
1316 let rank_score = |rank: Option<usize>| rank.map_or(0.0, |r| 1.0 / (RRF_K + r as f32));
1317 result.score =
1318 keyword_weight * rank_score(fused.keyword_rank) + rank_score(fused.semantic_rank);
1319 result
1320 })
1321 .collect()
1322}
1323
1324async fn hybrid_search_with_progress(
1325 options: &SearchOptions,
1326 progress_callback: Option<SearchProgressCallback>,
1327) -> Result<Vec<SearchResult>> {
1328 let mut arm_options = options.clone();
1333 arm_options.top_k = options.top_k.map(|k| (k * 5).max(50));
1334
1335 if let Some(ref callback) = progress_callback {
1336 callback("Running keyword search...");
1337 }
1338 let (keyword_results, keyword_is_fallback) = hybrid_keyword_search(&arm_options)?;
1339
1340 if let Some(ref callback) = progress_callback {
1341 callback("Running semantic search...");
1342 }
1343 let semantic_results =
1344 semantic_search_v3_with_progress(&arm_options, progress_callback).await?;
1345
1346 let keyword_weight = if keyword_is_fallback { 0.3 } else { 1.0 };
1347 let mut rrf_results = rrf_fuse(&keyword_results, &semantic_results.matches, keyword_weight);
1348
1349 if let Some(threshold) = options.threshold {
1351 rrf_results.retain(|result| result.score >= threshold);
1352 }
1353
1354 rrf_results.retain(|result| path_matches_include(&result.file, &options.include_patterns));
1355
1356 rrf_results.sort_by(|a, b| {
1358 b.score
1359 .partial_cmp(&a.score)
1360 .unwrap_or(std::cmp::Ordering::Equal)
1361 });
1362
1363 if let Some(top_k) = options.top_k {
1364 rrf_results.truncate(top_k);
1365 }
1366
1367 Ok(rrf_results)
1368}
1369
1370fn build_globset(patterns: &[String]) -> GlobSet {
1371 let mut builder = GlobSetBuilder::new();
1372 for pat in patterns {
1373 if let Ok(glob) = Glob::new(pat) {
1375 builder.add(glob);
1376 }
1377 }
1378 builder.build().unwrap_or_else(|_| GlobSet::empty())
1379}
1380
1381fn should_exclude_path(path: &Path, globset: &GlobSet) -> bool {
1382 if globset.is_match(path) {
1384 return true;
1385 }
1386 for component in path.components() {
1387 if let std::path::Component::Normal(name) = component
1388 && globset.is_match(name)
1389 {
1390 return true;
1391 }
1392 }
1393 false
1394}
1395
1396fn collect_files(
1397 path: &Path,
1398 recursive: bool,
1399 exclude_patterns: &[String],
1400) -> Result<Vec<PathBuf>> {
1401 let mut files = Vec::new();
1402 let globset = build_globset(exclude_patterns);
1403
1404 if path.is_file() {
1405 files.push(path.to_path_buf());
1407 } else if recursive {
1408 for entry in WalkDir::new(path).into_iter().filter_entry(|e| {
1409 let name = e.file_name();
1411 !globset.is_match(e.path()) && !globset.is_match(name)
1412 }) {
1413 match entry {
1414 Ok(entry) => {
1415 if entry.file_type().is_file() && !should_exclude_path(entry.path(), &globset) {
1416 files.push(entry.path().to_path_buf());
1417 }
1418 }
1419 Err(e) => {
1420 tracing::debug!("Skipping path due to error: {}", e);
1422 continue;
1423 }
1424 }
1425 }
1426 } else {
1427 match fs::read_dir(path) {
1428 Ok(read_dir) => {
1429 for entry in read_dir {
1430 match entry {
1431 Ok(entry) => {
1432 let path = entry.path();
1433 if path.is_file() && !should_exclude_path(&path, &globset) {
1434 files.push(path);
1435 }
1436 }
1437 Err(e) => {
1438 tracing::debug!("Skipping directory entry due to error: {}", e);
1439 continue;
1440 }
1441 }
1442 }
1443 }
1444 Err(e) => {
1445 tracing::debug!("Cannot read directory {:?}: {}", path, e);
1446 return Err(e.into());
1447 }
1448 }
1449 }
1450
1451 Ok(files)
1452}
1453
1454async fn ensure_index_updated_with_progress(
1457 path: &Path,
1458 force_reindex: bool,
1459 need_embeddings: bool,
1460 progress_callback: Option<ck_index::ProgressCallback>,
1461 detailed_progress_callback: Option<ck_index::DetailedProgressCallback>,
1462 file_options: &ck_core::FileCollectionOptions,
1463 model_override: Option<&str>,
1464) -> Result<Option<ck_index::UpdateStats>> {
1465 let index_root_buf = find_nearest_index_root(path).unwrap_or_else(|| {
1467 if path.is_file() {
1468 path.parent().unwrap_or(path).to_path_buf()
1469 } else {
1470 path.to_path_buf()
1471 }
1472 });
1473 let index_root = &index_root_buf;
1474
1475 if force_reindex {
1478 let stats = ck_index::smart_update_index_with_detailed_progress(
1479 index_root,
1480 true,
1481 progress_callback,
1482 detailed_progress_callback,
1483 need_embeddings,
1484 file_options,
1485 model_override,
1486 )
1487 .await?;
1488 if stats.files_indexed > 0 || stats.orphaned_files_removed > 0 {
1489 tracing::info!(
1490 "Index updated: {} files indexed, {} orphaned files removed",
1491 stats.files_indexed,
1492 stats.orphaned_files_removed
1493 );
1494 }
1495 return Ok(Some(stats));
1496 }
1497
1498 if path.is_file() {
1501 use ck_index::index_file;
1503 index_file(path, need_embeddings).await?;
1504 Ok(None)
1505 } else {
1506 let stats = ck_index::smart_update_index_with_detailed_progress(
1508 index_root,
1509 false,
1510 progress_callback,
1511 detailed_progress_callback,
1512 need_embeddings,
1513 file_options,
1514 model_override,
1515 )
1516 .await?;
1517 if stats.files_indexed > 0 || stats.orphaned_files_removed > 0 {
1518 tracing::info!(
1519 "Index updated: {} files indexed, {} orphaned files removed",
1520 stats.files_indexed,
1521 stats.orphaned_files_removed
1522 );
1523 }
1524 Ok(Some(stats))
1525 }
1526}
1527
1528fn get_context_preview(lines: &[String], line_idx: usize, options: &SearchOptions) -> String {
1529 let before = options.before_context_lines.max(options.context_lines);
1530 let after = options.after_context_lines.max(options.context_lines);
1531
1532 if before > 0 || after > 0 {
1533 let start_idx = line_idx.saturating_sub(before);
1534 let end_idx = (line_idx + after + 1).min(lines.len());
1535 lines[start_idx..end_idx].join("\n")
1536 } else {
1537 lines[line_idx].to_string()
1538 }
1539}
1540
1541fn extract_code_sections(file_path: &Path, content: &str) -> Option<Vec<(usize, usize, String)>> {
1542 let lang = ck_core::Language::from_path(file_path)?;
1543
1544 if let Ok(chunks) = ck_chunk::chunk_text(content, Some(lang)) {
1546 let include_markdown = lang == ck_core::Language::Markdown;
1547 let sections: Vec<(usize, usize, String)> = chunks
1548 .into_iter()
1549 .filter(|chunk| {
1550 if include_markdown {
1551 matches!(
1552 chunk.chunk_type,
1553 ck_chunk::ChunkType::Module | ck_chunk::ChunkType::Text
1554 )
1555 } else {
1556 matches!(
1557 chunk.chunk_type,
1558 ck_chunk::ChunkType::Function
1559 | ck_chunk::ChunkType::Class
1560 | ck_chunk::ChunkType::Method
1561 )
1562 }
1563 })
1564 .map(|chunk| {
1565 (
1566 chunk.span.line_start - 1, chunk.span.line_end - 1,
1568 chunk.text,
1569 )
1570 })
1571 .collect();
1572
1573 if sections.is_empty() {
1574 None
1575 } else {
1576 Some(sections)
1577 }
1578 } else {
1579 None
1580 }
1581}
1582
1583fn find_containing_section(
1584 sections: &[(usize, usize, String)],
1585 line_idx: usize,
1586) -> Option<&String> {
1587 for (start, end, text) in sections {
1588 if line_idx >= *start && line_idx <= *end {
1589 return Some(text);
1590 }
1591 }
1592 None
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598 use std::fs;
1599 use tempfile::TempDir;
1600
1601 fn make_result(file: &str, line_start: usize, line_end: usize, preview: &str) -> SearchResult {
1602 SearchResult {
1603 file: PathBuf::from(file),
1604 span: Span {
1605 byte_start: 0,
1606 byte_end: 0,
1607 line_start,
1608 line_end,
1609 },
1610 score: 0.0,
1611 preview: preview.to_string(),
1612 lang: None,
1613 symbol: None,
1614 chunk_hash: None,
1615 index_epoch: None,
1616 }
1617 }
1618
1619 #[test]
1620 fn test_hybrid_query_terms_filters_stopwords_and_dedupes() {
1621 let terms =
1622 hybrid_query_terms("How does the RRF rank fusion merge rank results from regex?");
1623 assert_eq!(
1624 terms,
1625 vec!["rrf", "rank", "fusion", "merge", "results", "regex"]
1626 );
1627
1628 assert!(hybrid_query_terms("how does the it").is_empty());
1630 }
1631
1632 #[test]
1633 fn test_rrf_fuse_merges_keyword_hit_into_containing_semantic_chunk() {
1634 let semantic = vec![
1636 make_result("src/a.rs", 10, 50, "fn fuse() { /* rrf */ }"),
1637 make_result("src/b.rs", 1, 5, "unrelated chunk"),
1638 ];
1639 let keyword = vec![make_result("src/a.rs", 20, 20, "let rrf_score = ranks")];
1640
1641 let fused = rrf_fuse(&keyword, &semantic, 1.0);
1642
1643 assert_eq!(fused.len(), 2);
1645 let chunk = fused
1646 .iter()
1647 .find(|r| r.file == Path::new("src/a.rs"))
1648 .unwrap();
1649 assert_eq!((chunk.span.line_start, chunk.span.line_end), (10, 50));
1651 let expected = 1.0 / 61.0 + 1.0 / 61.0;
1652 assert!((chunk.score - expected).abs() < 1e-6);
1653
1654 let other = fused
1656 .iter()
1657 .find(|r| r.file == Path::new("src/b.rs"))
1658 .unwrap();
1659 assert!(chunk.score > other.score);
1660 }
1661
1662 #[test]
1663 fn test_rrf_fuse_keyword_only_hit_keeps_own_identity() {
1664 let semantic = vec![make_result("src/a.rs", 10, 50, "chunk")];
1665 let keyword = vec![make_result("src/z.rs", 7, 7, "standalone line")];
1666
1667 let fused = rrf_fuse(&keyword, &semantic, 1.0);
1668 assert_eq!(fused.len(), 2);
1669 let standalone = fused
1670 .iter()
1671 .find(|r| r.file == Path::new("src/z.rs"))
1672 .unwrap();
1673 assert!((standalone.score - 1.0 / 61.0).abs() < 1e-6);
1674 }
1675
1676 #[test]
1677 fn test_rrf_fuse_counts_each_list_once_per_key() {
1678 let semantic = vec![make_result("src/a.rs", 10, 50, "chunk")];
1681 let keyword = vec![
1682 make_result("src/a.rs", 12, 12, "first hit"),
1683 make_result("src/a.rs", 40, 40, "second hit"),
1684 ];
1685
1686 let fused = rrf_fuse(&keyword, &semantic, 1.0);
1687 assert_eq!(fused.len(), 1);
1688 let expected = 1.0 / 61.0 + 1.0 / 61.0; assert!((fused[0].score - expected).abs() < 1e-6);
1690 }
1691
1692 #[test]
1693 fn test_lexical_corpus_fingerprint_tracks_changes() {
1694 let temp_dir = TempDir::new().unwrap();
1695 let a = temp_dir.path().join("a.txt");
1696 let b = temp_dir.path().join("b.txt");
1697 fs::write(&a, "one").unwrap();
1698 fs::write(&b, "two").unwrap();
1699
1700 let original = lexical_corpus_fingerprint(&[a.clone(), b.clone()]);
1701
1702 assert_eq!(
1704 original,
1705 lexical_corpus_fingerprint(&[b.clone(), a.clone()])
1706 );
1707
1708 fs::write(&a, "one but longer").unwrap();
1710 assert_ne!(
1711 original,
1712 lexical_corpus_fingerprint(&[a.clone(), b.clone()])
1713 );
1714
1715 let shrunk = lexical_corpus_fingerprint(std::slice::from_ref(&a));
1717 assert_ne!(shrunk, lexical_corpus_fingerprint(&[a, b]));
1718 }
1719
1720 fn create_test_files(dir: &std::path::Path) -> Vec<PathBuf> {
1721 let files = vec![
1722 ("test1.txt", "hello world rust programming"),
1723 ("test2.rs", "fn main() { println!(\"Hello Rust\"); }"),
1724 ("test3.py", "print('Hello Python')"),
1725 ("test4.txt", "machine learning artificial intelligence"),
1726 ];
1727
1728 let mut paths = Vec::new();
1729 for (name, content) in files {
1730 let path = dir.join(name);
1731 fs::write(&path, content).unwrap();
1732 paths.push(path);
1733 }
1734 paths
1735 }
1736
1737 #[test]
1738 fn test_extract_lines_from_file() {
1739 let temp_dir = TempDir::new().unwrap();
1740 let test_file = temp_dir.path().join("test_lines.txt");
1741
1742 let content =
1744 "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10";
1745 fs::write(&test_file, content).unwrap();
1746
1747 let result = extract_lines_from_file(&test_file, 3, 5).unwrap();
1749 assert_eq!(result, "Line 3\nLine 4\nLine 5");
1750
1751 let result = extract_lines_from_file(&test_file, 7, 7).unwrap();
1753 assert_eq!(result, "Line 7");
1754
1755 let result = extract_lines_from_file(&test_file, 8, 100).unwrap();
1757 assert_eq!(result, "Line 8\nLine 9\nLine 10");
1758
1759 let result = extract_lines_from_file(&test_file, 0, 5).unwrap();
1761 assert_eq!(result, "");
1762
1763 let result = extract_lines_from_file(&test_file, 20, 25).unwrap();
1765 assert_eq!(result, "");
1766 }
1767
1768 #[tokio::test]
1769 async fn test_extract_content_from_span() {
1770 let temp_dir = TempDir::new().unwrap();
1771 let test_file = temp_dir.path().join("code.rs");
1772
1773 let content = "fn first() {\n println!(\"First\");\n}\n\nfn second() {\n println!(\"Second\");\n}\n\nfn third() {\n println!(\"Third\");\n}";
1775 fs::write(&test_file, content).unwrap();
1776
1777 let span = ck_core::Span {
1779 byte_start: 0, byte_end: 0, line_start: 5,
1782 line_end: 7,
1783 };
1784
1785 let result = extract_content_from_span(&test_file, &span).await.unwrap();
1786 assert_eq!(result, "fn second() {\n println!(\"Second\");\n}");
1787
1788 let span = ck_core::Span {
1790 byte_start: 0,
1791 byte_end: 0,
1792 line_start: 2,
1793 line_end: 2,
1794 };
1795
1796 let result = extract_content_from_span(&test_file, &span).await.unwrap();
1797 assert_eq!(result, " println!(\"First\");");
1798 }
1799
1800 #[test]
1801 fn test_collect_files() {
1802 let temp_dir = TempDir::new().unwrap();
1803 let test_files = create_test_files(temp_dir.path());
1804
1805 let files = collect_files(temp_dir.path(), false, &[]).unwrap();
1807 assert_eq!(files.len(), 4);
1808
1809 let files = collect_files(temp_dir.path(), true, &[]).unwrap();
1811 assert_eq!(files.len(), 4);
1812
1813 let files = collect_files(&test_files[0], false, &[]).unwrap();
1815 assert_eq!(files.len(), 1);
1816 assert_eq!(files[0], test_files[0]);
1817 }
1818
1819 #[test]
1820 fn test_regex_search() {
1821 let temp_dir = TempDir::new().unwrap();
1822 create_test_files(temp_dir.path());
1823
1824 let options = SearchOptions {
1825 mode: SearchMode::Regex,
1826 query: "rust".to_string(),
1827 path: temp_dir.path().to_path_buf(),
1828 recursive: true,
1829 ..Default::default()
1830 };
1831
1832 let results = regex_search(&options).unwrap();
1833 assert!(!results.is_empty());
1834
1835 let rust_matches: Vec<_> = results
1837 .iter()
1838 .filter(|r| r.preview.to_lowercase().contains("rust"))
1839 .collect();
1840 assert!(!rust_matches.is_empty());
1841 }
1842
1843 #[test]
1844 fn test_regex_search_case_insensitive() {
1845 let temp_dir = TempDir::new().unwrap();
1846 create_test_files(temp_dir.path());
1847
1848 let options = SearchOptions {
1849 mode: SearchMode::Regex,
1850 query: "HELLO".to_string(),
1851 path: temp_dir.path().to_path_buf(),
1852 recursive: true,
1853 case_insensitive: true,
1854 ..Default::default()
1855 };
1856
1857 let results = regex_search(&options).unwrap();
1858 assert!(!results.is_empty());
1859 }
1860
1861 #[test]
1862 fn test_regex_search_fixed_string() {
1863 let temp_dir = TempDir::new().unwrap();
1864 create_test_files(temp_dir.path());
1865
1866 let options = SearchOptions {
1867 mode: SearchMode::Regex,
1868 query: "fn main()".to_string(),
1869 path: temp_dir.path().to_path_buf(),
1870 recursive: true,
1871 fixed_string: true,
1872 ..Default::default()
1873 };
1874
1875 let results = regex_search(&options).unwrap();
1876 assert!(!results.is_empty());
1877 }
1878
1879 #[test]
1880 fn test_regex_search_whole_word() {
1881 let temp_dir = TempDir::new().unwrap();
1882 fs::write(
1883 temp_dir.path().join("word_test.txt"),
1884 "rust rusty rustacean",
1885 )
1886 .unwrap();
1887
1888 let options = SearchOptions {
1889 mode: SearchMode::Regex,
1890 query: "rust".to_string(),
1891 path: temp_dir.path().to_path_buf(),
1892 recursive: true,
1893 whole_word: true,
1894 ..Default::default()
1895 };
1896
1897 let results = regex_search(&options).unwrap();
1898 assert!(!results.is_empty());
1899 }
1901
1902 #[test]
1903 fn test_regex_search_top_k() {
1904 let temp_dir = TempDir::new().unwrap();
1905
1906 for i in 0..10 {
1908 fs::write(temp_dir.path().join(format!("file{i}.txt")), "test content").unwrap();
1909 }
1910
1911 let options = SearchOptions {
1912 mode: SearchMode::Regex,
1913 query: "test".to_string(),
1914 path: temp_dir.path().to_path_buf(),
1915 recursive: true,
1916 top_k: Some(5),
1917 ..Default::default()
1918 };
1919
1920 let results = regex_search(&options).unwrap();
1921 assert!(results.len() <= 5);
1922 }
1923
1924 #[test]
1925 fn test_regex_search_span_offsets() {
1926 let temp_dir = TempDir::new().unwrap();
1928 let test_file = temp_dir.path().join("spans.txt");
1929 fs::write(&test_file, "test test test\nline two test\ntest end").unwrap();
1930
1931 let options = SearchOptions {
1932 mode: SearchMode::Regex,
1933 query: "test".to_string(),
1934 path: test_file.clone(),
1935 recursive: false,
1936 ..Default::default()
1937 };
1938
1939 let results = regex_search(&options).unwrap();
1940
1941 assert_eq!(results.len(), 5);
1943
1944 let line1_matches: Vec<_> = results.iter().filter(|r| r.span.line_start == 1).collect();
1946 assert_eq!(line1_matches.len(), 3);
1947 assert_eq!(line1_matches[0].span.byte_start, 0);
1948 assert_eq!(line1_matches[1].span.byte_start, 5);
1949 assert_eq!(line1_matches[2].span.byte_start, 10);
1950
1951 let line2_matches: Vec<_> = results.iter().filter(|r| r.span.line_start == 2).collect();
1953 assert_eq!(line2_matches.len(), 1);
1954 assert_eq!(line2_matches[0].span.byte_start, 24); let mut byte_starts: Vec<_> = results.iter().map(|r| r.span.byte_start).collect();
1958 byte_starts.sort();
1959 byte_starts.dedup();
1960 assert_eq!(byte_starts.len(), 5); }
1962
1963 #[test]
1964 fn test_search_file() {
1965 let temp_dir = TempDir::new().unwrap();
1966 let file_path = temp_dir.path().join("test.txt");
1967 fs::write(
1968 &file_path,
1969 "line 1: hello\nline 2: world\nline 3: rust programming",
1970 )
1971 .unwrap();
1972
1973 let regex = regex::Regex::new("rust").unwrap();
1974 let options = SearchOptions::default();
1975
1976 let results = search_file(®ex, &file_path, &options).unwrap();
1977 assert_eq!(results.len(), 1);
1978 assert_eq!(results[0].span.line_start, 3);
1979 assert!(results[0].preview.contains("rust"));
1980 }
1981
1982 #[test]
1983 fn test_search_file_with_context() {
1984 let temp_dir = TempDir::new().unwrap();
1985 let file_path = temp_dir.path().join("test.txt");
1986 fs::write(&file_path, "line 1\nline 2\ntarget line\nline 4\nline 5").unwrap();
1987
1988 let regex = regex::Regex::new("target").unwrap();
1989 let options = SearchOptions {
1990 context_lines: 1,
1991 ..Default::default()
1992 };
1993
1994 let results = search_file(®ex, &file_path, &options).unwrap();
1995 assert_eq!(results.len(), 1);
1996
1997 println!("Preview: '{}'", results[0].preview);
1998
1999 assert!(results[0].preview.contains("line 2"));
2002 assert!(results[0].preview.contains("target line"));
2003 assert!(results[0].preview.contains("line 4"));
2004 }
2005
2006 #[tokio::test]
2007 async fn test_search_main_function() {
2008 let temp_dir = TempDir::new().unwrap();
2009 create_test_files(temp_dir.path());
2010
2011 let options = SearchOptions {
2012 mode: SearchMode::Regex,
2013 query: "hello".to_string(),
2014 path: temp_dir.path().to_path_buf(),
2015 recursive: true,
2016 case_insensitive: true,
2017 ..Default::default()
2018 };
2019
2020 let results = search(&options).await.unwrap();
2021 assert!(!results.is_empty());
2022 }
2023
2024 #[tokio::test]
2025 async fn test_regex_search_mixed_line_endings() {
2026 let temp_dir = TempDir::new().unwrap();
2028
2029 let test_file = temp_dir.path().join("mixed_endings.txt");
2031 let content = "line1\r\nline2\nline3\r\npattern here\nline5\r\n";
2032 std::fs::write(&test_file, content).unwrap();
2033
2034 let options = SearchOptions {
2035 mode: SearchMode::Regex,
2036 query: "pattern".to_string(),
2037 path: test_file.clone(),
2038 recursive: false,
2039 ..Default::default()
2040 };
2041
2042 let results = search(&options).await.unwrap();
2043 assert_eq!(results.len(), 1);
2044
2045 let result = &results[0];
2046 let original_content = std::fs::read_to_string(&test_file).unwrap();
2048 let pattern_start = original_content.find("pattern").unwrap();
2049
2050 assert_eq!(result.span.byte_start, pattern_start);
2051 assert_eq!(result.span.line_start, 4); }
2053
2054 #[tokio::test]
2055 async fn test_regex_search_windows_line_endings() {
2056 let temp_dir = TempDir::new().unwrap();
2058
2059 let test_file = temp_dir.path().join("windows_endings.txt");
2060 let content = "first line\r\nsecond line\r\nmatch this\r\nfourth line\r\n";
2061 std::fs::write(&test_file, content).unwrap();
2062
2063 let options = SearchOptions {
2064 mode: SearchMode::Regex,
2065 query: "match".to_string(),
2066 path: test_file.clone(),
2067 recursive: false,
2068 ..Default::default()
2069 };
2070
2071 let results = search(&options).await.unwrap();
2072 assert_eq!(results.len(), 1);
2073
2074 let result = &results[0];
2075
2076 assert_eq!(result.span.line_start, 3);
2078
2079 let expected_byte_start = 25; assert_eq!(result.span.byte_start, expected_byte_start);
2083 }
2084
2085 #[test]
2086 fn test_split_lines_with_endings_helper() {
2087 let unix_content = "line1\nline2\nline3\n";
2089 let (unix_lines, unix_endings) = split_lines_with_endings(unix_content);
2090 assert_eq!(unix_lines, vec!["line1", "line2", "line3"]);
2091 assert_eq!(unix_endings, vec![1, 1, 1]);
2092
2093 let windows_content = "line1\r\nline2\r\nline3\r\n";
2095 let (windows_lines, windows_endings) = split_lines_with_endings(windows_content);
2096 assert_eq!(windows_lines, vec!["line1", "line2", "line3"]);
2097 assert_eq!(windows_endings, vec![2, 2, 2]);
2098
2099 let mac_content = "line1\rline2\rline3\r";
2101 let (mac_lines, mac_endings) = split_lines_with_endings(mac_content);
2102 assert_eq!(mac_lines, vec!["line1", "line2", "line3"]);
2103 assert_eq!(mac_endings, vec![1, 1, 1]);
2104
2105 let mixed_content = "line1\nline2\r\nline3\r";
2107 let (mixed_lines, mixed_endings) = split_lines_with_endings(mixed_content);
2108 assert_eq!(mixed_lines, vec!["line1", "line2", "line3"]);
2109 assert_eq!(mixed_endings, vec![1, 2, 1]);
2110
2111 let no_endings = "single line";
2113 let (no_lines, no_endings_vec) = split_lines_with_endings(no_endings);
2114 assert_eq!(no_lines, vec!["single line"]);
2115 assert_eq!(no_endings_vec, vec![0]);
2116 }
2117
2118 #[cfg(feature = "fastembed")]
2122 #[tokio::test]
2123 async fn test_subdirectory_search_uses_parent_ckignore() {
2124 let temp_dir = TempDir::new().unwrap();
2129 let parent = temp_dir.path();
2130 let subdir = parent.join("subproject");
2131 fs::create_dir(&subdir).unwrap();
2132
2133 fs::write(parent.join(".ckignore"), "*.tmp\n").unwrap();
2135
2136 fs::write(parent.join("parent.txt"), "searchable content in parent").unwrap();
2138 fs::write(parent.join("ignored.tmp"), "this should not be indexed").unwrap();
2139
2140 fs::write(subdir.join("nested.txt"), "searchable content in subdir").unwrap();
2142 fs::write(
2143 subdir.join("also_ignored.tmp"),
2144 "this should not be indexed either",
2145 )
2146 .unwrap();
2147
2148 let parent_options = SearchOptions {
2150 mode: SearchMode::Semantic,
2151 query: "searchable".to_string(),
2152 path: parent.to_path_buf(),
2153 top_k: Some(10),
2154 threshold: Some(0.1),
2155 ..Default::default()
2156 };
2157
2158 let _ = search(&parent_options).await;
2159
2160 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2162
2163 let subdir_options = SearchOptions {
2167 mode: SearchMode::Semantic,
2168 query: "content".to_string(),
2169 path: subdir.clone(),
2170 top_k: Some(10),
2171 threshold: Some(0.1),
2172 ..Default::default()
2173 };
2174
2175 let results = search(&subdir_options).await.unwrap();
2176
2177 let tmp_files: Vec<_> = results
2179 .iter()
2180 .filter(|r| r.file.to_string_lossy().ends_with(".tmp"))
2181 .collect();
2182 assert!(
2183 tmp_files.is_empty(),
2184 "Bug: .tmp files were indexed despite parent .ckignore. Found {} .tmp files: {:?}",
2185 tmp_files.len(),
2186 tmp_files.iter().map(|r| &r.file).collect::<Vec<_>>()
2187 );
2188
2189 let txt_in_subdir = results.iter().any(|r| r.file.ends_with("nested.txt"));
2191 assert!(txt_in_subdir, "Should find nested.txt in subdirectory");
2192
2193 assert!(
2195 !subdir.join(".ck").exists(),
2196 "Should not create .ck directory in subdirectory"
2197 );
2198 }
2199
2200 #[cfg(feature = "fastembed")]
2204 #[tokio::test]
2205 async fn test_multiple_ckignore_files_merge_correctly() {
2206 use std::fs;
2208 use tempfile::TempDir;
2209
2210 let temp_dir = TempDir::new().unwrap();
2211 let parent = temp_dir.path();
2212 let subdir = parent.join("subdir");
2213 let deeper = subdir.join("deeper");
2214 fs::create_dir(&subdir).unwrap();
2215 fs::create_dir(&deeper).unwrap();
2216
2217 fs::write(parent.join(".ckignore"), "*.log\n").unwrap();
2219 fs::write(subdir.join(".ckignore"), "*.tmp\n").unwrap();
2220 fs::write(deeper.join(".ckignore"), "*.cache\n").unwrap();
2221
2222 fs::write(parent.join("root.txt"), "searchable").unwrap();
2224 fs::write(parent.join("root.log"), "should be ignored").unwrap();
2225
2226 fs::write(subdir.join("mid.txt"), "searchable").unwrap();
2227 fs::write(subdir.join("mid.log"), "should be ignored by parent").unwrap();
2228 fs::write(subdir.join("mid.tmp"), "should be ignored by local").unwrap();
2229
2230 fs::write(deeper.join("deep.txt"), "searchable").unwrap();
2231 fs::write(deeper.join("deep.log"), "should be ignored by grandparent").unwrap();
2232 fs::write(deeper.join("deep.tmp"), "should be ignored by parent").unwrap();
2233 fs::write(deeper.join("deep.cache"), "should be ignored by local").unwrap();
2234
2235 let parent_options = SearchOptions {
2237 mode: SearchMode::Semantic,
2238 query: "searchable".to_string(),
2239 path: parent.to_path_buf(),
2240 top_k: Some(20),
2241 threshold: Some(0.1),
2242 ..Default::default()
2243 };
2244
2245 let _ = search(&parent_options).await;
2246 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2247
2248 let deeper_options = SearchOptions {
2250 mode: SearchMode::Semantic,
2251 query: "ignored".to_string(),
2252 path: deeper.clone(),
2253 top_k: Some(20),
2254 threshold: Some(0.1),
2255 ..Default::default()
2256 };
2257
2258 let results = search(&deeper_options).await.unwrap();
2259
2260 let has_log = results
2262 .iter()
2263 .any(|r| r.file.to_string_lossy().ends_with(".log"));
2264 let has_tmp = results
2265 .iter()
2266 .any(|r| r.file.to_string_lossy().ends_with(".tmp"));
2267 let has_cache = results
2268 .iter()
2269 .any(|r| r.file.to_string_lossy().ends_with(".cache"));
2270
2271 assert!(
2272 !has_log,
2273 "*.log files should be excluded by parent .ckignore"
2274 );
2275 assert!(
2276 !has_tmp,
2277 "*.tmp files should be excluded by subdir .ckignore"
2278 );
2279 assert!(
2280 !has_cache,
2281 "*.cache files should be excluded by deeper .ckignore"
2282 );
2283
2284 let has_txt = results
2286 .iter()
2287 .any(|r| r.file.to_string_lossy().ends_with(".txt"));
2288 assert!(has_txt, "Should find .txt files (not ignored)");
2289 }
2290
2291 #[cfg(feature = "fastembed")]
2295 #[tokio::test]
2296 async fn test_scoped_search_does_not_lose_results_to_global_top_k() {
2297 use std::fs;
2310 use tempfile::TempDir;
2311
2312 let temp_dir = TempDir::new().unwrap();
2313 let parent = temp_dir.path();
2314 let noisy = parent.join("noisy");
2315 let scoped = parent.join("scoped");
2316 fs::create_dir(&noisy).unwrap();
2317 fs::create_dir(&scoped).unwrap();
2318
2319 for i in 0..8 {
2322 fs::write(
2323 noisy.join(format!("noise_{i}.txt")),
2324 format!(
2325 "function open_database_connection_{i}() {{\n \
2326 // establish a database connection to postgres\n \
2327 // handle database connection errors gracefully\n}}\n"
2328 ),
2329 )
2330 .unwrap();
2331 }
2332
2333 fs::write(
2335 scoped.join("target.txt"),
2336 "function connect() {\n \
2337 // open a database connection to the primary store\n \
2338 // database connection pool config goes here\n}\n",
2339 )
2340 .unwrap();
2341
2342 let index_options = SearchOptions {
2344 mode: SearchMode::Semantic,
2345 query: "database connection".to_string(),
2346 path: parent.to_path_buf(),
2347 top_k: Some(20),
2348 threshold: Some(0.0),
2349 ..Default::default()
2350 };
2351 let _ = search(&index_options).await;
2352 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2353
2354 let scoped_options = SearchOptions {
2358 mode: SearchMode::Semantic,
2359 query: "database connection".to_string(),
2360 path: scoped.clone(),
2361 top_k: Some(3),
2362 threshold: Some(0.0),
2363 ..Default::default()
2364 };
2365
2366 let results = search(&scoped_options).await.unwrap();
2367
2368 assert!(
2369 !results.is_empty(),
2370 "Scoped search returned zero results — top_k was applied \
2371 before the path filter (the bug this test guards against)."
2372 );
2373 let all_in_scope = results.iter().all(|r| {
2374 r.file.starts_with(&scoped)
2375 || r.file.canonicalize().ok() == scoped.join("target.txt").canonicalize().ok()
2376 });
2377 assert!(
2378 all_in_scope,
2379 "Some results leaked out of the requested scope: {:?}",
2380 results.iter().map(|r| &r.file).collect::<Vec<_>>()
2381 );
2382 }
2383
2384 #[test]
2385 fn test_lenient_parse_matches_strict_for_valid_query() {
2386 let mut schema_builder = Schema::builder();
2389 let content_field = schema_builder.add_text_field("content", TEXT | STORED);
2390 let schema = schema_builder.build();
2391 let index = Index::create_in_ram(schema);
2392 {
2393 let mut writer = index.writer(50_000_000).unwrap();
2394 writer
2395 .add_document(doc!(content_field => "zebra alpha"))
2396 .unwrap();
2397 writer
2398 .add_document(doc!(content_field => "zebra zebra beta"))
2399 .unwrap();
2400 writer
2401 .add_document(doc!(content_field => "gamma delta"))
2402 .unwrap();
2403 writer.commit().unwrap();
2404 }
2405 let searcher = index.reader().unwrap().searcher();
2406 let query_parser = QueryParser::for_index(&index, vec![content_field]);
2407
2408 let strict = query_parser.parse_query("zebra").unwrap();
2409 let (lenient, errors) = query_parser.parse_query_lenient("zebra");
2410 assert!(errors.is_empty(), "valid query must parse without errors");
2411
2412 let strict_hits = searcher.search(&strict, &TopDocs::with_limit(10)).unwrap();
2413 let lenient_hits = searcher.search(&lenient, &TopDocs::with_limit(10)).unwrap();
2414 assert_eq!(strict_hits.len(), lenient_hits.len());
2415 for ((s_score, s_addr), (l_score, l_addr)) in strict_hits.iter().zip(lenient_hits.iter()) {
2416 assert_eq!(s_addr, l_addr, "same documents in the same order");
2417 assert!((s_score - l_score).abs() < 1e-6, "same scores");
2418 }
2419 }
2420
2421 #[tokio::test]
2422 async fn test_lexical_search_recovers_from_unbalanced_quote() {
2423 let temp_dir = TempDir::new().unwrap();
2426 fs::write(
2427 temp_dir.path().join("mod.py"),
2428 "def gamma():\n zebra = 3\n",
2429 )
2430 .unwrap();
2431 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2432
2433 let options = SearchOptions {
2434 mode: SearchMode::Lexical,
2435 query: "zebra\"".to_string(),
2436 path: temp_dir.path().to_path_buf(),
2437 recursive: true,
2438 ..Default::default()
2439 };
2440
2441 let results = lexical_search(&options)
2442 .await
2443 .expect("lenient parse must not error on an unbalanced quote");
2444 assert!(
2445 results
2446 .iter()
2447 .any(|r| r.file.file_name().unwrap() == "mod.py")
2448 );
2449 }
2450
2451 #[tokio::test]
2452 async fn test_lexical_search_recovers_term_from_field_colon() {
2453 let temp_dir = TempDir::new().unwrap();
2456 fs::write(
2457 temp_dir.path().join("mod.py"),
2458 "def gamma():\n zebra = 3\n",
2459 )
2460 .unwrap();
2461 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2462
2463 let options = SearchOptions {
2464 mode: SearchMode::Lexical,
2465 query: "zebra foo:baz".to_string(),
2466 path: temp_dir.path().to_path_buf(),
2467 recursive: true,
2468 ..Default::default()
2469 };
2470
2471 let results = lexical_search(&options)
2472 .await
2473 .expect("lenient parse must not error on a stray field colon");
2474 assert!(
2475 results
2476 .iter()
2477 .any(|r| r.file.file_name().unwrap() == "mod.py")
2478 );
2479 }
2480
2481 #[tokio::test]
2482 async fn test_lexical_search_all_fragments_error_degrades_gracefully() {
2483 let temp_dir = TempDir::new().unwrap();
2487 fs::write(
2488 temp_dir.path().join("mod.py"),
2489 "def gamma():\n zebra = 3\n",
2490 )
2491 .unwrap();
2492 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2493
2494 let options = SearchOptions {
2495 mode: SearchMode::Lexical,
2496 query: "title:zebra".to_string(),
2497 path: temp_dir.path().to_path_buf(),
2498 recursive: true,
2499 ..Default::default()
2500 };
2501
2502 assert!(
2503 lexical_search(&options).await.is_ok(),
2504 "an all-unknown-field query must degrade gracefully, not error"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_lexical_query_terms_tokenization() {
2510 assert_eq!(lexical_query_terms("Hello, World!"), vec!["hello", "world"]);
2511 assert_eq!(
2512 lexical_query_terms("snake_case-and.dots"),
2513 vec!["snake", "case", "and", "dots"]
2514 );
2515 assert!(lexical_query_terms(" ").is_empty());
2516 assert!(lexical_query_terms("").is_empty());
2517 }
2518
2519 #[test]
2520 fn test_locate_lexical_span_late_section() {
2521 let content = "fn alpha() {\n let x = 1;\n}\n\nfn beta() {\n let y = 2;\n}\n\nfn gamma() {\n let zebra = 3;\n}\n";
2524 let term_line = content.lines().position(|l| l.contains("zebra")).unwrap() + 1;
2525
2526 let (span, preview) = locate_lexical_span(
2527 Path::new("sample.rs"),
2528 content,
2529 &["zebra".to_string()],
2530 false,
2531 );
2532
2533 assert!(span.line_start > 1, "span should not start at line 1");
2534 assert!(
2535 span.line_start <= term_line && span.line_end >= term_line,
2536 "span {}-{} should bracket the term line {term_line}",
2537 span.line_start,
2538 span.line_end
2539 );
2540 assert!(preview.to_lowercase().contains("zebra"));
2541 assert!(!preview.contains("fn alpha"));
2543 }
2544
2545 #[test]
2546 fn test_locate_lexical_span_no_match_falls_back_to_whole_file() {
2547 let content = "fn alpha() {\n let x = 1;\n}\n\nfn beta() {\n let y = 2;\n}\n";
2550 let (span, preview) = locate_lexical_span(
2551 Path::new("sample.rs"),
2552 content,
2553 &["nonexistentterm".to_string()],
2554 false,
2555 );
2556
2557 assert_eq!(span.line_start, 1);
2558 assert_eq!(span.line_end, content.lines().count());
2559 assert_eq!(span.byte_start, 0);
2560 assert_eq!(span.byte_end, content.len());
2561 assert_eq!(
2562 preview,
2563 content.lines().take(3).collect::<Vec<_>>().join("\n")
2564 );
2565 }
2566
2567 #[test]
2568 fn test_locate_lexical_span_empty_terms_falls_back() {
2569 let content = "one\ntwo\nthree\n";
2572 let (span, _) = locate_lexical_span(Path::new("f.txt"), content, &[], false);
2573 assert_eq!(span.line_start, 1);
2574 assert_eq!(span.line_end, content.lines().count());
2575 }
2576
2577 #[test]
2578 fn test_locate_lexical_span_full_section_keeps_whole_file() {
2579 let content = "fn alpha() {\n let x = 1;\n}\n\nfn gamma() {\n let zebra = 3;\n}\n";
2582 let (span, preview) = locate_lexical_span(
2583 Path::new("sample.rs"),
2584 content,
2585 &["zebra".to_string()],
2586 true,
2587 );
2588
2589 assert!(span.line_start > 1);
2590 assert_eq!(preview, content);
2591 }
2592
2593 #[test]
2594 fn test_locate_lexical_span_counts_tokens_not_substrings() {
2595 let content =
2600 "fn printing() {\n let index = string;\n}\n\nfn other() {\n let x = \"in\";\n}\n";
2601 let target_line = content.lines().position(|l| l.contains("\"in\"")).unwrap() + 1;
2602
2603 let (span, preview) =
2604 locate_lexical_span(Path::new("sample.rs"), content, &["in".to_string()], false);
2605
2606 assert!(
2607 span.line_start > 3,
2608 "token counting should select the second function, got line_start {}",
2609 span.line_start
2610 );
2611 assert!(span.line_start <= target_line && span.line_end >= target_line);
2612 assert!(preview.contains("\"in\""));
2613 assert!(!preview.contains("printing"));
2614 }
2615
2616 #[test]
2617 fn test_locate_lexical_span_last_chunk_reaches_eof() {
2618 let content = "fn early() {\n let a = 1;\n}\n\nfn late() {\n let zebra = 2;\n}\n";
2621 let (span, _) =
2622 locate_lexical_span(Path::new("s.rs"), content, &["zebra".to_string()], false);
2623 assert!(span.line_start > 1);
2624 assert_eq!(
2625 span.line_end,
2626 content.lines().count(),
2627 "last chunk span should reach end of file"
2628 );
2629 }
2630
2631 #[tokio::test]
2632 async fn test_lexical_search_span_uses_parsed_query_terms() {
2633 let temp_dir = TempDir::new().unwrap();
2640 let mut body = String::from("fn phantoms() {\n");
2641 for i in 0..30 {
2642 body.push_str(&format!(" let content_{i} = {i};\n"));
2643 }
2644 for i in 0..30 {
2645 body.push_str(&format!(" let or_{i} = {i};\n"));
2646 }
2647 body.push_str("}\n\nfn target() {\n let index = 1;\n let zebra = 2;\n let pair = \"index zebra\";\n}\n");
2648 fs::write(temp_dir.path().join("f.rs"), &body).unwrap();
2649 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2650 let zebra_line = body.lines().position(|l| l.contains("let zebra")).unwrap() + 1;
2651
2652 for query in ["content:zebra", "\"index zebra\"", "index OR zebra"] {
2653 let options = SearchOptions {
2654 mode: SearchMode::Lexical,
2655 query: query.to_string(),
2656 path: temp_dir.path().to_path_buf(),
2657 recursive: true,
2658 ..Default::default()
2659 };
2660 let results = lexical_search(&options).await.unwrap();
2661 let hit = results
2662 .iter()
2663 .find(|r| r.file.file_name().unwrap() == "f.rs")
2664 .unwrap_or_else(|| panic!("expected a hit for query {query:?}"));
2665 assert!(
2666 hit.span.line_start <= zebra_line && hit.span.line_end >= zebra_line,
2667 "query {query:?}: span {}-{} should bracket the real match at line {zebra_line}",
2668 hit.span.line_start,
2669 hit.span.line_end
2670 );
2671 assert!(
2672 hit.preview.to_lowercase().contains("zebra"),
2673 "query {query:?}: preview should contain the real match, got {:?}",
2674 hit.preview
2675 );
2676 }
2677 }
2678
2679 #[tokio::test]
2680 async fn test_lexical_search_reports_chunk_line_span() {
2681 let temp_dir = TempDir::new().unwrap();
2682 let file = temp_dir.path().join("late.rs");
2683 fs::write(
2685 &file,
2686 "fn alpha() {\n let a = 1;\n}\n\nfn beta() {\n let b = 2;\n}\n\nfn gamma() {\n let zebra = 3;\n}\n",
2687 )
2688 .unwrap();
2689 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2692
2693 let options = SearchOptions {
2694 mode: SearchMode::Lexical,
2695 query: "zebra".to_string(),
2696 path: temp_dir.path().to_path_buf(),
2697 recursive: true,
2698 ..Default::default()
2699 };
2700
2701 let results = lexical_search(&options).await.unwrap();
2702 let hit = results
2703 .iter()
2704 .find(|r| r.file.file_name().unwrap() == "late.rs")
2705 .expect("expected a hit for late.rs");
2706
2707 assert!(
2708 hit.span.line_start > 1,
2709 "lexical hit should report a mid-file line, got line_start {}",
2710 hit.span.line_start
2711 );
2712 assert!(hit.preview.to_lowercase().contains("zebra"));
2713 }
2714
2715 #[tokio::test]
2716 async fn test_lexical_search_ranking_unchanged() {
2717 let temp_dir = TempDir::new().unwrap();
2720 fs::write(temp_dir.path().join("a.txt"), "zebra zebra zebra").unwrap();
2721 fs::write(temp_dir.path().join("b.txt"), "zebra tail").unwrap();
2722 fs::write(temp_dir.path().join("c.txt"), "nothing relevant here").unwrap();
2723 fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2724
2725 let options = SearchOptions {
2726 mode: SearchMode::Lexical,
2727 query: "zebra".to_string(),
2728 path: temp_dir.path().to_path_buf(),
2729 recursive: true,
2730 ..Default::default()
2731 };
2732
2733 let results = lexical_search(&options).await.unwrap();
2734 let files: Vec<String> = results
2735 .iter()
2736 .map(|r| r.file.file_name().unwrap().to_string_lossy().into_owned())
2737 .collect();
2738
2739 assert_eq!(files, vec!["a.txt".to_string(), "b.txt".to_string()]);
2741 for pair in results.windows(2) {
2742 assert!(
2743 pair[0].score >= pair[1].score,
2744 "results must be in descending score order"
2745 );
2746 }
2747 assert!((results[0].score - 1.0).abs() < 1e-6);
2749 }
2750}