Skip to main content

reflex/query/
zero_hint.rs

1//! Why a search returned nothing, judged from the FILTER, in a fixed order.
2//!
3//! The 2.0.0 field test found the zero-result hint naming lock/generated files for
4//! every kind of exclusion: `count_occurrences {pattern:"runs-on", file:".github/"}`
5//! said "6 candidate file(s) were lock or generated files" when the true cause was
6//! "hidden path, not indexed". An agent followed the hint, added `include_locks`,
7//! got 0 again, and concluded the thing did not exist.
8//!
9//! One reason is chosen, the first that applies:
10//!
11//! 1. the filter names a hidden path (`.github/`, `.gitignore`) — not indexed;
12//! 2. the `file` filter names a path that is not in the index — say why, from disk;
13//! 3. every candidate under the filter was a lock/generated file — say how to widen;
14//! 4. a whole-identifier search has substring hits — say how to see them;
15//! 5. nothing applies — no hint. The generic "check spelling / broaden" text is
16//!    enough, and an invented reason is worse than none.
17
18use std::path::Path;
19
20use serde::{Deserialize, Serialize};
21
22use super::filter::{QueryFilter, excluded_by_default_hint_text, substring_hint_text};
23use super::open_index::OpenIndex;
24use crate::models::IndexConfig;
25
26/// Machine-readable cause of a zero result, beside the prose `hint`.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ExcludedReason {
30    /// The filter names a dot-directory or dotfile, which the index skips
31    /// (ripgrep's default) unless `[index] hidden = true`.
32    Hidden,
33    /// The `file` filter names a path that is not in the index: deleted, binary,
34    /// ignored, over `max_file_size`, or added since the last index.
35    NotIndexed,
36    /// Every candidate under the filter was a lock or generated file.
37    LockOrGenerated,
38    /// Whole-identifier search: substring matches exist.
39    WholeIdentifier,
40}
41
42/// A reason and the sentence that explains it.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ZeroHint {
45    pub reason: ExcludedReason,
46    pub text: String,
47}
48
49/// The first rule that applies to this zero result; `None` when none does.
50#[allow(clippy::too_many_arguments)]
51pub fn explain_zero(
52    root: &Path,
53    config: &IndexConfig,
54    open: &OpenIndex,
55    filter: &QueryFilter,
56    pattern: &str,
57    substring_only: Option<usize>,
58    excluded_scoped: usize,
59    bracket_rewritten: bool,
60) -> Option<ZeroHint> {
61    if !config.hidden
62        && let Some(seg) = hidden_target(filter)
63    {
64        return Some(ZeroHint {
65            reason: ExcludedReason::Hidden,
66            text: format!(
67                "Hidden paths ({seg}: dot-directories and dotfiles) are not indexed, \
68                 matching ripgrep's default. Use grep --hidden for this path, or set \
69                 [index] hidden = true in .reflex/config.toml and re-index."
70            ),
71        });
72    }
73    if let Some(text) = unindexed_target(root, config, open, filter) {
74        return Some(ZeroHint {
75            reason: ExcludedReason::NotIndexed,
76            text,
77        });
78    }
79    if excluded_scoped > 0 {
80        return Some(ZeroHint {
81            reason: ExcludedReason::LockOrGenerated,
82            text: excluded_by_default_hint_text(excluded_scoped),
83        });
84    }
85    match substring_only {
86        Some(n) if n > 0 && !filter.use_contains && !bracket_rewritten => Some(ZeroHint {
87            reason: ExcludedReason::WholeIdentifier,
88            text: substring_hint_text(n, pattern),
89        }),
90        _ => None,
91    }
92}
93
94/// The first hidden segment named by the `file` or `glob` filters, if any.
95///
96/// `.github/workflows`, `.gitignore`, `**/.githooks/**` all name one; `.`, `..`,
97/// `*`, `**` and `*.yml` do not.
98pub fn hidden_target(filter: &QueryFilter) -> Option<String> {
99    filter
100        .file_pattern
101        .iter()
102        .chain(filter.glob_patterns.iter())
103        .flat_map(|p| p.split('/'))
104        .find(|seg| crate::indexer::is_hidden_segment(seg))
105        .map(str::to_string)
106}
107
108/// Rule 2: the `file` filter matches no indexed path. Says why, from the disk.
109fn unindexed_target(
110    root: &Path,
111    config: &IndexConfig,
112    open: &OpenIndex,
113    filter: &QueryFilter,
114) -> Option<String> {
115    let fp = filter.file_pattern.as_deref()?;
116    if fp.is_empty() {
117        return None;
118    }
119    let needle = fp.strip_prefix("./").unwrap_or(fp);
120    let any_indexed = (0..open.content.file_count() as u32).any(|id| {
121        open.content
122            .get_file_path(id)
123            .and_then(|p| p.to_str())
124            .is_some_and(|p| p.contains(needle))
125    });
126    if any_indexed {
127        return None;
128    }
129
130    // The CLI's own heuristic: no wildcard, and a separator or an extension.
131    let looks_like_path =
132        !fp.contains('*') && !fp.contains('?') && (fp.contains('/') || fp.contains('.'));
133    if !looks_like_path {
134        return Some(format!("No indexed path contains {fp:?}."));
135    }
136
137    let full = root.join(needle.trim_end_matches('/'));
138    let why = match std::fs::metadata(&full) {
139        Err(_) => "not on disk — deleted since the last index".to_string(),
140        Ok(md) if md.is_dir() => match crate::git::is_ignored(root, needle) {
141            Some(true) => "a directory ignored by .gitignore".to_string(),
142            _ => "a directory with no indexed file under it — run index_project if it \
143                  was recently added"
144                .to_string(),
145        },
146        Ok(md) if md.len() > config.max_file_size as u64 => {
147            format!("larger than max_file_size ({} bytes)", config.max_file_size)
148        }
149        Ok(_) if crate::indexer::looks_binary(&full) => "binary".to_string(),
150        Ok(_) => match crate::git::is_ignored(root, needle) {
151            Some(true) => "ignored by .gitignore".to_string(),
152            _ => "added since the last index — run index_project".to_string(),
153        },
154    };
155    Some(format!("{fp} is not in the index ({why})."))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn with_file(fp: &str) -> QueryFilter {
163        QueryFilter {
164            file_pattern: Some(fp.to_string()),
165            ..Default::default()
166        }
167    }
168
169    fn with_glob(g: &str) -> QueryFilter {
170        QueryFilter {
171            glob_patterns: vec![g.to_string()],
172            ..Default::default()
173        }
174    }
175
176    #[test]
177    fn hidden_segments_are_recognised() {
178        assert_eq!(
179            hidden_target(&with_file(".github/")).as_deref(),
180            Some(".github")
181        );
182        assert_eq!(
183            hidden_target(&with_file(".gitignore")).as_deref(),
184            Some(".gitignore")
185        );
186        assert_eq!(
187            hidden_target(&with_file("src/.env.example")).as_deref(),
188            Some(".env.example")
189        );
190        assert_eq!(
191            hidden_target(&with_glob("**/.githooks/**")).as_deref(),
192            Some(".githooks")
193        );
194    }
195
196    #[test]
197    fn ordinary_segments_are_not_hidden() {
198        for p in [
199            "src/main.rs",
200            "./src",
201            "../lib",
202            "*.yml",
203            "**/*.rs",
204            "Cargo.lock",
205        ] {
206            assert_eq!(hidden_target(&with_file(p)), None, "{p}");
207            assert_eq!(hidden_target(&with_glob(p)), None, "{p}");
208        }
209        assert_eq!(hidden_target(&QueryFilter::default()), None);
210    }
211}