Skip to main content

lean_ctx/tools/ctx_search/
implementation.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use glob::Pattern;
7use ignore::WalkBuilder;
8use regex::RegexBuilder;
9
10use crate::core::protocol;
11use crate::core::symbol_map::{self, SymbolMap};
12use crate::core::tokens::count_tokens;
13use crate::tools::CrpMode;
14
15pub(crate) const MAX_FILE_SIZE: u64 = 512_000;
16pub(crate) const MAX_WALK_DEPTH: usize = 20;
17const MAX_MATCH_LINE_WIDTH: usize = 150;
18
19/// Modeled baseline for the *estimated* savings series (GL #479 D1): a native
20/// agent grep tool ships matches with surrounding context lines, per-file
21/// headers and line numbers, which is roughly 2.5x the tokens of the bare
22/// match lines lean-ctx observes. This factor is a documented model
23/// assumption — it feeds `stats.json` ("estimated") only. The signed savings
24/// ledger ("verified") records `observed_tokens` without any factor applied.
25pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
26
27/// Result of a search: the rendered output plus both baseline figures.
28pub struct SearchOutcome {
29    /// Rendered, compressed search output.
30    pub text: String,
31    /// Modeled native-tool baseline (`observed_tokens` x [`NATIVE_GREP_BASELINE_FACTOR`]).
32    /// Feeds the estimated stats series.
33    pub modeled_baseline: usize,
34    /// Tokens actually measured in the raw match lines — no model applied.
35    /// Feeds the verified savings ledger.
36    pub observed_tokens: usize,
37}
38
39impl SearchOutcome {
40    fn error(text: String) -> Self {
41        Self {
42            text,
43            modeled_baseline: 0,
44            observed_tokens: 0,
45        }
46    }
47
48    fn from_observed(text: String, observed_tokens: usize) -> Self {
49        let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
50        Self {
51            text,
52            modeled_baseline: modeled.max(observed_tokens),
53            observed_tokens,
54        }
55    }
56}
57
58/// Wall-clock budget for a single `ctx_search` call. The regular-file guard in
59/// the read loop removes the known infinite block — `read_to_string` on a
60/// FIFO/socket/device (#336) — while this deadline is the backstop for any
61/// *other* pathological case (a gigantic corpus, a stuck network mount): the
62/// tool returns partial results with a hint instead of appearing to hang.
63/// Tunable via `LEAN_CTX_SEARCH_DEADLINE_MS` (`0` disables). Default 10s.
64fn search_deadline() -> Option<Duration> {
65    const DEFAULT_MS: u64 = 10_000;
66    let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
67        .ok()
68        .and_then(|v| v.trim().parse::<u64>().ok())
69        .unwrap_or(DEFAULT_MS);
70    (ms > 0).then(|| Duration::from_millis(ms))
71}
72
73/// Back-compat shim: the pre-#870 8-arg entry point. Delegates to
74/// [`handle_filtered`] with no exclude filters. New callers pass the two
75/// `exclude` args directly.
76#[allow(clippy::too_many_arguments)]
77pub fn handle(
78    pattern: &str,
79    dir: &str,
80    include: Option<&str>,
81    max_results: usize,
82    crp_mode: CrpMode,
83    respect_gitignore: bool,
84    allow_secret_paths: bool,
85    anchored: bool,
86) -> SearchOutcome {
87    handle_filtered(
88        pattern,
89        dir,
90        include,
91        max_results,
92        crp_mode,
93        respect_gitignore,
94        allow_secret_paths,
95        anchored,
96        None,
97        None,
98    )
99}
100
101/// Searches files for a regex pattern with compressed output and monorepo scope hints.
102///
103/// `anchored` (opt-in, #1008) appends a `:hh` line-hash to every match
104/// (`path:line:hh content`) so a hit can be edited directly with `ctx_patch`
105/// without a separate `ctx_read(mode="anchored")`. Default output is byte-for-byte
106/// unchanged (#498).
107///
108/// `exclude` (path glob, complement of `include`) and `exclude_pattern` (regex
109/// on result lines, like `grep -v`) are the negative filters added in #870.
110#[allow(clippy::too_many_arguments)]
111pub fn handle_filtered(
112    pattern: &str,
113    dir: &str,
114    include: Option<&str>,
115    max_results: usize,
116    _crp_mode: CrpMode,
117    respect_gitignore: bool,
118    allow_secret_paths: bool,
119    anchored: bool,
120    exclude: Option<&str>,
121    exclude_pattern: Option<&str>,
122) -> SearchOutcome {
123    // `include` is a glob matched against each file's path *relative to* `dir`
124    // (e.g. `*.ts`, `*.{rs,ts}`, `src/**/*.tsx`). Bare globs without `/` match
125    // at any directory depth (like `rg --glob`), so `*.ts` finds `a/b.ts` too.
126    // Brace alternation is expanded here because the `glob` crate has no native
127    // support for it. An empty result (no `include`, or only unparsable globs)
128    // means "no filter", so a typo never silently drops every match.
129    let include_patterns = compile_include(include);
130    // `exclude` is the negative complement of `include`: a file matching any
131    // exclude glob is dropped, even when it also matches `include` (#870).
132    let exclude_patterns = compile_include(exclude);
133    const MAX_PATTERN_LEN: usize = 1024;
134    const MAX_REGEX_SIZE: usize = 1 << 20; // 1 MiB DFA limit
135
136    let redact = crate::core::redaction::redaction_enabled_for_active_role();
137    if pattern.len() > MAX_PATTERN_LEN {
138        return SearchOutcome::error(format!(
139            "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
140            pattern.len()
141        ));
142    }
143    let re = match RegexBuilder::new(pattern)
144        .size_limit(MAX_REGEX_SIZE)
145        .dfa_size_limit(MAX_REGEX_SIZE)
146        .build()
147    {
148        Ok(r) => r,
149        Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
150    };
151    // #870: `exclude_pattern` drops any *result line* matching this regex — the
152    // in-pipeline equivalent of `grep -v`. An unparsable pattern is ignored
153    // (no exclusion), so a typo never silently hides every match.
154    let exclude_re = exclude_pattern.and_then(|p| {
155        RegexBuilder::new(p)
156            .size_limit(MAX_REGEX_SIZE)
157            .dfa_size_limit(MAX_REGEX_SIZE)
158            .build()
159            .ok()
160    });
161
162    let root = Path::new(dir);
163    if !root.exists() {
164        return SearchOutcome::error(format!("ERROR: {dir} does not exist"));
165    }
166    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
167    // would walk the whole home dir and trip macOS TCC privacy prompts.
168    if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
169        return SearchOutcome::error(err);
170    }
171
172    let mut files: Vec<PathBuf> = Vec::new();
173    let mut matches = Vec::new();
174    let mut raw_tokens_accum: usize = 0;
175    let mut files_searched = 0u32;
176    let mut files_skipped_size = 0u32;
177    let mut files_skipped_encoding = 0u32;
178    let mut files_skipped_boundary = 0u32;
179    let mut files_skipped_special = 0u32;
180    let mut deadline_hit = false;
181    // Set when any hit gained an `∈name@Lstart` enclosing tag, so the
182    // self-describing legend is emitted only when it is actually needed (#580).
183    let mut any_enclosing = false;
184
185    // Fast path: a warm resident trigram index narrows the candidate files in
186    // memory, eliminating the per-call directory walk + full-corpus read. The
187    // index covers the exact same file universe as the walk below, and matches
188    // are still verified line-by-line with the same regex — so results are
189    // identical. Missing/stale index → returns None and triggers a background
190    // (re)build; this call uses the walk fallback.
191    let used_index = if let Some(idx) =
192        crate::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
193    {
194        files = idx
195            .candidate_paths(pattern, &include_patterns, root)
196            .into_paths();
197        true
198    } else {
199        false
200    };
201
202    if !used_index {
203        // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
204        // disabling gitignore is the escape hatch to look inside them (#400).
205        // GH #1431: hidden(false) for consistency with ctx_glob — dot-dirs
206        // must be searchable; gitignore + is_secret_like guard the rest.
207        let walker = WalkBuilder::new(root)
208            .hidden(false)
209            .max_depth(Some(MAX_WALK_DEPTH))
210            .git_ignore(respect_gitignore)
211            .git_global(respect_gitignore)
212            .git_exclude(respect_gitignore)
213            .require_git(false)
214            .filter_entry(move |e| {
215                if respect_gitignore {
216                    crate::core::walk_filter::keep_entry(e)
217                } else {
218                    crate::core::cloud_files::keep_entry(e)
219                }
220            })
221            .build();
222
223        for entry in walker.filter_map(std::result::Result::ok) {
224            if entry.file_type().is_none_or(|ft| ft.is_dir()) {
225                continue;
226            }
227
228            if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
229                continue;
230            }
231
232            let path = entry.path();
233
234            if is_binary_ext(path) || is_generated_file(path) {
235                continue;
236            }
237
238            if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
239                files_skipped_boundary += 1;
240                continue;
241            }
242
243            if !include_patterns.is_empty() {
244                let rel = path.strip_prefix(root).unwrap_or(path);
245                let rel_str = rel.to_string_lossy();
246                if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
247                    continue;
248                }
249            }
250
251            // Size / regular-file filtering happens once in the shared read loop
252            // below, so the walk path and the trigram-index fast path apply the
253            // exact same eligibility rules.
254            files.push(path.to_path_buf());
255        }
256    }
257
258    // #870: drop excluded paths after both the index and walk populate `files`,
259    // so the negative glob filter applies uniformly to either collection path.
260    if !exclude_patterns.is_empty() {
261        files.retain(|path| {
262            let rel = path.strip_prefix(root).unwrap_or(path);
263            let rel_str = rel.to_string_lossy();
264            !exclude_patterns.iter().any(|p| p.matches(&rel_str))
265        });
266    }
267
268    // Deterministic search: stable file ordering makes max_results truncation reproducible.
269    files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
270
271    let root_str = root.to_string_lossy();
272    let deadline = search_deadline().map(|budget| Instant::now() + budget);
273    for path in &files {
274        if matches.len() >= max_results {
275            break;
276        }
277
278        // Stop gracefully instead of appearing to hang on a pathological corpus
279        // or a stuck read (#336): once the wall-clock budget is spent, return
280        // the partial results gathered so far with a hint to narrow the search.
281        if deadline.is_some_and(|dl| Instant::now() >= dl) {
282            deadline_hit = true;
283            break;
284        }
285
286        // Only ever read regular files within the size budget. A FIFO, socket or
287        // device node would block `read_to_string` forever — the root cause of
288        // #336 — and oversized or unstatable files are skipped. `metadata`
289        // (stat) never opens the file, so it cannot block on a special file.
290        let state = match std::fs::metadata(path) {
291            Ok(meta) if !meta.file_type().is_file() => {
292                files_skipped_special += 1;
293                continue;
294            }
295            Ok(meta) if meta.len() > MAX_FILE_SIZE => {
296                files_skipped_size += 1;
297                continue;
298            }
299            Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
300            Err(_) => {
301                files_skipped_encoding += 1;
302                continue;
303            }
304        };
305
306        // Reuse the copy the trigram-index build already read (issue #148): the
307        // corpus is read from disk once and the regex-verify pass here is an
308        // in-memory hit. On a miss (cold cache / evicted) read once and publish
309        // it for the next caller. `(mtime, size)` validation guarantees we never
310        // verify against stale bytes.
311        let content: std::sync::Arc<str> =
312            if let Some(cached) = state.and_then(|s| crate::core::content_cache::get(path, s)) {
313                cached
314            } else {
315                let Ok(text) = std::fs::read_to_string(path) else {
316                    files_skipped_encoding += 1;
317                    continue;
318                };
319                let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
320                if let Some(s) = state {
321                    crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
322                }
323                arc
324            };
325
326        files_searched += 1;
327        // Enclosing-symbol spans for this file, computed lazily on the first hit
328        // (never for non-matching files) and reused for every later hit here.
329        let mut file_enclosing: Option<EnclosingIndex> = None;
330
331        for (i, line) in content.lines().enumerate() {
332            // #870: `exclude_pattern` filters out matching lines (grep -v).
333            if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
334                let short_path =
335                    protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
336                // Count raw tokens incrementally (avoids separate Vec + join)
337                raw_tokens_accum += count_tokens(line.trim()) + 2;
338                let mut shown = if redact {
339                    crate::core::redaction::redact_text(line.trim())
340                } else {
341                    line.trim().to_string()
342                };
343                if shown.len() > MAX_MATCH_LINE_WIDTH {
344                    shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
345                    shown.push_str("...");
346                }
347                // grep-ast enrichment (#608): name the enclosing symbol + its
348                // handle anchor so the hit is actionable without a follow-up
349                // read. Appended after `shown`, so the `path:line content` prefix
350                // every caller/test relies on is untouched.
351                let tag = file_enclosing
352                    .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
353                    .tag_for(i + 1);
354                if tag.is_some() {
355                    any_enclosing = true;
356                }
357                let tag = tag.unwrap_or_default();
358                // The anchor hash is over the RAW line (matching ctx_read/ctx_patch
359                // which both hash `content.lines()`), never the trimmed/truncated
360                // display text — otherwise ctx_patch would always see a mismatch.
361                if anchored {
362                    matches.push(format!(
363                        "{short_path}:{}:{} {}{}",
364                        i + 1,
365                        crate::core::anchor::line_hash(line),
366                        shown,
367                        tag
368                    ));
369                } else {
370                    matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
371                }
372                if matches.len() >= max_results {
373                    break;
374                }
375            }
376        }
377    }
378
379    // #883: sort matches by file match-density — files with more hits surface
380    // first, which is a proxy for relevance when the pattern contains common tokens.
381    if matches.len() > 1 {
382        use std::collections::HashMap;
383        let mut file_counts: HashMap<String, usize> = HashMap::new();
384        for m in &matches {
385            let file = extract_file_from_match(m).to_string();
386            *file_counts.entry(file).or_default() += 1;
387        }
388        matches.sort_by(|a, b| {
389            let fa = extract_file_from_match(a);
390            let fb = extract_file_from_match(b);
391            let ca = file_counts.get(fa).copied().unwrap_or(0);
392            let cb = file_counts.get(fb).copied().unwrap_or(0);
393            cb.cmp(&ca).then_with(|| fa.cmp(fb))
394        });
395    }
396
397    if matches.is_empty() {
398        let mut msg = format!("0 matches for '{pattern}' in {files_searched} files");
399        if files_skipped_size > 0 {
400            msg.push_str(&format!(" ({files_skipped_size} large files skipped)"));
401        }
402        if files_skipped_encoding > 0 {
403            msg.push_str(&format!(
404                " ({files_skipped_encoding} files skipped: binary/encoding)"
405            ));
406        }
407        if files_skipped_boundary > 0 {
408            msg.push_str(&format!(
409                " ({files_skipped_boundary} secret-like files skipped by boundary policy)"
410            ));
411        }
412        if files_skipped_special > 0 {
413            msg.push_str(&format!(
414                " ({files_skipped_special} special files skipped: not regular files)"
415            ));
416        }
417        if deadline_hit {
418            msg.push_str(
419                " (search stopped at the time budget — refine the pattern or scope with path=)",
420            );
421        }
422        return SearchOutcome::error(msg);
423    }
424
425    // Prefix-cache-friendly: structural file list before per-query match content
426    let matched_files: Vec<&str> = {
427        let mut seen = HashSet::new();
428        matches
429            .iter()
430            .filter_map(|m| {
431                let file = extract_file_from_match(m);
432                if seen.insert(file) { Some(file) } else { None }
433            })
434            .collect()
435    };
436
437    let mut result = format!("{} matches in {} files", matches.len(), files_searched);
438    if matched_files.len() > 1 {
439        if matched_files.len() <= 10 {
440            result.push_str(" [");
441            result.push_str(&matched_files.join(", "));
442            result.push(']');
443        } else {
444            let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
445            result.push_str(&format!(
446                " [{}, +{} more]",
447                shown.join(", "),
448                matched_files.len() - 8
449            ));
450        }
451    }
452    result.push_str(":\n");
453    // Self-describing output (GL #580): the anchor notation ships its own legend.
454    if anchored {
455        result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
456    }
457    // Self-describing output (GL #580 / #608): explain the `∈` enclosing tag and
458    // how to turn it into a handle. Emitted only when at least one hit carries it.
459    if any_enclosing {
460        result.push_str(
461            "[∈ enclosing symbol → ctx_search(action=symbol, handle=\"path#name@Lstart\")]\n",
462        );
463    }
464    result.push_str(&matches.join("\n"));
465
466    if files_skipped_size > 0 {
467        result.push_str(&format!("\n({files_skipped_size} files >512KB skipped)"));
468    }
469    if files_skipped_encoding > 0 {
470        result.push_str(&format!(
471            "\n({files_skipped_encoding} files skipped: binary/encoding)"
472        ));
473    }
474    if files_skipped_boundary > 0 {
475        result.push_str(&format!(
476            "\n({files_skipped_boundary} secret-like files skipped by boundary policy)"
477        ));
478    }
479    if files_skipped_special > 0 {
480        result.push_str(&format!(
481            "\n({files_skipped_special} special files skipped: not regular files)"
482        ));
483    }
484    if deadline_hit {
485        result.push_str(&format!(
486            "\n(search stopped after the {}s budget — {files_searched} files scanned; \
487             refine the pattern or scope with path= for full coverage)",
488            search_deadline().map_or(0, |d| d.as_secs())
489        ));
490    }
491
492    // Determinism contract (#498): the hint must be a pure function of the
493    // results. A show-once AtomicBool here made the first call differ from
494    // every repeat, breaking byte-stability for provider prompt caches.
495    let scope_hint = monorepo_scope_hint(&matches, dir);
496
497    if let Some(delta) = crate::core::search_delta::compute_delta(pattern, &matches) {
498        return SearchOutcome::from_observed(delta, raw_tokens_accum);
499    }
500
501    if symbol_map::substitution_enabled() {
502        let exts = extract_extensions(include);
503        let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
504        let mut sym = SymbolMap::new();
505        let idents = symbol_map::extract_identifiers(&result, &ext_refs);
506        for ident in &idents {
507            sym.register(ident);
508        }
509        if sym.len() >= 3 {
510            let sym_table = sym.format_table();
511            let compressed = sym.apply(&result);
512            let original_tok = count_tokens(&result);
513            let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
514            let net_saving = original_tok.saturating_sub(compressed_tok);
515            if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
516                result = format!("{compressed}{sym_table}");
517            }
518        }
519    }
520
521    if let Some(hint) = scope_hint {
522        result.push_str(&hint);
523    }
524
525    SearchOutcome::from_observed(result, raw_tokens_accum)
526}
527
528pub(crate) fn is_binary_ext(path: &Path) -> bool {
529    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
530    matches!(
531        ext,
532        "png"
533            | "jpg"
534            | "jpeg"
535            | "gif"
536            | "webp"
537            | "ico"
538            | "svg"
539            | "woff"
540            | "woff2"
541            | "ttf"
542            | "eot"
543            | "pdf"
544            | "zip"
545            | "tar"
546            | "gz"
547            | "br"
548            | "zst"
549            | "bz2"
550            | "xz"
551            | "mp3"
552            | "mp4"
553            | "webm"
554            | "ogg"
555            | "wasm"
556            | "so"
557            | "dylib"
558            | "dll"
559            | "exe"
560            | "lock"
561            | "map"
562            | "snap"
563            | "patch"
564            | "db"
565            | "sqlite"
566            | "parquet"
567            | "arrow"
568            | "bin"
569            | "o"
570            | "a"
571            | "class"
572            | "pyc"
573            | "pyo"
574    )
575}
576
577pub(crate) fn is_generated_file(path: &Path) -> bool {
578    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
579    name.ends_with(".min.js")
580        || name.ends_with(".min.css")
581        || name.ends_with(".bundle.js")
582        || name.ends_with(".chunk.js")
583        || name.ends_with(".d.ts")
584        || name.ends_with(".js.map")
585        || name.ends_with(".css.map")
586}
587
588/// Per-file map from a line number to its narrowest enclosing symbol, built
589/// once per matched file from the signature spans. Powers the `∈name@Lstart`
590/// tag on each `ctx_search` hit (grep-ast pattern): the agent sees which
591/// function/class a match lives in — and the handle to fetch it — without a
592/// follow-up read. Tree-sitter-gated by construction: the regex-fallback
593/// extractor yields single-line spans (`end == start`), which are filtered out
594/// here, so a non-tree-sitter build emits byte-identical output (#498).
595struct EnclosingIndex {
596    /// `(start_line, end_line, name)` for multi-line symbols, sorted by start.
597    spans: Vec<(usize, usize, String)>,
598}
599
600impl EnclosingIndex {
601    fn for_file(path: &Path, content: &str) -> Self {
602        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
603        let mut spans: Vec<(usize, usize, String)> =
604            crate::core::signatures::extract_signatures(content, ext)
605                .into_iter()
606                .filter_map(|s| match (s.start_line, s.end_line) {
607                    (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
608                    _ => None,
609                })
610                .collect();
611        spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
612        Self { spans }
613    }
614
615    /// The narrowest span containing `line`, rendered as the compact
616    /// ` ∈name@Lstart` tag, or `None` when no multi-line symbol encloses it.
617    fn tag_for(&self, line: usize) -> Option<String> {
618        let mut best: Option<&(usize, usize, String)> = None;
619        for sp in &self.spans {
620            if line >= sp.0 && line <= sp.1 {
621                match best {
622                    None => best = Some(sp),
623                    Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
624                    _ => {}
625                }
626            }
627        }
628        best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
629    }
630}
631
632/// Upper bound on the number of globs a single `include` may expand to, so a
633/// pathological brace pattern (`{a,b}{c,d}{e,f}…`) can never blow up.
634const MAX_INCLUDE_GLOBS: usize = 64;
635
636/// Compile an `include` filter into one or more matchers.
637///
638/// Brace alternation (`*.{rs,ts}`) is expanded to multiple globs (`*.rs`,
639/// `*.ts`) because the `glob` crate matches `{` / `}` literally. A file is
640/// included when it matches *any* of the returned patterns. An empty vec means
641/// "no filter": `include` was `None`, or every expansion failed to parse.
642///
643/// Bare globs without a `/` (e.g. `pathjail.rs`, `*.rs`) are auto-prefixed
644/// with `**/` to match at any directory depth — matching `rg --glob` and
645/// `git grep` behaviour. Globs that already contain `/` are used as-is, so
646/// `src/**/*.rs` only matches under `src/`.
647fn compile_include(include: Option<&str>) -> Vec<Pattern> {
648    let Some(raw) = include else {
649        return Vec::new();
650    };
651    expand_braces(raw)
652        .into_iter()
653        .take(MAX_INCLUDE_GLOBS)
654        .filter(|g| !g.is_empty())
655        .map(|g| {
656            if g.contains('/') {
657                g
658            } else {
659                format!("**/{g}")
660            }
661        })
662        .filter_map(|g| Pattern::new(&g).ok())
663        .collect()
664}
665
666/// Expand one or more `{a,b,c}` brace groups into the cartesian set of concrete
667/// globs. Patterns without braces (or with an unbalanced brace) are returned
668/// unchanged, so this is safe to call on any input.
669fn expand_braces(pattern: &str) -> Vec<String> {
670    let Some(open) = pattern.find('{') else {
671        return vec![pattern.to_string()];
672    };
673    let Some(close_rel) = pattern[open..].find('}') else {
674        return vec![pattern.to_string()];
675    };
676    let close = open + close_rel;
677    let prefix = &pattern[..open];
678    let inner = &pattern[open + 1..close];
679    let suffix = &pattern[close + 1..];
680
681    let mut out = Vec::new();
682    for alt in inner.split(',') {
683        let alt = alt.trim();
684        for expanded_suffix in expand_braces(suffix) {
685            out.push(format!("{prefix}{alt}{expanded_suffix}"));
686            if out.len() >= MAX_INCLUDE_GLOBS {
687                return out;
688            }
689        }
690    }
691    out
692}
693
694/// Extract the file extensions referenced by an `include` glob, used by the
695/// symbol-substitution pass (which keyword-filters per language).
696///
697/// Only the final path component is inspected, so dots inside directory
698/// segments never leak in. Handles a single trailing extension (`*.rs` → `rs`)
699/// and brace expansion (`*.{rs,ts}` → `rs`, `ts`); a glob without an extension
700/// (`src/**/*`) yields an empty list. Unknown extensions are returned verbatim —
701/// `symbol_map::is_keyword` simply treats them as "no keywords", so no allowlist
702/// has to be kept in sync here.
703fn extract_extensions(include: Option<&str>) -> Vec<String> {
704    let Some(pattern) = include else {
705        return Vec::new();
706    };
707    let filename = pattern.rsplit('/').next().unwrap_or(pattern);
708    let Some(dot) = filename.rfind('.') else {
709        return Vec::new();
710    };
711    let ext_part = &filename[dot + 1..];
712
713    if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
714        return inner
715            .split(',')
716            .map(|e| e.trim().to_string())
717            .filter(|e| !e.is_empty())
718            .collect();
719    }
720
721    if ext_part.is_empty() {
722        return Vec::new();
723    }
724    vec![ext_part.to_string()]
725}
726
727/// Extract file path from a grep match line, handling Windows drive letters (e.g. "C:").
728fn extract_file_from_match(line: &str) -> &str {
729    let start = if line.len() >= 2
730        && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
731        && line.as_bytes().get(1) == Some(&b':')
732    {
733        2
734    } else {
735        0
736    };
737    match line[start..].find(':') {
738        Some(pos) => &line[..start + pos],
739        None => line,
740    }
741}
742
743fn monorepo_scope_hint(matches: &[String], search_dir: &str) -> Option<String> {
744    let top_dirs: HashSet<&str> = matches
745        .iter()
746        .filter_map(|m| {
747            let path = extract_file_from_match(m);
748            let relative = path.strip_prefix("./").unwrap_or(path);
749            let relative = relative.strip_prefix(search_dir).unwrap_or(relative);
750            let relative = relative.strip_prefix('/').unwrap_or(relative);
751            relative.split('/').next()
752        })
753        .collect();
754
755    if top_dirs.len() > 3 {
756        let mut dirs: Vec<&&str> = top_dirs.iter().collect();
757        dirs.sort();
758        let dir_list: Vec<String> = dirs.iter().take(6).map(|d| format!("'{d}'")).collect();
759        let extra = if top_dirs.len() > 6 {
760            format!(", +{} more", top_dirs.len() - 6)
761        } else {
762            String::new()
763        };
764        Some(format!(
765            "\n\nResults span {} directories ({}{}). \
766             Use the 'path' parameter to scope to a specific service, \
767             e.g. path=\"{}/\".",
768            top_dirs.len(),
769            dir_list.join(", "),
770            extra,
771            dirs[0]
772        ))
773    } else {
774        None
775    }
776}
777
778#[cfg(test)]
779#[path = "tests.rs"]
780mod tests;