lean_ctx/core/
path_suggest.rs1use std::path::{Path, PathBuf};
15
16const MAX_SIBLINGS: usize = 2_000;
18const MAX_WALK_ENTRIES: usize = 20_000;
20const MAX_WALK_DEPTH: usize = 12;
21const MAX_SUGGESTIONS: usize = 3;
22
23pub(crate) fn suggestions(path: &Path) -> Vec<PathBuf> {
26 if path.exists() {
27 return Vec::new();
28 }
29 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
30 return Vec::new();
31 };
32 if let Some(parent) = path.parent().filter(|p| p.is_dir()) {
33 return similar_sibling(parent, name).into_iter().collect();
34 }
35 nearest_existing_ancestor(path)
36 .map(|base| same_tail_below(&base, path))
37 .unwrap_or_default()
38}
39
40pub(crate) fn unambiguous_match(path: &Path) -> Option<PathBuf> {
47 if path.exists() {
48 return None;
49 }
50 let name = path.file_name()?.to_str()?;
51 if let Some(parent) = path.parent().filter(|p| p.is_dir()) {
52 let lowered = name.to_lowercase();
53 let mut same_name = std::fs::read_dir(parent)
54 .ok()?
55 .filter_map(Result::ok)
56 .take(MAX_SIBLINGS)
57 .filter_map(|e| e.file_name().into_string().ok())
58 .filter(|n| n.to_lowercase() == lowered);
59 let only = same_name.next()?;
60 return same_name.next().is_none().then(|| parent.join(only));
61 }
62 let base = nearest_existing_ancestor(path)?;
63 let (ranked, tail_len) = ranked_tail_matches(&base, path);
64 match ranked.as_slice() {
65 [(shared, only)] if *shared == tail_len => Some(only.clone()),
66 [(shared, best), (next, _), ..] if *shared == tail_len && next < shared => {
67 Some(best.clone())
68 }
69 _ => None,
70 }
71}
72
73pub fn unambiguous_match_in(path: &Path, project_root: &Path) -> Option<PathBuf> {
82 if let Some(found) = unambiguous_match(path) {
83 return Some(found);
84 }
85 if path.exists() || !path.starts_with(project_root) || !project_root.is_dir() {
86 return None;
87 }
88 let (ranked, _) = ranked_tail_matches(project_root, path);
89 match ranked.as_slice() {
90 [(shared, only)] if *shared >= 2 => Some(only.clone()),
91 [(shared, best), (next, _), ..] if *shared >= 2 && next < shared => Some(best.clone()),
92 _ => None,
93 }
94}
95
96pub fn hint(path: &Path) -> String {
98 let found = suggestions(path);
99 match found.as_slice() {
100 [] => String::new(),
101 [one] => format!(" — did you mean {}?", one.display()),
102 many => format!(
103 " — did you mean one of: {}?",
104 many.iter()
105 .map(|p| p.display().to_string())
106 .collect::<Vec<_>>()
107 .join(", ")
108 ),
109 }
110}
111
112fn similar_sibling(parent: &Path, name: &str) -> Option<PathBuf> {
115 let names: Vec<String> = std::fs::read_dir(parent)
116 .ok()?
117 .filter_map(Result::ok)
118 .take(MAX_SIBLINGS)
119 .filter_map(|e| e.file_name().into_string().ok())
120 .collect();
121 let lowered = name.to_lowercase();
122 if let Some(exact_case) = names.iter().find(|n| n.to_lowercase() == lowered) {
123 return Some(parent.join(exact_case));
124 }
125 if let Some(close) = crate::core::levenshtein::closest(name, names.iter().map(String::as_str)) {
126 return Some(parent.join(close));
127 }
128 let stem = Path::new(name).file_stem()?.to_str()?;
129 names
130 .iter()
131 .find(|n| Path::new(n).file_stem().and_then(|s| s.to_str()) == Some(stem))
132 .map(|n| parent.join(n))
133}
134
135fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
136 path.ancestors()
137 .skip(1)
138 .take(32)
139 .find(|a| a.is_dir())
140 .map(Path::to_path_buf)
141}
142
143fn same_tail_below(base: &Path, path: &Path) -> Vec<PathBuf> {
147 ranked_tail_matches(base, path)
148 .0
149 .into_iter()
150 .take(MAX_SUGGESTIONS)
151 .map(|(_, p)| p)
152 .collect()
153}
154
155fn ranked_tail_matches(base: &Path, path: &Path) -> (Vec<(usize, PathBuf)>, usize) {
159 if !crate::core::graph_index::is_safe_scan_root_public(&base.to_string_lossy()) {
160 return (Vec::new(), 0);
161 }
162 let Ok(missing) = path.strip_prefix(base) else {
163 return (Vec::new(), 0);
164 };
165 let tail: Vec<_> = missing.components().collect();
166 let Some(name) = tail.last().map(|c| c.as_os_str().to_owned()) else {
167 return (Vec::new(), 0);
168 };
169
170 let mut ranked: Vec<(usize, PathBuf)> = Vec::new();
172 let walker = ignore::WalkBuilder::new(base)
173 .max_depth(Some(MAX_WALK_DEPTH))
174 .hidden(true)
175 .build();
176 for entry in walker.filter_map(Result::ok).take(MAX_WALK_ENTRIES) {
177 let candidate = entry.path();
178 if candidate.file_name() != Some(name.as_os_str()) {
179 continue;
180 }
181 let shared = candidate
182 .components()
183 .rev()
184 .zip(tail.iter().rev())
185 .take_while(|(a, b)| a == *b)
186 .count();
187 ranked.push((shared, candidate.to_path_buf()));
188 }
189 ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
190 (ranked, tail.len())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn project() -> tempfile::TempDir {
198 let dir = tempfile::tempdir().unwrap();
199 let root = dir.path();
200 std::fs::create_dir_all(root.join(".git")).unwrap();
201 std::fs::create_dir_all(root.join("rust/src/core")).unwrap();
202 std::fs::write(root.join("rust/src/core/config.rs"), "").unwrap();
203 std::fs::write(root.join("rust/src/main.rs"), "").unwrap();
204 dir
205 }
206
207 #[test]
208 fn typo_in_file_name_suggests_the_sibling() {
209 let dir = project();
210 let wanted = dir.path().join("rust/src/core/confg.rs");
211 assert_eq!(
212 suggestions(&wanted),
213 vec![dir.path().join("rust/src/core/config.rs")]
214 );
215 }
216
217 #[test]
218 fn wrong_extension_suggests_the_same_stem() {
219 let dir = project();
220 let wanted = dir.path().join("rust/src/main.ts");
221 assert_eq!(
222 suggestions(&wanted),
223 vec![dir.path().join("rust/src/main.rs")]
224 );
225 }
226
227 #[test]
228 fn wrong_directory_finds_the_file_by_its_tail() {
229 let dir = project();
230 let wanted = dir.path().join("src/core/config.rs");
231 let found = suggestions(&wanted);
232 assert_eq!(
233 found.first(),
234 Some(&dir.path().join("rust/src/core/config.rs"))
235 );
236 assert!(hint(&wanted).contains("did you mean"), "{}", hint(&wanted));
237 }
238
239 #[test]
240 fn unambiguous_match_follows_case_and_wrong_prefix_only() {
241 let dir = project();
242 let root = dir.path();
243 let other_case = root.join("rust/src/core/Config.rs");
246 if !other_case.exists() {
247 assert_eq!(
248 unambiguous_match(&other_case),
249 Some(root.join("rust/src/core/config.rs"))
250 );
251 }
252 assert_eq!(
254 unambiguous_match(&root.join("src/core/config.rs")),
255 Some(root.join("rust/src/core/config.rs"))
256 );
257 assert_eq!(
259 unambiguous_match(&root.join("rust/src/core/confg.rs")),
260 None
261 );
262 assert_eq!(unambiguous_match(&root.join("rust/src/main.ts")), None);
263 assert_eq!(unambiguous_match(&root.join("rust/src/main.rs")), None);
265 }
266
267 #[test]
268 fn project_wide_match_repairs_a_search_relative_path_on_the_wrong_prefix() {
269 let dir = project();
270 let root = dir.path();
271 std::fs::create_dir_all(root.join("tests/core")).unwrap();
272 std::fs::write(root.join("tests/core/test_config.rs"), "").unwrap();
273 let wanted = root.join("rust/src/core/test_config.rs");
275 assert_eq!(unambiguous_match(&wanted), None);
276 assert_eq!(
277 unambiguous_match_in(&wanted, root),
278 Some(root.join("tests/core/test_config.rs"))
279 );
280 assert_eq!(
282 unambiguous_match_in(&root.join("rust/src/other/test_config.rs"), root),
283 None
284 );
285 std::fs::create_dir_all(root.join("more/core")).unwrap();
287 std::fs::write(root.join("more/core/test_config.rs"), "").unwrap();
288 assert_eq!(unambiguous_match_in(&wanted, root), None);
289 }
290
291 #[test]
292 fn unambiguous_match_refuses_two_equally_good_candidates() {
293 let dir = project();
294 let root = dir.path();
295 std::fs::create_dir_all(root.join("other/src/core")).unwrap();
296 std::fs::write(root.join("other/src/core/config.rs"), "").unwrap();
297 assert_eq!(unambiguous_match(&root.join("src/core/config.rs")), None);
298 assert_eq!(suggestions(&root.join("src/core/config.rs")).len(), 2);
299 }
300
301 #[test]
302 fn nothing_close_suggests_nothing() {
303 let dir = project();
304 {
305 let found = &suggestions(&dir.path().join("rust/src/zzzzzzzz.py"));
306 assert!(found.is_empty(), "{found:?}");
307 }
308 assert_eq!(hint(&dir.path().join("nope/void.md")), "");
309 }
310}