reflex/query/
zero_hint.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ExcludedReason {
30 Hidden,
33 NotIndexed,
36 LockOrGenerated,
38 WholeIdentifier,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ZeroHint {
45 pub reason: ExcludedReason,
46 pub text: String,
47}
48
49#[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
94pub 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
108fn 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 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}