Skip to main content

srcwalk/
lib.rs

1#![warn(clippy::pedantic)]
2#![allow(
3    clippy::cast_possible_truncation,  // line numbers as u32, token counts — we target 64-bit
4    clippy::cast_sign_loss,            // same
5    clippy::cast_possible_wrap,        // u32→i32 for tree-sitter APIs
6    clippy::module_name_repetitions,   // Rust naming conventions
7    clippy::similar_names,             // common in parser/search code
8    clippy::too_many_lines,            // one complex function (find_definitions)
9    clippy::too_many_arguments,        // internal recursive AST walker
10    clippy::unnecessary_wraps,         // Result return for API consistency
11    clippy::struct_excessive_bools,    // CLI struct derives clap
12    clippy::missing_errors_doc,        // internal pub(crate) fns don't need error docs
13    clippy::missing_panics_doc,        // same
14)]
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
37/// Holds expanded search dependencies, allocated once.
38/// Avoids scattered `Option<T>` + `unwrap()` throughout dispatch.
39struct ExpandedCtx {
40    session: session::Session,
41    sym_index: index::SymbolIndex,
42    bloom: index::bloom::BloomFilterCache,
43    expand: usize,
44}
45
46/// The single public API. Everything flows through here:
47/// classify → match on query type → return formatted string.
48pub 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
72/// Full variant — forces full file output, bypassing smart views.
73pub 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
97/// Run with expanded search — inline source for top N matches.
98pub 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/// Find all callers of a symbol.
125#[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    // BFS path when --depth N (N >= 2). Otherwise preserve legacy behavior byte-exact.
146    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        // BFS JSON handles its own budget internally (edges array cap).
170        // Legacy callers JSON uses the generic budget::apply below.
171        return Ok(output);
172    }
173    match budget_tokens {
174        Some(b) => Ok(budget::apply(&output, b)),
175        None => Ok(output),
176    }
177}
178
179/// Show what a symbol calls (forward call graph).
180pub 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    // Find definition of target symbol
192    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    // Detailed mode: ordered call sites with args + assignment context.
216    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        // Collect unresolved (names not matching any site — skip for detailed, all are sites)
234        let output = match budget_tokens {
235            Some(b) => budget::apply(&out, b),
236            None => out,
237        };
238        return Ok(output);
239    }
240
241    // Default mode: resolved callees with transitive expansion.
242    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    // Unresolved callees
263    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
315/// Analyze blast-radius dependencies of a file.
316pub 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
328/// Test/vendor/build directories that we de-prioritize when picking a single
329/// file for a bare-filename + `--section` request.
330const 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
357/// Build a set of files visible to a .gitignore-respecting walk of `scope`.
358/// Anything NOT in this set (e.g. build artifacts, benchmark fixtures, caches,
359/// egg-info, venvs) is treated as non-primary — this lets us avoid hardcoding
360/// every repo's ignore patterns and naturally adapts to whatever conventions
361/// a project uses (`.gitignore` + `.ignore` + `.git/info/exclude`).
362fn 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
381/// Rank by path-depth from scope (shallower = more primary). Used as a
382/// tiebreaker when gitignore + hardcoded filters still leave >1 candidate:
383/// an `index.ts` or `Program.cs` at the workspace root is almost always the
384/// one the agent wants, vs. nested test harness copies.
385fn depth_from_scope(path: &Path, scope: &Path) -> usize {
386    path.strip_prefix(scope)
387        .unwrap_or(path)
388        .components()
389        .count()
390}
391
392/// Resolve a glob pattern produced from a bare filename to a single file when
393/// `--section` is supplied. Returns:
394/// - `Some((picked, Some(note)))` when exactly one prod-path candidate exists
395///   and other candidates were skipped.
396/// - `Some((picked, None))` when there's a single match overall.
397/// - Returns an `Err(InvalidQuery)` listing candidates when the choice is
398///   ambiguous (>1 prod paths or >1 total with no prod/non-prod split).
399/// - `Ok(None)` when the glob matched nothing — caller falls back to the
400///   normal Glob handler so existing 0-match UX is preserved.
401fn 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    // .gitignore-aware "primary" set — a file is primary iff it is visible
417    // to a standard gitignore-respecting walk AND not inside one of the
418    // hardcoded test/vendor segments (which stay around even in repos
419    // without a .gitignore).
420    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    // Picker: single primary → done. Multiple primary → break tie by
429    // min depth-from-scope if unique, otherwise fail loud.
430    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        // Preview up to 3 of the skipped non-primary paths so the agent
455        // knows what got filtered (helps when the pick is wrong).
456        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    // Ambiguous — fail loud with top-5 candidates (prefer primary set).
484    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    // P1.2 — disambiguate bare-filename + --section.
523    // Glob classification swallows `--section` silently for bare filenames like
524    // `Cart.php`. When section is set, resolve the glob now: pick the prod
525    // candidate if exactly one survives test/vendor filtering, else fail loud.
526    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    // Multi-symbol: comma-separated identifiers, 2..=5 items
547    // Check before main dispatch. Only activate when all parts look like identifiers
548    // to avoid hijacking regex (/foo,bar/) or glob (*.{rs,ts}) queries.
549    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    // FilePath and Glob are read operations, not search — handle before expanded dispatch
584    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
625/// Dispatch search queries in expanded mode (inline source for top N matches).
626/// Only called for search query types — FilePath/Glob are handled before this.
627fn 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        // FilePath/Glob never reach here (gated by use_expanded)
686        QueryType::FilePath(_) | QueryType::Glob(_) => {
687            unreachable!("non-search query type in expanded path")
688        }
689    }
690}
691
692/// Dispatch search queries in basic mode (no expansion).
693/// Only called for search query types — FilePath/Glob are handled before this.
694fn 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
722/// Shared cascade for single-word queries: symbol → content → not found.
723///
724/// When `prefer_definitions` is true (Concept path), only accept symbol results
725/// that contain actual definitions; fall back to content otherwise.
726/// When false (Fallthrough path), accept any symbol match immediately.
727fn 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    // For concept queries: if symbol had usages but no definitions, show those
755    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
767/// Multi-word concept search: exact phrase first, then relaxed word proximity.
768fn 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    // Try exact phrase match first
777    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    // Relaxed: match all words in any order
785    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        // 3+ words: match any word (OR), rely on multi_word_boost in ranking
796        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
818/// Cross-convention symbol suggest first (P1.3 infra), then file-name fallback.
819/// Used by symbol→content miss paths so users get a useful "Did you mean: ...".
820fn 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        // Skip case-only variants to avoid suggest loops (foo→Foo→foo).
824        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}