Skip to main content

zeph_tools/
search_code.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::LazyLock;
8use std::time::{Duration, Instant};
9
10use schemars::JsonSchema;
11use serde::Deserialize;
12use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
13
14use zeph_common::ToolName;
15
16use crate::executor::{
17    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
18};
19use crate::file::expand_tilde;
20use crate::registry::{InvocationHint, ToolDef};
21
22// ---------------------------------------------------------------------------
23// Language detection
24// ---------------------------------------------------------------------------
25
26use zeph_common::treesitter::{
27    GO_SYM_Q, JS_SYM_Q, PYTHON_SYM_Q, RUST_SYM_Q, TS_SYM_Q, compile_query, lang_for_ext,
28};
29
30struct LangInfo {
31    grammar: tree_sitter::Language,
32    symbol_query: Option<&'static Query>,
33}
34
35fn lang_info_for_path(path: &Path) -> Option<LangInfo> {
36    let ext = path.extension()?.to_str()?;
37    let grammar = lang_for_ext(ext)?;
38    let symbol_query = match ext {
39        "rs" => {
40            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
41                let lang: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into();
42                compile_query(&lang, RUST_SYM_Q, "rust")
43            });
44            Q.as_ref()
45        }
46        "py" | "pyi" => {
47            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
48                let lang: tree_sitter::Language = tree_sitter_python::LANGUAGE.into();
49                compile_query(&lang, PYTHON_SYM_Q, "python")
50            });
51            Q.as_ref()
52        }
53        "js" | "jsx" | "mjs" | "cjs" => {
54            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
55                let lang: tree_sitter::Language = tree_sitter_javascript::LANGUAGE.into();
56                compile_query(&lang, JS_SYM_Q, "javascript")
57            });
58            Q.as_ref()
59        }
60        "ts" | "tsx" | "mts" | "cts" => {
61            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
62                let lang: tree_sitter::Language =
63                    tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
64                compile_query(&lang, TS_SYM_Q, "typescript")
65            });
66            Q.as_ref()
67        }
68        "go" => {
69            static Q: LazyLock<Option<Query>> = LazyLock::new(|| {
70                let lang: tree_sitter::Language = tree_sitter_go::LANGUAGE.into();
71                compile_query(&lang, GO_SYM_Q, "go")
72            });
73            Q.as_ref()
74        }
75        _ => None,
76    };
77    Some(LangInfo {
78        grammar,
79        symbol_query,
80    })
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum SearchCodeSource {
86    Semantic,
87    Structural,
88    LspSymbol,
89    LspReferences,
90    GrepFallback,
91}
92
93impl SearchCodeSource {
94    fn label(self) -> &'static str {
95        match self {
96            Self::Semantic => "vector search",
97            Self::Structural => "tree-sitter",
98            Self::LspSymbol => "LSP symbol search",
99            Self::LspReferences => "LSP references",
100            Self::GrepFallback => "grep fallback",
101        }
102    }
103
104    #[must_use]
105    pub fn default_score(self) -> f32 {
106        match self {
107            Self::Structural => 0.98,
108            Self::LspSymbol => 0.95,
109            Self::LspReferences => 0.90,
110            Self::Semantic => 0.75,
111            Self::GrepFallback => 0.45,
112        }
113    }
114}
115
116#[derive(Debug, Clone)]
117pub struct SearchCodeHit {
118    pub file_path: String,
119    pub line_start: usize,
120    pub line_end: usize,
121    pub snippet: String,
122    pub source: SearchCodeSource,
123    pub score: f32,
124    pub symbol_name: Option<String>,
125}
126
127pub trait SemanticSearchBackend: Send + Sync {
128    fn search<'a>(
129        &'a self,
130        query: &'a str,
131        file_pattern: Option<&'a str>,
132        max_results: usize,
133    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
134}
135
136pub trait LspSearchBackend: Send + Sync {
137    fn workspace_symbol<'a>(
138        &'a self,
139        symbol: &'a str,
140        file_pattern: Option<&'a str>,
141        max_results: usize,
142    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
143
144    fn references<'a>(
145        &'a self,
146        symbol: &'a str,
147        file_pattern: Option<&'a str>,
148        max_results: usize,
149    ) -> Pin<Box<dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a>>;
150}
151
152#[derive(Deserialize, JsonSchema)]
153struct SearchCodeParams {
154    /// Natural-language query for semantic search.
155    #[serde(default)]
156    query: Option<String>,
157    /// Exact or partial symbol name.
158    #[serde(default)]
159    symbol: Option<String>,
160    /// Optional glob restricting files, for example `crates/zeph-tools/**`.
161    #[serde(default)]
162    file_pattern: Option<String>,
163    /// Also return reference locations when `symbol` is provided.
164    #[serde(default)]
165    include_references: bool,
166    /// Cap on returned locations.
167    #[serde(default = "default_max_results")]
168    max_results: usize,
169}
170
171const fn default_max_results() -> usize {
172    10
173}
174
175/// Maximum directory entries a single structural/grep walk may visit before it
176/// is truncated. Bounds `allowed_paths` resolving to a huge tree (e.g. `~`).
177///
178/// Large monorepos can exceed a few thousand entries in normal operation, so this is
179/// generous; `MAX_WALK_DURATION` is the real backstop against the original
180/// pathological-scope hang (e.g. `allowed_paths` resolving to `~`), since wall-clock
181/// time bounds the walk regardless of how many entries a given filesystem can visit
182/// per second.
183const MAX_WALK_ENTRIES: usize = 50_000;
184
185/// Maximum wall-clock time a single structural/grep walk may run before it is
186/// truncated, independent of entry count (guards against slow filesystems).
187const MAX_WALK_DURATION: Duration = Duration::from_secs(2);
188
189/// Safety net shared by the structural and grep-fallback directory walks.
190///
191/// `allowed_paths` is user/config controlled and may resolve to an
192/// unexpectedly broad root (e.g. `~`). Without a bound, a recursive walk over
193/// such a root can run indefinitely. `tick` caps both the number of entries
194/// visited and elapsed wall-clock time; once either limit is hit, the walk
195/// stops and reports partial results as truncated instead of hanging.
196struct WalkBudget {
197    started: Instant,
198    visited: usize,
199    truncated: bool,
200}
201
202impl WalkBudget {
203    fn new() -> Self {
204        Self {
205            started: Instant::now(),
206            visited: 0,
207            truncated: false,
208        }
209    }
210
211    /// Records one visited directory entry; returns `true` once the walk
212    /// should stop because the budget is exhausted.
213    fn tick(&mut self) -> bool {
214        if self.truncated {
215            return true;
216        }
217        self.visited += 1;
218        if self.visited > MAX_WALK_ENTRIES || self.started.elapsed() > MAX_WALK_DURATION {
219            self.truncated = true;
220        }
221        self.truncated
222    }
223}
224
225/// Bundles the two pieces of walk-wide mutable state threaded through the
226/// structural/grep directory recursion: the [`WalkBudget`] and the symlink-cycle
227/// guard (canonical paths of directories on the *active* recursion path). Grouped
228/// into one struct so the walk functions stay under clippy's argument-count limit.
229struct WalkState<'a> {
230    budget: &'a mut WalkBudget,
231    ancestors: &'a mut Vec<PathBuf>,
232}
233
234/// Canonicalizes `current` and checks it against the active recursion stack in
235/// `ancestors` — the canonical paths of directories on the *active* recursion path
236/// (not every directory ever visited). A symlinked directory is only a cycle when
237/// following it would revisit one of those ancestors; legitimate symlinks that point
238/// elsewhere (a vendored dependency, a symlinked source file) are not cycles.
239///
240/// Returns `true` when `current` would revisit an ancestor — the caller must stop
241/// without recursing further. Otherwise pushes `current`'s canonical path onto
242/// `ancestors` and returns `false`; the caller **must** pop it (`ancestors.pop()`)
243/// once the recursive body for `current` returns.
244///
245/// Shared by [`collect_structural_hits`] and [`collect_grep_hits`] so the two walk
246/// modes cannot silently diverge (#5588).
247fn enters_symlink_cycle(ancestors: &mut Vec<PathBuf>, current: &Path) -> bool {
248    let canonical = current
249        .canonicalize()
250        .unwrap_or_else(|_| current.to_path_buf());
251    if ancestors.contains(&canonical) {
252        return true;
253    }
254    ancestors.push(canonical);
255    false
256}
257
258pub struct SearchCodeExecutor {
259    allowed_paths: Vec<PathBuf>,
260    semantic_backend: Option<std::sync::Arc<dyn SemanticSearchBackend>>,
261    lsp_backend: Option<std::sync::Arc<dyn LspSearchBackend>>,
262}
263
264impl std::fmt::Debug for SearchCodeExecutor {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("SearchCodeExecutor")
267            .field("allowed_paths", &self.allowed_paths)
268            .field("has_semantic_backend", &self.semantic_backend.is_some())
269            .field("has_lsp_backend", &self.lsp_backend.is_some())
270            .finish()
271    }
272}
273
274impl SearchCodeExecutor {
275    #[must_use]
276    pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
277        let paths = if allowed_paths.is_empty() {
278            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
279        } else {
280            allowed_paths.into_iter().map(expand_tilde).collect()
281        };
282        Self {
283            allowed_paths: paths
284                .into_iter()
285                .map(|p| p.canonicalize().unwrap_or(p))
286                .collect(),
287            semantic_backend: None,
288            lsp_backend: None,
289        }
290    }
291
292    #[must_use]
293    pub fn with_semantic_backend(
294        mut self,
295        backend: std::sync::Arc<dyn SemanticSearchBackend>,
296    ) -> Self {
297        self.semantic_backend = Some(backend);
298        self
299    }
300
301    #[must_use]
302    pub fn with_lsp_backend(mut self, backend: std::sync::Arc<dyn LspSearchBackend>) -> Self {
303        self.lsp_backend = Some(backend);
304        self
305    }
306
307    async fn handle_search_code(
308        &self,
309        params: &SearchCodeParams,
310    ) -> Result<Option<ToolOutput>, ToolError> {
311        let query = params
312            .query
313            .as_deref()
314            .map(str::trim)
315            .filter(|s| !s.is_empty());
316        let symbol = params
317            .symbol
318            .as_deref()
319            .map(str::trim)
320            .filter(|s| !s.is_empty());
321
322        if query.is_none() && symbol.is_none() {
323            return Err(ToolError::InvalidParams {
324                message: "at least one of `query` or `symbol` must be provided".into(),
325            });
326        }
327
328        let max_results = params.max_results.clamp(1, 50);
329        let mut hits = Vec::new();
330        let mut walk_truncated = false;
331
332        if let Some(query) = query
333            && let Some(backend) = &self.semantic_backend
334        {
335            hits.extend(
336                backend
337                    .search(query, params.file_pattern.as_deref(), max_results)
338                    .await?,
339            );
340        }
341
342        if let Some(symbol) = symbol {
343            let paths = self.allowed_paths.clone();
344            let sym = symbol.to_owned();
345            let pat = params.file_pattern.clone();
346            let (structural_hits, structural_truncated) = tokio::task::spawn_blocking(move || {
347                collect_all_structural_hits(&paths, &sym, pat.as_deref(), max_results)
348            })
349            .await
350            .map_err(|e| ToolError::Execution(e.into()))??;
351            hits.extend(structural_hits);
352            walk_truncated |= structural_truncated;
353
354            if let Some(backend) = &self.lsp_backend {
355                if let Ok(lsp_hits) = backend
356                    .workspace_symbol(symbol, params.file_pattern.as_deref(), max_results)
357                    .await
358                {
359                    hits.extend(lsp_hits);
360                }
361                if params.include_references
362                    && let Ok(lsp_refs) = backend
363                        .references(symbol, params.file_pattern.as_deref(), max_results)
364                        .await
365                {
366                    hits.extend(lsp_refs);
367                }
368            }
369        }
370
371        if hits.is_empty() {
372            let fallback_term = symbol.or(query).unwrap_or_default();
373            let (grep_hits, grep_truncated) =
374                self.grep_fallback(fallback_term, params.file_pattern.as_deref(), max_results)?;
375            hits.extend(grep_hits);
376            walk_truncated |= grep_truncated;
377        }
378
379        let merged = dedupe_hits(hits, max_results);
380        let root = self
381            .allowed_paths
382            .first()
383            .map_or(Path::new("."), PathBuf::as_path);
384        Ok(Some(build_search_code_output(
385            &merged,
386            root,
387            walk_truncated,
388        )))
389    }
390
391    /// Returns the collected hits alongside whether the walk was cut short by
392    /// [`WalkBudget`].
393    fn grep_fallback(
394        &self,
395        pattern: &str,
396        file_pattern: Option<&str>,
397        max_results: usize,
398    ) -> Result<(Vec<SearchCodeHit>, bool), ToolError> {
399        let matcher = file_pattern
400            .map(glob::Pattern::new)
401            .transpose()
402            .map_err(|e| ToolError::InvalidParams {
403                message: format!("invalid file_pattern: {e}"),
404            })?;
405        let escaped = regex::escape(pattern);
406        let regex = regex::RegexBuilder::new(&escaped)
407            .case_insensitive(true)
408            .build()
409            .map_err(|e| ToolError::InvalidParams {
410                message: e.to_string(),
411            })?;
412        let mut hits = Vec::new();
413        let mut budget = WalkBudget::new();
414        for root in &self.allowed_paths {
415            let mut ancestors = Vec::new();
416            let mut state = WalkState {
417                budget: &mut budget,
418                ancestors: &mut ancestors,
419            };
420            collect_grep_hits(
421                root,
422                root,
423                matcher.as_ref(),
424                &regex,
425                &mut hits,
426                max_results,
427                &mut state,
428            )?;
429            if hits.len() >= max_results || budget.truncated {
430                break;
431            }
432        }
433        Ok((hits, budget.truncated))
434    }
435}
436
437impl ToolExecutor for SearchCodeExecutor {
438    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
439        Ok(None)
440    }
441
442    #[cfg_attr(
443        feature = "profiling",
444        tracing::instrument(name = "tools.search_code.execute", skip_all)
445    )]
446    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
447        if call.tool_id != "search_code" {
448            return Ok(None);
449        }
450        let params: SearchCodeParams = deserialize_params(&call.params)?;
451        self.handle_search_code(&params).await
452    }
453
454    fn tool_definitions(&self) -> Vec<ToolDef> {
455        vec![ToolDef {
456            id: "search_code".into(),
457            description: "Search the codebase using semantic, structural, and LSP sources. Use only to search source code files — not for user-provided facts, preferences, or statements made in conversation.\n\nParameters: query (string, optional) - natural language description to find semantically similar code; symbol (string, optional) - exact or partial symbol name for definition search; file_pattern (string, optional) - glob restricting files; include_references (boolean, optional) - also return symbol references when LSP is available; max_results (integer, optional) - cap results 1-50, default 10\nReturns: ranked code locations with file path, line range, snippet, source label, and score\nErrors: InvalidParams when both query and symbol are empty\nExample: {\"query\": \"where is retry backoff calculated\", \"symbol\": \"retry_backoff_ms\", \"include_references\": true}".into(),
458            schema: schemars::schema_for!(SearchCodeParams),
459            invocation: InvocationHint::ToolCall,
460            output_schema: None,
461            server_id: None,
462        }]
463    }
464
465    crate::tool_executor_no_inner_defaults!();
466}
467
468/// Traverse `allowed_paths` collecting structural symbol hits synchronously.
469///
470/// Extracted as a free function so callers can run it inside
471/// `tokio::task::spawn_blocking` without borrowing `self`.
472///
473/// Returns the collected hits alongside whether the walk was cut short by
474/// [`WalkBudget`] (see its docs for why the bound exists).
475fn collect_all_structural_hits(
476    allowed_paths: &[PathBuf],
477    symbol: &str,
478    file_pattern: Option<&str>,
479    max_results: usize,
480) -> Result<(Vec<SearchCodeHit>, bool), ToolError> {
481    let matcher = file_pattern
482        .map(glob::Pattern::new)
483        .transpose()
484        .map_err(|e| ToolError::InvalidParams {
485            message: format!("invalid file_pattern: {e}"),
486        })?;
487    let mut hits = Vec::new();
488    let symbol_lower = symbol.to_lowercase();
489    let mut budget = WalkBudget::new();
490    for root in allowed_paths {
491        let mut ancestors = Vec::new();
492        let mut state = WalkState {
493            budget: &mut budget,
494            ancestors: &mut ancestors,
495        };
496        collect_structural_hits(
497            root,
498            root,
499            matcher.as_ref(),
500            &symbol_lower,
501            &mut hits,
502            &mut state,
503        )?;
504        if hits.len() >= max_results || budget.truncated {
505            break;
506        }
507    }
508    Ok((hits, budget.truncated))
509}
510
511fn dedupe_hits(mut hits: Vec<SearchCodeHit>, max_results: usize) -> Vec<SearchCodeHit> {
512    let mut merged: HashMap<(String, usize, usize), SearchCodeHit> = HashMap::new();
513    for hit in hits.drain(..) {
514        let key = (hit.file_path.clone(), hit.line_start, hit.line_end);
515        merged
516            .entry(key)
517            .and_modify(|existing| {
518                if hit.score > existing.score {
519                    existing.score = hit.score;
520                    existing.snippet.clone_from(&hit.snippet);
521                    existing.symbol_name = hit.symbol_name.clone().or(existing.symbol_name.clone());
522                }
523                if existing.source != hit.source {
524                    existing.source = if existing.score >= hit.score {
525                        existing.source
526                    } else {
527                        hit.source
528                    };
529                }
530            })
531            .or_insert(hit);
532    }
533
534    let mut merged = merged.into_values().collect::<Vec<_>>();
535    merged.sort_by(|a, b| {
536        b.score
537            .partial_cmp(&a.score)
538            .unwrap_or(std::cmp::Ordering::Equal)
539            .then_with(|| a.file_path.cmp(&b.file_path))
540            .then_with(|| a.line_start.cmp(&b.line_start))
541    });
542    merged.truncate(max_results);
543    merged
544}
545
546/// Assembles the final `search_code` [`ToolOutput`] from merged hits.
547///
548/// `walk_truncated` reports whether any directory walk (structural or grep
549/// fallback) was cut short by [`WalkBudget`] — surfaced both as a note in the
550/// summary text and as a `truncated` field in `raw_response` so callers can
551/// detect incomplete results programmatically.
552fn build_search_code_output(
553    hits: &[SearchCodeHit],
554    root: &Path,
555    walk_truncated: bool,
556) -> ToolOutput {
557    let mut summary = format_hits(hits, root);
558    if walk_truncated {
559        summary.push_str(
560            "\n\n[search truncated: directory walk exceeded the safety budget; \
561             results may be incomplete — narrow `allowed_paths` or `file_pattern`]",
562        );
563    }
564    let locations = hits
565        .iter()
566        .map(|hit| hit.file_path.clone())
567        .collect::<Vec<_>>();
568    let raw_response = serde_json::json!({
569        "results": hits.iter().map(|hit| {
570            serde_json::json!({
571                "file_path": hit.file_path,
572                "line_start": hit.line_start,
573                "line_end": hit.line_end,
574                "snippet": hit.snippet,
575                "source": hit.source.label(),
576                "score": hit.score,
577                "symbol_name": hit.symbol_name,
578            })
579        }).collect::<Vec<_>>(),
580        "truncated": walk_truncated,
581    });
582
583    ToolOutput {
584        tool_name: ToolName::new("search_code"),
585        summary,
586        blocks_executed: 1,
587        filter_stats: None,
588        diff: None,
589        streamed: false,
590        terminal_id: None,
591        locations: Some(locations),
592        raw_response: Some(raw_response),
593        claim_source: Some(ClaimSource::CodeSearch),
594        ..Default::default()
595    }
596}
597
598fn format_hits(hits: &[SearchCodeHit], root: &Path) -> String {
599    if hits.is_empty() {
600        return "No code matches found.".into();
601    }
602
603    hits.iter()
604        .enumerate()
605        .map(|(idx, hit)| {
606            let display_path = Path::new(&hit.file_path)
607                .strip_prefix(root)
608                .map_or_else(|_| hit.file_path.clone(), |p| p.display().to_string());
609            format!(
610                "[{}] {}:{}-{}\n    {}\n    source: {}\n    score: {:.2}",
611                idx + 1,
612                display_path,
613                hit.line_start,
614                hit.line_end,
615                hit.snippet.replace('\n', " "),
616                hit.source.label(),
617                hit.score,
618            )
619        })
620        .collect::<Vec<_>>()
621        .join("\n\n")
622}
623
624/// Walks `current`, guarding against symlink cycles via [`enters_symlink_cycle`], still
625/// bounded by [`WalkBudget`] via `state.budget`.
626fn collect_structural_hits(
627    root: &Path,
628    current: &Path,
629    matcher: Option<&glob::Pattern>,
630    symbol_lower: &str,
631    hits: &mut Vec<SearchCodeHit>,
632    state: &mut WalkState<'_>,
633) -> Result<(), ToolError> {
634    if should_skip_path(current) || state.budget.truncated {
635        return Ok(());
636    }
637
638    if enters_symlink_cycle(state.ancestors, current) {
639        return Ok(());
640    }
641    let result = collect_structural_hits_inner(root, current, matcher, symbol_lower, hits, state);
642    state.ancestors.pop();
643    result
644}
645
646fn collect_structural_hits_inner(
647    root: &Path,
648    current: &Path,
649    matcher: Option<&glob::Pattern>,
650    symbol_lower: &str,
651    hits: &mut Vec<SearchCodeHit>,
652    state: &mut WalkState<'_>,
653) -> Result<(), ToolError> {
654    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
655    for entry in entries {
656        if state.budget.tick() {
657            return Ok(());
658        }
659        let entry = entry.map_err(ToolError::Execution)?;
660        let path = entry.path();
661        let Ok(meta) = std::fs::symlink_metadata(&path) else {
662            continue;
663        };
664        let is_dir = if meta.file_type().is_symlink() {
665            match std::fs::metadata(&path) {
666                Ok(target_meta) => target_meta.is_dir(),
667                Err(_) => continue, // broken symlink target
668            }
669        } else {
670            meta.is_dir()
671        };
672        if is_dir {
673            collect_structural_hits(root, &path, matcher, symbol_lower, hits, state)?;
674            continue;
675        }
676        if !matches_pattern(root, &path, matcher) {
677            continue;
678        }
679        let Some(info) = lang_info_for_path(&path) else {
680            continue;
681        };
682        let grammar = info.grammar;
683        let Some(query) = info.symbol_query.as_ref() else {
684            continue;
685        };
686        let Ok(source) = std::fs::read_to_string(&path) else {
687            continue;
688        };
689        let mut parser = Parser::new();
690        if parser.set_language(&grammar).is_err() {
691            continue;
692        }
693        let Some(tree) = parser.parse(&source, None) else {
694            continue;
695        };
696        let mut cursor = QueryCursor::new();
697        let capture_names = query.capture_names();
698        let def_idx = capture_names.iter().position(|name| *name == "def");
699        let name_idx = capture_names.iter().position(|name| *name == "name");
700        let (Some(def_idx), Some(name_idx)) = (def_idx, name_idx) else {
701            continue;
702        };
703
704        let mut query_matches = cursor.matches(query, tree.root_node(), source.as_bytes());
705        while let Some(match_) = query_matches.next() {
706            let mut def_node = None;
707            let mut name = None;
708            for capture in match_.captures {
709                if capture.index as usize == def_idx {
710                    def_node = Some(capture.node);
711                }
712                if capture.index as usize == name_idx {
713                    name = Some(source[capture.node.byte_range()].to_string());
714                }
715            }
716            let Some(name) = name else {
717                continue;
718            };
719            if !name.to_lowercase().contains(symbol_lower) {
720                continue;
721            }
722            let Some(def_node) = def_node else {
723                continue;
724            };
725            hits.push(SearchCodeHit {
726                file_path: canonical_string(&path),
727                line_start: def_node.start_position().row + 1,
728                line_end: def_node.end_position().row + 1,
729                snippet: extract_snippet(&source, def_node.start_position().row + 1),
730                source: SearchCodeSource::Structural,
731                score: SearchCodeSource::Structural.default_score(),
732                symbol_name: Some(name),
733            });
734        }
735    }
736    Ok(())
737}
738
739/// Symlink cycle guard mirrors [`collect_structural_hits`] via [`enters_symlink_cycle`].
740fn collect_grep_hits(
741    root: &Path,
742    current: &Path,
743    matcher: Option<&glob::Pattern>,
744    regex: &regex::Regex,
745    hits: &mut Vec<SearchCodeHit>,
746    max_results: usize,
747    state: &mut WalkState<'_>,
748) -> Result<(), ToolError> {
749    if hits.len() >= max_results || should_skip_path(current) || state.budget.truncated {
750        return Ok(());
751    }
752
753    if enters_symlink_cycle(state.ancestors, current) {
754        return Ok(());
755    }
756    let result = collect_grep_hits_inner(root, current, matcher, regex, hits, max_results, state);
757    state.ancestors.pop();
758    result
759}
760
761fn collect_grep_hits_inner(
762    root: &Path,
763    current: &Path,
764    matcher: Option<&glob::Pattern>,
765    regex: &regex::Regex,
766    hits: &mut Vec<SearchCodeHit>,
767    max_results: usize,
768    state: &mut WalkState<'_>,
769) -> Result<(), ToolError> {
770    let entries = std::fs::read_dir(current).map_err(ToolError::Execution)?;
771    for entry in entries {
772        if state.budget.tick() {
773            return Ok(());
774        }
775        let entry = entry.map_err(ToolError::Execution)?;
776        let path = entry.path();
777        let Ok(meta) = std::fs::symlink_metadata(&path) else {
778            continue;
779        };
780        let is_dir = if meta.file_type().is_symlink() {
781            match std::fs::metadata(&path) {
782                Ok(target_meta) => target_meta.is_dir(),
783                Err(_) => continue, // broken symlink target
784            }
785        } else {
786            meta.is_dir()
787        };
788        if is_dir {
789            collect_grep_hits(root, &path, matcher, regex, hits, max_results, state)?;
790            continue;
791        }
792        if !matches_pattern(root, &path, matcher) {
793            continue;
794        }
795        let Ok(source) = std::fs::read_to_string(&path) else {
796            continue;
797        };
798        for (idx, line) in source.lines().enumerate() {
799            if regex.is_match(line) {
800                hits.push(SearchCodeHit {
801                    file_path: canonical_string(&path),
802                    line_start: idx + 1,
803                    line_end: idx + 1,
804                    snippet: line.trim().to_string(),
805                    source: SearchCodeSource::GrepFallback,
806                    score: SearchCodeSource::GrepFallback.default_score(),
807                    symbol_name: None,
808                });
809                if hits.len() >= max_results {
810                    return Ok(());
811                }
812            }
813        }
814    }
815    Ok(())
816}
817
818fn matches_pattern(root: &Path, path: &Path, matcher: Option<&glob::Pattern>) -> bool {
819    let Some(matcher) = matcher else {
820        return true;
821    };
822    let relative = path.strip_prefix(root).unwrap_or(path);
823    matcher.matches_path(relative)
824}
825
826fn should_skip_path(path: &Path) -> bool {
827    path.file_name()
828        .and_then(|name| name.to_str())
829        .is_some_and(|name| matches!(name, ".git" | "target" | "node_modules" | ".zeph"))
830}
831
832fn canonical_string(path: &Path) -> String {
833    path.canonicalize()
834        .unwrap_or_else(|_| path.to_path_buf())
835        .display()
836        .to_string()
837}
838
839fn extract_snippet(source: &str, line_number: usize) -> String {
840    source
841        .lines()
842        .nth(line_number.saturating_sub(1))
843        .map(str::trim)
844        .unwrap_or_default()
845        .to_string()
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use std::assert_matches;
852
853    struct EmptySemantic;
854
855    impl SemanticSearchBackend for EmptySemantic {
856        fn search<'a>(
857            &'a self,
858            _query: &'a str,
859            _file_pattern: Option<&'a str>,
860            _max_results: usize,
861        ) -> Pin<
862            Box<
863                dyn std::future::Future<Output = Result<Vec<SearchCodeHit>, ToolError>> + Send + 'a,
864            >,
865        > {
866            Box::pin(async move { Ok(vec![]) })
867        }
868    }
869
870    /// `lang_info_for_path` (#5971) now resolves its `grammar` field via the
871    /// shared `lang_for_ext`, and only the symbol-query match is left local to
872    /// this file. Before this test the only extension exercised through
873    /// `lang_info_for_path` was `.rs` (via `search_code_finds_structural_symbol`);
874    /// the other eight groups compiled but had no direct coverage at all in
875    /// this crate. Also checks that formats with no symbol query (bash, toml,
876    /// json, markdown) still resolve a grammar but leave `symbol_query` unset.
877    #[test]
878    fn lang_info_for_path_covers_all_extension_groups() {
879        let with_symbol_query = [
880            "main.rs",
881            "script.py",
882            "app.js",
883            "app.jsx",
884            "app.ts",
885            "app.tsx",
886            "main.go",
887        ];
888        for path in with_symbol_query {
889            let info = lang_info_for_path(Path::new(path))
890                .unwrap_or_else(|| panic!("expected grammar for {path}"));
891            assert!(
892                info.symbol_query.is_some(),
893                "expected symbol_query for {path}"
894            );
895        }
896
897        let without_symbol_query = [
898            "script.sh",
899            "script.bash",
900            "script.zsh",
901            "Cargo.toml",
902            "data.json",
903            "data.jsonc",
904            "README.md",
905            "README.markdown",
906        ];
907        for path in without_symbol_query {
908            let info = lang_info_for_path(Path::new(path))
909                .unwrap_or_else(|| panic!("expected grammar for {path}"));
910            assert!(
911                info.symbol_query.is_none(),
912                "expected no symbol_query for {path}"
913            );
914        }
915
916        assert!(lang_info_for_path(Path::new("file.xyz")).is_none());
917    }
918
919    #[tokio::test]
920    async fn search_code_requires_query_or_symbol() {
921        let dir = tempfile::tempdir().unwrap();
922        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
923        let call = ToolCall {
924            tool_id: "search_code".into(),
925            params: serde_json::Map::new(),
926            caller_id: None,
927            context: None,
928
929            tool_call_id: String::new(),
930            skill_name: None,
931        };
932        let err = exec.execute_tool_call(&call).await.unwrap_err();
933        assert_matches!(err, ToolError::InvalidParams { .. });
934    }
935
936    #[tokio::test]
937    async fn search_code_finds_structural_symbol() {
938        let dir = tempfile::tempdir().unwrap();
939        let file = dir.path().join("lib.rs");
940        std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
941        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
942        let call = ToolCall {
943            tool_id: "search_code".into(),
944            params: serde_json::json!({ "symbol": "retry_backoff_ms" })
945                .as_object()
946                .unwrap()
947                .clone(),
948            caller_id: None,
949            context: None,
950
951            tool_call_id: String::new(),
952            skill_name: None,
953        };
954        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
955        assert!(out.summary.contains("retry_backoff_ms"));
956        assert!(out.summary.contains("tree-sitter"));
957        assert_eq!(out.tool_name, "search_code");
958    }
959
960    #[tokio::test]
961    async fn search_code_uses_grep_fallback() {
962        let dir = tempfile::tempdir().unwrap();
963        let file = dir.path().join("mod.rs");
964        std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
965        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
966        let call = ToolCall {
967            tool_id: "search_code".into(),
968            params: serde_json::json!({ "query": "retry_backoff_ms" })
969                .as_object()
970                .unwrap()
971                .clone(),
972            caller_id: None,
973            context: None,
974
975            tool_call_id: String::new(),
976            skill_name: None,
977        };
978        let out = exec.execute_tool_call(&call).await.unwrap().unwrap();
979        assert!(out.summary.contains("grep fallback"));
980    }
981
982    #[test]
983    fn walk_budget_truncates_after_entry_limit() {
984        let mut budget = WalkBudget::new();
985        for _ in 0..MAX_WALK_ENTRIES {
986            assert!(!budget.tick(), "budget must not trip before the limit");
987        }
988        assert!(
989            budget.tick(),
990            "budget must trip once entries exceed MAX_WALK_ENTRIES"
991        );
992        assert!(budget.truncated);
993    }
994
995    /// Regression test for the hang reported against a broad `allowed_paths`
996    /// scope (e.g. `~`): a directory containing far more entries than
997    /// `MAX_WALK_ENTRIES` must not be walked exhaustively — the search
998    /// returns promptly with a truncation indicator instead of hanging.
999    #[tokio::test]
1000    async fn search_code_bounds_wide_directory_walk() {
1001        let dir = tempfile::tempdir().unwrap();
1002        for i in 0..(MAX_WALK_ENTRIES + 200) {
1003            std::fs::write(dir.path().join(format!("f{i}.txt")), "").unwrap();
1004        }
1005        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1006        let call = ToolCall {
1007            tool_id: "search_code".into(),
1008            params: serde_json::json!({ "symbol": "nonexistent_symbol_xyz" })
1009                .as_object()
1010                .unwrap()
1011                .clone(),
1012            caller_id: None,
1013            context: None,
1014
1015            tool_call_id: String::new(),
1016            skill_name: None,
1017        };
1018        let out = tokio::time::timeout(
1019            Duration::from_secs(MAX_WALK_DURATION.as_secs() + 10),
1020            exec.execute_tool_call(&call),
1021        )
1022        .await
1023        .expect("search_code must return within the walk budget, not hang")
1024        .unwrap()
1025        .unwrap();
1026        assert!(
1027            out.summary.contains("truncated"),
1028            "expected truncation note in summary, got: {}",
1029            out.summary
1030        );
1031        let raw = out.raw_response.unwrap();
1032        assert_eq!(raw["truncated"], serde_json::json!(true));
1033    }
1034
1035    /// A directory cycle created via a symlink pointing back to an ancestor
1036    /// must not send the walk into an infinite loop, even though symlinks are
1037    /// now followed in general (#5577).
1038    #[cfg(unix)]
1039    #[tokio::test]
1040    async fn search_code_does_not_follow_symlink_loop() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let file = dir.path().join("lib.rs");
1043        std::fs::write(&file, "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
1044        let loop_link = dir.path().join("self_loop");
1045        std::os::unix::fs::symlink(dir.path(), &loop_link).unwrap();
1046
1047        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1048        let call = ToolCall {
1049            tool_id: "search_code".into(),
1050            params: serde_json::json!({ "symbol": "retry_backoff_ms" })
1051                .as_object()
1052                .unwrap()
1053                .clone(),
1054            caller_id: None,
1055            context: None,
1056
1057            tool_call_id: String::new(),
1058            skill_name: None,
1059        };
1060        let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1061            .await
1062            .expect("search_code must not hang on a symlink loop")
1063            .unwrap()
1064            .unwrap();
1065        assert!(out.summary.contains("retry_backoff_ms"));
1066    }
1067
1068    /// Unit-level regression test for #5588: `enters_symlink_cycle` is the single
1069    /// shared implementation of the cycle-detection logic previously duplicated
1070    /// between `collect_structural_hits` and `collect_grep_hits`.
1071    #[test]
1072    fn enters_symlink_cycle_detects_repeated_ancestor() {
1073        let dir = tempfile::tempdir().unwrap();
1074        let mut ancestors = Vec::new();
1075
1076        assert!(!enters_symlink_cycle(&mut ancestors, dir.path()));
1077        assert_eq!(ancestors.len(), 1);
1078
1079        // Re-entering the same (canonicalized) path is a cycle.
1080        assert!(enters_symlink_cycle(&mut ancestors, dir.path()));
1081        // A cycle must not push a duplicate entry.
1082        assert_eq!(ancestors.len(), 1);
1083    }
1084
1085    #[test]
1086    fn enters_symlink_cycle_allows_distinct_paths() {
1087        let dir = tempfile::tempdir().unwrap();
1088        let child = dir.path().join("child");
1089        std::fs::create_dir_all(&child).unwrap();
1090        let mut ancestors = Vec::new();
1091
1092        assert!(!enters_symlink_cycle(&mut ancestors, dir.path()));
1093        assert!(!enters_symlink_cycle(&mut ancestors, &child));
1094        assert_eq!(ancestors.len(), 2);
1095    }
1096
1097    /// Regression test for #5577 (critic finding M1 / tester coverage gap 1): a
1098    /// symlink several directories deep pointing back at a non-root ancestor — not
1099    /// a direct self-loop — must still be caught. This only works because *every*
1100    /// directory entered (not just symlinked ones) is pushed onto the active
1101    /// recursion stack; a design that only tracked symlinked directories would miss
1102    /// this cycle, since `a` here is a plain directory, not a symlink.
1103    #[cfg(unix)]
1104    #[tokio::test]
1105    async fn search_code_does_not_follow_indirect_symlink_cycle() {
1106        let dir = tempfile::tempdir().unwrap();
1107        let a = dir.path().join("a");
1108        let c = a.join("b").join("c");
1109        std::fs::create_dir_all(&c).unwrap();
1110        std::fs::write(a.join("lib.rs"), "pub fn retry_backoff_ms() -> u64 { 0 }\n").unwrap();
1111        let back_to_a = c.join("back_to_a");
1112        std::os::unix::fs::symlink(&a, &back_to_a).unwrap();
1113
1114        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1115        let call = ToolCall {
1116            tool_id: "search_code".into(),
1117            params: serde_json::json!({ "symbol": "retry_backoff_ms" })
1118                .as_object()
1119                .unwrap()
1120                .clone(),
1121            caller_id: None,
1122            context: None,
1123
1124            tool_call_id: String::new(),
1125            skill_name: None,
1126        };
1127        let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1128            .await
1129            .expect("search_code must not hang on an indirect symlink cycle several levels deep")
1130            .unwrap()
1131            .unwrap();
1132        assert!(out.summary.contains("retry_backoff_ms"));
1133    }
1134
1135    /// Regression test for #5577 (tester coverage gap 2): `collect_grep_hits`'s
1136    /// cycle-detection logic is a hand-duplicated copy of
1137    /// `collect_structural_hits`'s (see [`WalkState`] docs) and had zero direct
1138    /// test coverage of its own loop-protection path — only the structural walk's
1139    /// was exercised by `search_code_does_not_follow_symlink_loop`. Uses `query`
1140    /// (no `symbol`, no semantic backend attached) so the search falls through to
1141    /// `grep_fallback`, exactly like `search_code_uses_grep_fallback`.
1142    #[cfg(unix)]
1143    #[tokio::test]
1144    async fn search_code_grep_fallback_does_not_follow_symlink_loop() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let file = dir.path().join("mod.rs");
1147        std::fs::write(&file, "let retry_backoff_ms = 5;\n").unwrap();
1148        let loop_link = dir.path().join("self_loop");
1149        std::os::unix::fs::symlink(dir.path(), &loop_link).unwrap();
1150
1151        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1152        let call = ToolCall {
1153            tool_id: "search_code".into(),
1154            params: serde_json::json!({ "query": "retry_backoff_ms" })
1155                .as_object()
1156                .unwrap()
1157                .clone(),
1158            caller_id: None,
1159            context: None,
1160
1161            tool_call_id: String::new(),
1162            skill_name: None,
1163        };
1164        let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1165            .await
1166            .expect("grep fallback must not hang on a symlink loop")
1167            .unwrap()
1168            .unwrap();
1169        assert!(out.summary.contains("grep fallback"));
1170    }
1171
1172    /// Regression test for #5577: a symlinked directory that does *not* create a
1173    /// cycle (e.g. a vendored dependency symlinked into the tree) must still be
1174    /// walked and searched, not blanket-skipped just because it is a symlink.
1175    #[cfg(unix)]
1176    #[tokio::test]
1177    async fn search_code_follows_non_looping_symlinked_directory() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let vendor = tempfile::tempdir().unwrap();
1180        std::fs::write(
1181            vendor.path().join("vendored.rs"),
1182            "pub fn vendored_symbol_xyz() -> u64 { 0 }\n",
1183        )
1184        .unwrap();
1185        let link = dir.path().join("vendor_link");
1186        std::os::unix::fs::symlink(vendor.path(), &link).unwrap();
1187
1188        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1189        let call = ToolCall {
1190            tool_id: "search_code".into(),
1191            params: serde_json::json!({ "symbol": "vendored_symbol_xyz" })
1192                .as_object()
1193                .unwrap()
1194                .clone(),
1195            caller_id: None,
1196            context: None,
1197
1198            tool_call_id: String::new(),
1199            skill_name: None,
1200        };
1201        let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1202            .await
1203            .expect("search_code must not hang")
1204            .unwrap()
1205            .unwrap();
1206        assert!(
1207            out.summary.contains("vendored_symbol_xyz"),
1208            "expected symlinked directory to be walked, got: {}",
1209            out.summary
1210        );
1211    }
1212
1213    /// Regression test for #5577: a symlinked source file (not a directory) must
1214    /// still be searched, not skipped just because it is a symlink.
1215    #[cfg(unix)]
1216    #[tokio::test]
1217    async fn search_code_follows_symlinked_file() {
1218        let dir = tempfile::tempdir().unwrap();
1219        let real_file = tempfile::tempdir().unwrap();
1220        let target = real_file.path().join("real.rs");
1221        std::fs::write(&target, "pub fn symlinked_file_symbol_xyz() -> u64 { 0 }\n").unwrap();
1222        let link = dir.path().join("linked.rs");
1223        std::os::unix::fs::symlink(&target, &link).unwrap();
1224
1225        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1226        let call = ToolCall {
1227            tool_id: "search_code".into(),
1228            params: serde_json::json!({ "symbol": "symlinked_file_symbol_xyz" })
1229                .as_object()
1230                .unwrap()
1231                .clone(),
1232            caller_id: None,
1233            context: None,
1234
1235            tool_call_id: String::new(),
1236            skill_name: None,
1237        };
1238        let out = tokio::time::timeout(Duration::from_secs(10), exec.execute_tool_call(&call))
1239            .await
1240            .expect("search_code must not hang")
1241            .unwrap()
1242            .unwrap();
1243        assert!(
1244            out.summary.contains("symlinked_file_symbol_xyz"),
1245            "expected symlinked file to be searched, got: {}",
1246            out.summary
1247        );
1248    }
1249
1250    #[test]
1251    fn tool_definitions_include_search_code() {
1252        let exec = SearchCodeExecutor::new(vec![])
1253            .with_semantic_backend(std::sync::Arc::new(EmptySemantic));
1254        let defs = exec.tool_definitions();
1255        assert_eq!(defs.len(), 1);
1256        assert_eq!(defs[0].id.as_ref(), "search_code");
1257    }
1258
1259    #[test]
1260    fn format_hits_strips_root_prefix() {
1261        let root = Path::new("/tmp/myproject");
1262        let hits = vec![SearchCodeHit {
1263            file_path: "/tmp/myproject/crates/foo/src/lib.rs".to_owned(),
1264            line_start: 10,
1265            line_end: 15,
1266            snippet: "pub fn example() {}".to_owned(),
1267            source: SearchCodeSource::GrepFallback,
1268            score: 0.45,
1269            symbol_name: None,
1270        }];
1271        let output = format_hits(&hits, root);
1272        assert!(
1273            output.contains("crates/foo/src/lib.rs"),
1274            "expected relative path in output, got: {output}"
1275        );
1276        assert!(
1277            !output.contains("/tmp/myproject"),
1278            "absolute path must not appear in output, got: {output}"
1279        );
1280    }
1281
1282    /// `search_code` description must explicitly state it is not for user-provided facts
1283    /// so the model does not use it when recalling conversation context (#2475).
1284    #[tokio::test]
1285    async fn search_code_description_excludes_user_facts() {
1286        let dir = tempfile::tempdir().unwrap();
1287        let exec = SearchCodeExecutor::new(vec![dir.path().to_path_buf()]);
1288        let defs = exec.tool_definitions();
1289        let search_code = defs
1290            .iter()
1291            .find(|d| d.id.as_ref() == "search_code")
1292            .unwrap();
1293        assert!(
1294            search_code
1295                .description
1296                .contains("not for user-provided facts"),
1297            "search_code description must contain disambiguation phrase; got: {}",
1298            search_code.description
1299        );
1300    }
1301}