1#![warn(clippy::pedantic)]
2#![allow(
3 clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_possible_wrap, clippy::module_name_repetitions, clippy::similar_names, clippy::too_many_lines, clippy::too_many_arguments, clippy::unnecessary_wraps, clippy::struct_excessive_bools, clippy::missing_errors_doc, clippy::missing_panics_doc, )]
15
16pub(crate) mod budget;
17pub mod cache;
18pub(crate) mod classify;
19pub mod error;
20pub(crate) mod format;
21pub mod index;
22pub(crate) mod lang;
23pub mod map;
24pub mod overview;
25pub(crate) mod read;
26pub(crate) mod search;
27pub(crate) mod session;
28pub(crate) mod types;
29
30use std::path::Path;
31
32use cache::OutlineCache;
33use classify::classify;
34use error::SrcwalkError;
35use types::QueryType;
36
37struct ExpandedCtx {
40 session: session::Session,
41 sym_index: index::SymbolIndex,
42 bloom: index::bloom::BloomFilterCache,
43 expand: usize,
44}
45
46pub fn run(
49 query: &str,
50 scope: &Path,
51 section: Option<&str>,
52 budget_tokens: Option<u64>,
53 limit: Option<usize>,
54 offset: usize,
55 glob: Option<&str>,
56 cache: &OutlineCache,
57) -> Result<String, SrcwalkError> {
58 run_inner(
59 query,
60 scope,
61 section,
62 budget_tokens,
63 false,
64 0,
65 limit,
66 offset,
67 glob,
68 cache,
69 )
70}
71
72pub fn run_full(
74 query: &str,
75 scope: &Path,
76 section: Option<&str>,
77 budget_tokens: Option<u64>,
78 limit: Option<usize>,
79 offset: usize,
80 glob: Option<&str>,
81 cache: &OutlineCache,
82) -> Result<String, SrcwalkError> {
83 run_inner(
84 query,
85 scope,
86 section,
87 budget_tokens,
88 true,
89 0,
90 limit,
91 offset,
92 glob,
93 cache,
94 )
95}
96
97pub fn run_expanded(
99 query: &str,
100 scope: &Path,
101 section: Option<&str>,
102 budget_tokens: Option<u64>,
103 full: bool,
104 expand: usize,
105 limit: Option<usize>,
106 offset: usize,
107 glob: Option<&str>,
108 cache: &OutlineCache,
109) -> Result<String, SrcwalkError> {
110 run_inner(
111 query,
112 scope,
113 section,
114 budget_tokens,
115 full,
116 expand,
117 limit,
118 offset,
119 glob,
120 cache,
121 )
122}
123
124#[allow(clippy::too_many_arguments)]
126pub fn run_callers(
127 target: &str,
128 scope: &Path,
129 expand: usize,
130 budget_tokens: Option<u64>,
131 limit: Option<usize>,
132 offset: usize,
133 glob: Option<&str>,
134 cache: &OutlineCache,
135 depth: Option<usize>,
136 max_frontier: Option<usize>,
137 max_edges: Option<usize>,
138 skip_hubs: Option<&str>,
139 json: bool,
140) -> Result<String, SrcwalkError> {
141 let session = session::Session::new();
142 let bloom = index::bloom::BloomFilterCache::new();
143 let expand = if expand > 0 { expand } else { 1 };
144
145 let output = match depth {
147 Some(d) if d >= 2 => search::callers::search_callers_bfs(
148 target,
149 scope,
150 cache,
151 &bloom,
152 d.min(5),
153 max_frontier.unwrap_or(50),
154 max_edges.unwrap_or(500),
155 glob,
156 skip_hubs,
157 json,
158 budget_tokens.map(|b| b as usize),
159 )?,
160 _ => {
161 let mut callers_out = search::callers::search_callers_expanded(
162 target, scope, cache, &session, &bloom, expand, None, limit, offset, glob,
163 )?;
164 callers_out.push_str("\n\n> Tip: use --depth N for transitive callers (max 5)");
165 callers_out
166 }
167 };
168 if json {
169 return Ok(output);
172 }
173 match budget_tokens {
174 Some(b) => Ok(budget::apply(&output, b)),
175 None => Ok(output),
176 }
177}
178
179pub fn run_callees(
181 target: &str,
182 scope: &Path,
183 budget_tokens: Option<u64>,
184 cache: &OutlineCache,
185 depth: Option<usize>,
186 detailed: bool,
187) -> Result<String, SrcwalkError> {
188 use std::fmt::Write;
189 let bloom = index::bloom::BloomFilterCache::new();
190
191 let raw = search::search_symbol_raw(target, scope, None)?;
193 let def_match = raw
194 .matches
195 .iter()
196 .find(|m| m.is_definition && m.def_range.is_some())
197 .ok_or_else(|| SrcwalkError::NoMatches {
198 query: target.to_string(),
199 scope: scope.to_path_buf(),
200 suggestion: symbol_or_file_suggestion(scope, target, None),
201 })?;
202
203 let content = std::fs::read_to_string(&def_match.path).map_err(|e| SrcwalkError::IoError {
204 path: def_match.path.clone(),
205 source: e,
206 })?;
207
208 let file_type = lang::detect_file_type(&def_match.path);
209 let types::FileType::Code(lang) = file_type else {
210 return Ok(format!("# Callees: {target}\n\n(not a code file)"));
211 };
212
213 let rel = format::rel_nonempty(&def_match.path, scope);
214
215 if detailed {
217 let sites = search::callees::extract_call_sites(&content, lang, def_match.def_range);
218 if sites.is_empty() {
219 return Ok(format!("# Callees: {target} ({rel})\n\n(no calls found)"));
220 }
221 let mut out = format!("# Callees: {target} ({rel})\n");
222 for s in &sites {
223 let prefix = if s.is_return { "->ret " } else { "" };
224 match &s.return_var {
225 Some(var) => {
226 let _ = write!(out, "\nL{} {}{} = {}", s.line, prefix, var, s.call_text);
227 }
228 None => {
229 let _ = write!(out, "\nL{} {}{}", s.line, prefix, s.call_text);
230 }
231 }
232 }
233 let output = match budget_tokens {
235 Some(b) => budget::apply(&out, b),
236 None => out,
237 };
238 return Ok(output);
239 }
240
241 let callee_names = search::callees::extract_callee_names(&content, lang, def_match.def_range);
243 if callee_names.is_empty() {
244 return Ok(format!(
245 "# Callees: {target} (in {rel})\n\n(no calls found)"
246 ));
247 }
248
249 let depth_limit = depth.map_or(1, |d| d.min(5) as u32);
250 let nodes = search::callees::resolve_callees_transitive(
251 &callee_names,
252 &def_match.path,
253 &content,
254 cache,
255 &bloom,
256 depth_limit,
257 50,
258 );
259
260 let mut out = format!("# Callees: {target} (in {rel})\n");
261
262 let resolved_names: std::collections::HashSet<&str> =
264 nodes.iter().map(|n| n.callee.name.as_str()).collect();
265 let unresolved: Vec<&String> = callee_names
266 .iter()
267 .filter(|n| !resolved_names.contains(n.as_str()))
268 .collect();
269
270 for node in &nodes {
271 let c = &node.callee;
272 let rel_c = format::rel_nonempty(&c.file, scope);
273 let sig = c.signature.as_deref().unwrap_or("");
274 let _ = write!(
275 out,
276 "\n {:<30} {}:{}-{}",
277 c.name, rel_c, c.start_line, c.end_line
278 );
279 if !sig.is_empty() {
280 let _ = write!(out, " {sig}");
281 }
282 for child in &node.children {
283 let rel_ch = format::rel_nonempty(&child.file, scope);
284 let _ = write!(
285 out,
286 "\n {:<28} {}:{}-{}",
287 child.name, rel_ch, child.start_line, child.end_line
288 );
289 if let Some(ref s) = child.signature {
290 let _ = write!(out, " {s}");
291 }
292 }
293 }
294
295 if !unresolved.is_empty() {
296 out.push_str("\n\n (unresolved): ");
297 out.push_str(
298 &unresolved
299 .iter()
300 .map(|s| s.as_str())
301 .collect::<Vec<_>>()
302 .join(", "),
303 );
304 }
305
306 out.push_str("\n\n> Tip: use --detailed for ordered call sites with args and assignments");
307
308 let output = match budget_tokens {
309 Some(b) => budget::apply(&out, b),
310 None => out,
311 };
312 Ok(output)
313}
314
315pub fn run_deps(
317 path: &Path,
318 scope: &Path,
319 budget_tokens: Option<u64>,
320 cache: &OutlineCache,
321) -> Result<String, SrcwalkError> {
322 let bloom = index::bloom::BloomFilterCache::new();
323 let result = search::deps::analyze_deps(path, scope, cache, &bloom)?;
324 let budget_usize = budget_tokens.map(|b| b as usize);
325 Ok(search::deps::format_deps(&result, scope, budget_usize))
326}
327
328const NON_PROD_DIR_SEGMENTS: &[&str] = &[
331 "tests",
332 "test",
333 "spec",
334 "specs",
335 "__tests__",
336 "vendor",
337 "node_modules",
338 "override",
339 "overrides",
340 "fixtures",
341 "examples",
342 "docs",
343 "build",
344 "dist",
345 "target",
346];
347
348fn is_non_prod(path: &Path, scope: &Path) -> bool {
349 let rel = path.strip_prefix(scope).unwrap_or(path);
350 rel.components().any(|c| {
351 c.as_os_str()
352 .to_str()
353 .is_some_and(|s| NON_PROD_DIR_SEGMENTS.contains(&s))
354 })
355}
356
357fn build_visible_set(scope: &Path) -> std::collections::HashSet<std::path::PathBuf> {
363 let walker = ignore::WalkBuilder::new(scope)
364 .hidden(true)
365 .git_ignore(true)
366 .git_global(true)
367 .git_exclude(true)
368 .ignore(true)
369 .parents(true)
370 .follow_links(false)
371 .build();
372 let mut out = std::collections::HashSet::new();
373 for entry in walker.flatten() {
374 if entry.file_type().is_some_and(|ft| ft.is_file()) {
375 out.insert(entry.path().to_path_buf());
376 }
377 }
378 out
379}
380
381fn depth_from_scope(path: &Path, scope: &Path) -> usize {
386 path.strip_prefix(scope)
387 .unwrap_or(path)
388 .components()
389 .count()
390}
391
392fn disambiguate_glob_for_section(
402 pattern: &str,
403 scope: &Path,
404 original_query: &str,
405) -> Result<Option<(std::path::PathBuf, Option<String>)>, SrcwalkError> {
406 let result = search::glob::search(pattern, scope, Some(200), 0)?;
407 if result.files.is_empty() {
408 return Ok(None);
409 }
410
411 let total = result.files.len();
412 if total == 1 {
413 return Ok(Some((result.files[0].path.clone(), None)));
414 }
415
416 let visible = build_visible_set(scope);
421 let primary: Vec<&std::path::PathBuf> = result
422 .files
423 .iter()
424 .map(|e| &e.path)
425 .filter(|p| visible.contains(*p) && !is_non_prod(p, scope))
426 .collect();
427
428 let picked_opt: Option<std::path::PathBuf> = match primary.len().cmp(&1) {
431 std::cmp::Ordering::Equal => Some(primary[0].clone()),
432 std::cmp::Ordering::Greater => {
433 let min_depth = primary
434 .iter()
435 .map(|p| depth_from_scope(p, scope))
436 .min()
437 .unwrap_or(0);
438 let shallowest: Vec<&std::path::PathBuf> = primary
439 .iter()
440 .copied()
441 .filter(|p| depth_from_scope(p, scope) == min_depth)
442 .collect();
443 if shallowest.len() == 1 {
444 Some(shallowest[0].clone())
445 } else {
446 None
447 }
448 }
449 std::cmp::Ordering::Less => None,
450 };
451
452 if let Some(picked) = picked_opt {
453 let skipped_count = total - 1;
454 let skipped_preview: Vec<String> = result
457 .files
458 .iter()
459 .map(|e| &e.path)
460 .filter(|p| **p != picked)
461 .take(3)
462 .map(|p| p.strip_prefix(scope).unwrap_or(p).display().to_string())
463 .collect();
464 let skipped_str = if skipped_preview.is_empty() {
465 String::new()
466 } else {
467 let joined = skipped_preview.join(", ");
468 let more = if skipped_count > skipped_preview.len() {
469 format!(", +{} more", skipped_count - skipped_preview.len())
470 } else {
471 String::new()
472 };
473 format!(" [{joined}{more}]")
474 };
475 let note = format!(
476 "Resolved '{original_query}' → {} (skipped {skipped_count} non-primary {}{skipped_str}). Pass full path to override.",
477 picked.strip_prefix(scope).unwrap_or(&picked).display(),
478 if skipped_count == 1 { "copy" } else { "copies" },
479 );
480 return Ok(Some((picked, Some(note))));
481 }
482
483 let candidates: Vec<&std::path::PathBuf> = if primary.is_empty() {
485 result.files.iter().take(5).map(|e| &e.path).collect()
486 } else {
487 primary
488 };
489 let listing = candidates
490 .iter()
491 .take(5)
492 .map(|p| format!(" - {}", p.strip_prefix(scope).unwrap_or(p).display()))
493 .collect::<Vec<_>>()
494 .join("\n");
495 let more = if candidates.len() > 5 {
496 format!("\n ... and {} more", candidates.len() - 5)
497 } else {
498 String::new()
499 };
500 Err(SrcwalkError::InvalidQuery {
501 query: original_query.to_string(),
502 reason: format!(
503 "matches {total} files; --section needs exactly one. Candidates:\n{listing}{more}\nPass full path or narrow --scope."
504 ),
505 })
506}
507
508fn run_inner(
509 query: &str,
510 scope: &Path,
511 section: Option<&str>,
512 budget_tokens: Option<u64>,
513 full: bool,
514 expand: usize,
515 limit: Option<usize>,
516 offset: usize,
517 glob: Option<&str>,
518 cache: &OutlineCache,
519) -> Result<String, SrcwalkError> {
520 let query_type = classify(query, scope);
521
522 let mut resolution_note: Option<String> = None;
527 let query_type = if section.is_some() {
528 if let QueryType::Glob(pattern) = &query_type {
529 match disambiguate_glob_for_section(pattern, scope, query)? {
530 Some((picked, note)) => {
531 resolution_note = note;
532 QueryType::FilePath(picked)
533 }
534 None => query_type,
535 }
536 } else {
537 query_type
538 }
539 } else {
540 query_type
541 };
542
543 let use_expanded =
544 expand > 0 && !matches!(query_type, QueryType::FilePath(_) | QueryType::Glob(_));
545
546 if query.contains(',')
550 && !matches!(
551 query_type,
552 QueryType::Regex(_) | QueryType::Glob(_) | QueryType::FilePath(_)
553 )
554 {
555 let parts: Vec<&str> = query
556 .split(',')
557 .map(str::trim)
558 .filter(|s| !s.is_empty())
559 .collect();
560 let all_identifiers = parts.iter().all(|p| classify::is_identifier(p));
561 if parts.len() > 5 && all_identifiers {
562 return Err(SrcwalkError::InvalidQuery {
563 query: query.to_string(),
564 reason: "multi-symbol search supports 2-5 symbols".to_string(),
565 });
566 }
567 if parts.len() >= 2 && parts.len() <= 5 && all_identifiers {
568 let session = session::Session::new();
569 let sym_index = index::SymbolIndex::new();
570 let bloom = index::bloom::BloomFilterCache::new();
571 let expand = if expand > 0 { expand } else { 2 };
572 let output = search::search_multi_symbol_expanded(
573 &parts, scope, cache, &session, &sym_index, &bloom, expand, None, limit, offset,
574 glob,
575 )?;
576 return match budget_tokens {
577 Some(b) => Ok(budget::apply(&output, b)),
578 None => Ok(output),
579 };
580 }
581 }
582
583 let output = match query_type {
585 QueryType::FilePath(path) => {
586 let mut out = read::read_file_with_budget(&path, section, full, budget_tokens, cache)?;
587 if section.is_none() && !full && read::would_outline(&path) {
588 let related = read::imports::resolve_related_files(&path);
589 if !related.is_empty() {
590 let hints: Vec<String> = related
591 .iter()
592 .filter_map(|p| p.strip_prefix(scope).ok().or(Some(p.as_path())))
593 .map(|p| p.display().to_string())
594 .collect();
595 out.push_str("\n\n> Related: ");
596 out.push_str(&hints.join(", "));
597 }
598 out.push_str("\n> Tip: use --deps to see imports and dependents (blast radius)");
599 }
600 out
601 }
602 QueryType::Glob(pattern) => search::search_glob(&pattern, scope, cache, limit, offset)?,
603 _ if use_expanded => {
604 let ctx = ExpandedCtx {
605 session: session::Session::new(),
606 sym_index: index::SymbolIndex::new(),
607 bloom: index::bloom::BloomFilterCache::new(),
608 expand,
609 };
610 run_query_expanded(&query_type, scope, cache, &ctx, limit, offset, glob)?
611 }
612 _ => run_query_basic(&query_type, scope, cache, limit, offset, glob)?,
613 };
614
615 let final_out = match budget_tokens {
616 Some(b) => budget::apply(&output, b),
617 None => output,
618 };
619 Ok(match resolution_note {
620 Some(note) => format!("{note}\n\n{final_out}"),
621 None => final_out,
622 })
623}
624
625fn run_query_expanded(
628 query_type: &QueryType,
629 scope: &Path,
630 cache: &OutlineCache,
631 ctx: &ExpandedCtx,
632 limit: Option<usize>,
633 offset: usize,
634 glob: Option<&str>,
635) -> Result<String, SrcwalkError> {
636 match query_type {
637 QueryType::Symbol(name) => search::search_symbol_expanded(
638 name,
639 scope,
640 cache,
641 &ctx.session,
642 &ctx.sym_index,
643 &ctx.bloom,
644 ctx.expand,
645 None,
646 limit,
647 offset,
648 glob,
649 ),
650 QueryType::Concept(text) if text.contains(' ') => search::search_content_expanded(
651 text,
652 scope,
653 cache,
654 &ctx.session,
655 ctx.expand,
656 None,
657 limit,
658 offset,
659 glob,
660 ),
661 QueryType::Concept(text) | QueryType::Fallthrough(text) => search::search_symbol_expanded(
662 text,
663 scope,
664 cache,
665 &ctx.session,
666 &ctx.sym_index,
667 &ctx.bloom,
668 ctx.expand,
669 None,
670 limit,
671 offset,
672 glob,
673 ),
674 QueryType::Regex(pattern) => search::search_regex_expanded(
675 pattern,
676 scope,
677 cache,
678 &ctx.session,
679 ctx.expand,
680 None,
681 limit,
682 offset,
683 glob,
684 ),
685 QueryType::FilePath(_) | QueryType::Glob(_) => {
687 unreachable!("non-search query type in expanded path")
688 }
689 }
690}
691
692fn run_query_basic(
695 query_type: &QueryType,
696 scope: &Path,
697 cache: &OutlineCache,
698 limit: Option<usize>,
699 offset: usize,
700 glob: Option<&str>,
701) -> Result<String, SrcwalkError> {
702 match query_type {
703 QueryType::Symbol(name) => search::search_symbol(name, scope, cache, limit, offset, glob),
704 QueryType::Concept(text) if text.contains(' ') => {
705 multi_word_concept_search(text, scope, cache, limit, offset, glob)
706 }
707 QueryType::Concept(text) => {
708 single_query_search(text, scope, cache, true, limit, offset, glob)
709 }
710 QueryType::Regex(pattern) => {
711 search::search_regex(pattern, scope, cache, limit, offset, glob)
712 }
713 QueryType::Fallthrough(text) => {
714 single_query_search(text, scope, cache, false, limit, offset, glob)
715 }
716 QueryType::FilePath(_) | QueryType::Glob(_) => {
717 unreachable!("non-search query type in basic path")
718 }
719 }
720}
721
722fn single_query_search(
728 text: &str,
729 scope: &Path,
730 cache: &cache::OutlineCache,
731 prefer_definitions: bool,
732 limit: Option<usize>,
733 offset: usize,
734 glob: Option<&str>,
735) -> Result<String, error::SrcwalkError> {
736 let mut sym_result = search::search_symbol_raw(text, scope, glob)?;
737 let accept_sym = if prefer_definitions {
738 sym_result.definitions > 0
739 } else {
740 sym_result.total_found > 0
741 };
742
743 if accept_sym {
744 search::pagination::paginate(&mut sym_result, limit, offset);
745 return search::format_raw_result(&sym_result, cache);
746 }
747
748 let mut content_result = search::search_content_raw(text, scope, glob)?;
749 if content_result.total_found > 0 {
750 search::pagination::paginate(&mut content_result, limit, offset);
751 return search::format_raw_result(&content_result, cache);
752 }
753
754 if prefer_definitions && sym_result.total_found > 0 {
756 search::pagination::paginate(&mut sym_result, limit, offset);
757 return search::format_raw_result(&sym_result, cache);
758 }
759
760 Err(error::SrcwalkError::NoMatches {
761 query: text.to_string(),
762 scope: scope.to_path_buf(),
763 suggestion: symbol_or_file_suggestion(scope, text, glob),
764 })
765}
766
767fn multi_word_concept_search(
769 text: &str,
770 scope: &Path,
771 cache: &cache::OutlineCache,
772 limit: Option<usize>,
773 offset: usize,
774 glob: Option<&str>,
775) -> Result<String, error::SrcwalkError> {
776 let mut content_result = search::search_content_raw(text, scope, glob)?;
778 content_result.query = text.to_string();
779 if content_result.total_found > 0 {
780 search::pagination::paginate(&mut content_result, limit, offset);
781 return search::format_raw_result(&content_result, cache);
782 }
783
784 let words: Vec<&str> = text.split_whitespace().collect();
786 let relaxed = if words.len() == 2 {
787 format!(
788 "{}.*{}|{}.*{}",
789 regex_syntax::escape(words[0]),
790 regex_syntax::escape(words[1]),
791 regex_syntax::escape(words[1]),
792 regex_syntax::escape(words[0]),
793 )
794 } else {
795 words
797 .iter()
798 .map(|w| regex_syntax::escape(w))
799 .collect::<Vec<_>>()
800 .join("|")
801 };
802
803 let mut relaxed_result = search::search_regex_raw(&relaxed, scope, glob)?;
804 relaxed_result.query = text.to_string();
805 if relaxed_result.total_found > 0 {
806 search::pagination::paginate(&mut relaxed_result, limit, offset);
807 return search::format_raw_result(&relaxed_result, cache);
808 }
809
810 let first_word = words.first().copied().unwrap_or(text);
811 Err(error::SrcwalkError::NoMatches {
812 query: text.to_string(),
813 scope: scope.to_path_buf(),
814 suggestion: symbol_or_file_suggestion(scope, first_word, glob),
815 })
816}
817
818fn symbol_or_file_suggestion(scope: &Path, query: &str, glob: Option<&str>) -> Option<String> {
821 let hits = search::symbol::suggest(query, scope, glob, 1);
822 if let Some((name, path, line)) = hits.into_iter().next() {
823 let q_low: String = query
825 .chars()
826 .filter(|c| *c != '_')
827 .flat_map(char::to_lowercase)
828 .collect();
829 let n_low: String = name
830 .chars()
831 .filter(|c| *c != '_')
832 .flat_map(char::to_lowercase)
833 .collect();
834 if q_low == n_low {
835 return None;
836 }
837 let rel = path.strip_prefix(scope).unwrap_or(&path).display();
838 return Some(format!("{name} ({rel}:{line})"));
839 }
840 read::suggest_similar_file(scope, query)
841}