1use ignore::WalkBuilder;
4use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
5use nucleo_matcher::{Config, Matcher};
6use std::path::Path;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum PathMatchKind {
11 File,
12 Directory,
13}
14
15#[derive(Debug, Clone)]
17pub struct PathMatch {
18 pub path: String,
19 pub kind: PathMatchKind,
20 pub score: u32,
21}
22
23pub struct PathEntry {
25 pub path: String,
26 pub kind: PathMatchKind,
27}
28
29#[derive(Debug, Clone)]
31pub struct SigilExpansion {
32 pub paths: Vec<String>,
34 pub suffix: String,
36}
37
38pub trait PathSource {
40 fn find_like(&self, query: &str) -> Option<Vec<PathEntry>>;
43
44 fn all_files(&self) -> Option<Vec<PathEntry>>;
47}
48
49pub fn expand_sigil(
54 query: &str,
55 alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
56) -> Option<SigilExpansion> {
57 if !query.starts_with('@') {
58 return None;
59 }
60
61 let rest = &query[1..]; let alias_end = rest
65 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
66 .unwrap_or(rest.len());
67
68 let alias_name = &rest[..alias_end];
69 let after_alias = &rest[alias_end..];
70
71 let suffix = after_alias
73 .strip_prefix("::")
74 .or_else(|| after_alias.strip_prefix('/'))
75 .or_else(|| after_alias.strip_prefix(':'))
76 .or_else(|| after_alias.strip_prefix('#'))
77 .unwrap_or(after_alias);
78
79 let targets = alias_lookup(alias_name)?;
80
81 Some(SigilExpansion {
82 paths: targets,
83 suffix: suffix.to_string(),
84 })
85}
86
87#[derive(Debug, Clone)]
89pub struct UnifiedPath {
90 pub file_path: String,
92 pub symbol_path: Vec<String>,
94 pub is_directory: bool,
96}
97
98fn normalize_separators(query: &str) -> String {
101 query
102 .replace("::", "/")
103 .replace('#', "/")
104 .split(':')
106 .enumerate()
107 .map(|(i, part)| {
108 if i == 0 {
109 part.to_string()
110 } else {
111 format!("/{}", part)
112 }
113 })
114 .collect::<String>()
115}
116
117pub fn resolve_unified(
128 query: &str,
129 root: &Path,
130 alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
131 path_source: Option<&dyn PathSource>,
132) -> Option<UnifiedPath> {
133 resolve_unified_depth(query, root, alias_lookup, path_source, 0)
134}
135
136fn resolve_unified_depth(
137 query: &str,
138 root: &Path,
139 alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
140 path_source: Option<&dyn PathSource>,
141 depth: u8,
142) -> Option<UnifiedPath> {
143 if query.starts_with('@')
145 && let Some(expansion) = expand_sigil(query, alias_lookup)
146 {
147 if depth >= 32 {
149 return None;
150 }
151 for target in &expansion.paths {
153 let full_query = if expansion.suffix.is_empty() {
154 target.clone()
155 } else {
156 format!("{}/{}", target, expansion.suffix)
157 };
158 if let Some(result) =
159 resolve_unified_depth(&full_query, root, alias_lookup, None, depth + 1)
160 {
161 return Some(result);
162 }
163 }
164 return None;
165 }
166 let normalized = normalize_separators(query);
169
170 let (segments, base_path): (Vec<&str>, std::path::PathBuf) = if normalized.starts_with('/') {
172 let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
173 (segs, std::path::PathBuf::from("/"))
174 } else {
175 let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
176 (segs, root.to_path_buf())
177 };
178 let is_absolute = normalized.starts_with('/');
179
180 if segments.is_empty() {
181 return None;
182 }
183
184 let mut current_path = base_path.clone();
186 for (idx, segment) in segments.iter().enumerate() {
187 let test_path = current_path.join(segment);
188
189 if test_path.is_file() {
190 let file_path = if is_absolute {
193 test_path.to_string_lossy().to_string()
194 } else {
195 test_path
196 .strip_prefix(root)
197 .unwrap_or(&test_path)
198 .to_string_lossy()
199 .to_string()
200 };
201 return Some(UnifiedPath {
202 file_path,
203 symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
204 is_directory: false,
205 });
206 } else if test_path.is_dir() {
207 current_path = test_path;
208 } else {
209 break;
211 }
212 }
213
214 if current_path != base_path && current_path.is_dir() {
216 let dir_path = if is_absolute {
217 current_path.to_string_lossy().to_string()
218 } else {
219 current_path
220 .strip_prefix(root)
221 .unwrap_or(¤t_path)
222 .to_string_lossy()
223 .to_string()
224 };
225 let matched_segments = dir_path.matches('/').count() + 1;
226 if matched_segments >= segments.len() {
227 return Some(UnifiedPath {
228 file_path: dir_path,
229 symbol_path: vec![],
230 is_directory: true,
231 });
232 }
233 }
234
235 if !is_absolute {
237 let all_paths = get_paths_for_query(root, "", path_source);
239 for split_point in (1..=segments.len()).rev() {
240 let file_query = segments[..split_point].join("/");
241 let matches = resolve_from_paths(&file_query, &all_paths);
242
243 if let Some(m) = matches.first() {
244 if m.kind == PathMatchKind::File {
245 return Some(UnifiedPath {
246 file_path: m.path.clone(),
247 symbol_path: segments[split_point..]
248 .iter()
249 .map(|s| s.to_string())
250 .collect(),
251 is_directory: false,
252 });
253 } else if m.kind == PathMatchKind::Directory && split_point == segments.len() {
254 return Some(UnifiedPath {
256 file_path: m.path.clone(),
257 symbol_path: vec![],
258 is_directory: true,
259 });
260 }
261 }
262 }
263 }
264
265 None
266}
267
268pub fn resolve_unified_all(
272 query: &str,
273 root: &Path,
274 alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
275 path_source: Option<&dyn PathSource>,
276) -> Vec<UnifiedPath> {
277 resolve_unified_all_depth(query, root, alias_lookup, path_source, 0)
278}
279
280fn resolve_unified_all_depth(
281 query: &str,
282 root: &Path,
283 alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
284 path_source: Option<&dyn PathSource>,
285 depth: u8,
286) -> Vec<UnifiedPath> {
287 if query.starts_with('@')
289 && let Some(expansion) = expand_sigil(query, alias_lookup)
290 {
291 if depth >= 32 {
293 return vec![];
294 }
295 let mut results = Vec::new();
296 for target in &expansion.paths {
297 let full_query = if expansion.suffix.is_empty() {
298 target.clone()
299 } else {
300 format!("{}/{}", target, expansion.suffix)
301 };
302 results.extend(resolve_unified_all_depth(
303 &full_query,
304 root,
305 alias_lookup,
306 None,
307 depth + 1,
308 ));
309 }
310 return results;
311 }
312
313 let normalized = normalize_separators(query);
314
315 let dir_only = normalized.ends_with('/');
317
318 if normalized.starts_with('/') {
320 return resolve_unified_depth(query, root, alias_lookup, None, depth)
321 .into_iter()
322 .collect();
323 }
324
325 let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
326 if segments.is_empty() {
327 return vec![];
328 }
329
330 let mut current_path = root.to_path_buf();
332 for (idx, segment) in segments.iter().enumerate() {
333 let test_path = current_path.join(segment);
334 if test_path.is_file() {
335 let file_path = test_path
337 .strip_prefix(root)
338 .unwrap_or(&test_path)
339 .to_string_lossy()
340 .to_string();
341 return vec![UnifiedPath {
342 file_path,
343 symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
344 is_directory: false,
345 }];
346 } else if test_path.is_dir() {
347 current_path = test_path;
348 } else {
349 break;
350 }
351 }
352
353 if current_path != root.to_path_buf() && current_path.is_dir() {
355 let dir_path = current_path
356 .strip_prefix(root)
357 .unwrap_or(¤t_path)
358 .to_string_lossy()
359 .to_string();
360 return vec![UnifiedPath {
361 file_path: dir_path,
362 symbol_path: vec![],
363 is_directory: true,
364 }];
365 }
366
367 let all_paths = get_paths_for_query(root, "", path_source);
370 for split_point in (1..=segments.len()).rev() {
371 let file_query = segments[..split_point].join("/");
372 let matches = resolve_from_paths(&file_query, &all_paths);
373
374 if !matches.is_empty() {
375 let filtered: Vec<_> = if dir_only {
377 matches
378 .into_iter()
379 .filter(|m| m.kind == PathMatchKind::Directory)
380 .collect()
381 } else {
382 matches
383 };
384
385 if !filtered.is_empty() {
386 return filtered
387 .into_iter()
388 .map(|m| UnifiedPath {
389 file_path: m.path,
390 symbol_path: segments[split_point..]
391 .iter()
392 .map(|s| s.to_string())
393 .collect(),
394 is_directory: m.kind == PathMatchKind::Directory,
395 })
396 .collect();
397 }
398 }
399 }
400
401 vec![]
402}
403
404pub fn all_files(root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
406 get_paths_for_query(root, "", path_source)
407 .into_iter()
408 .map(|(path, is_dir)| PathMatch {
409 path,
410 kind: if is_dir {
411 PathMatchKind::Directory
412 } else {
413 PathMatchKind::File
414 },
415 score: 0,
416 })
417 .collect()
418}
419
420pub fn resolve(query: &str, root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
433 if query.starts_with('/') {
435 let abs_path = std::path::Path::new(query);
436 if abs_path.is_file() {
437 return vec![PathMatch {
438 path: query.to_string(),
439 kind: PathMatchKind::File,
440 score: u32::MAX,
441 }];
442 } else if abs_path.is_dir() {
443 return vec![PathMatch {
444 path: query.to_string(),
445 kind: PathMatchKind::Directory,
446 score: u32::MAX,
447 }];
448 }
449 return vec![];
451 }
452
453 if query.contains(':') {
455 let file_part = query.split(':').next().unwrap();
457 return resolve(file_part, root, path_source);
458 }
459
460 if query.starts_with('.') && !query.contains('/') {
462 if let Some(src) = path_source.as_ref()
463 && let Some(files) = src.find_like(query)
464 {
465 return files
466 .into_iter()
467 .map(|e| PathMatch {
468 path: e.path,
469 kind: e.kind,
470 score: u32::MAX,
471 })
472 .collect();
473 }
474 let walker = WalkBuilder::new(root)
476 .hidden(false)
477 .git_ignore(true)
478 .git_global(true)
479 .git_exclude(true)
480 .filter_entry(|e| e.file_name() != ".git")
481 .build();
482 return walker
483 .flatten()
484 .filter_map(|entry| {
485 let path = entry.path();
486 if path.is_file() {
487 let path_str = path.to_string_lossy();
488 if path_str.ends_with(query)
489 && let Ok(rel) = path.strip_prefix(root)
490 {
491 return Some(PathMatch {
492 path: rel.to_string_lossy().to_string(),
493 kind: PathMatchKind::File,
494 score: u32::MAX,
495 });
496 }
497 }
498 None
499 })
500 .collect();
501 }
502
503 let all_paths = get_paths_for_query(root, query, path_source);
505
506 resolve_from_paths(query, &all_paths)
507}
508
509fn get_paths_for_query(
511 root: &Path,
512 query: &str,
513 path_source: Option<&dyn PathSource>,
514) -> Vec<(String, bool)> {
515 if let Some(src) = path_source {
516 if !query.is_empty()
518 && let Some(files) = src.find_like(query)
519 && !files.is_empty()
520 {
521 return files
522 .into_iter()
523 .map(|e| (e.path, e.kind == PathMatchKind::Directory))
524 .collect();
525 }
526 if let Some(files) = src.all_files() {
528 return files
529 .into_iter()
530 .map(|e| (e.path, e.kind == PathMatchKind::Directory))
531 .collect();
532 }
533 }
534 let mut all_paths: Vec<(String, bool)> = Vec::new();
536 let walker = WalkBuilder::new(root)
537 .hidden(false)
538 .git_ignore(true)
539 .git_global(true)
540 .git_exclude(true)
541 .build();
542
543 for entry in walker.flatten() {
544 let path = entry.path();
545 if let Ok(rel) = path.strip_prefix(root) {
546 let rel_str = rel.to_string_lossy().to_string();
547 if rel_str.is_empty() || rel_str == ".git" || rel_str.starts_with(".git/") {
549 continue;
550 }
551 let is_dir = path.is_dir();
552 all_paths.push((rel_str, is_dir));
553 }
554 }
555
556 all_paths
557}
558
559#[inline]
561fn normalize_char(c: char) -> char {
562 match c {
563 '-' | '.' | '_' => ' ',
564 c => c.to_ascii_lowercase(),
565 }
566}
567
568fn eq_normalized(a: &str, b: &str) -> bool {
570 let mut a_chars = a.chars().map(normalize_char);
571 let mut b_chars = b.chars().map(normalize_char);
572 loop {
573 match (a_chars.next(), b_chars.next()) {
574 (Some(ac), Some(bc)) if ac == bc => continue,
575 (None, None) => return true,
576 _ => return false,
577 }
578 }
579}
580
581fn normalize_for_match(s: &str) -> String {
583 s.chars().map(normalize_char).collect()
584}
585
586fn resolve_from_paths(query: &str, all_paths: &[(String, bool)]) -> Vec<PathMatch> {
588 if query.contains('*') {
590 let pattern = glob::Pattern::new(query).ok();
591 if let Some(ref pat) = pattern {
592 let mut glob_matches: Vec<PathMatch> = Vec::new();
593 for (path, is_dir) in all_paths {
594 if pat.matches(path) || pat.matches(&path.replace('\\', "/")) {
595 glob_matches.push(PathMatch {
596 path: path.clone(),
597 kind: if *is_dir {
598 PathMatchKind::Directory
599 } else {
600 PathMatchKind::File
601 },
602 score: u32::MAX,
603 });
604 }
605 }
606 if !glob_matches.is_empty() {
607 return glob_matches;
608 }
609 }
610 }
611
612 let query_lower = query.to_lowercase();
613 let query_normalized = normalize_for_match(query);
614
615 for (path, is_dir) in all_paths {
617 if eq_normalized(path, query) {
618 return vec![PathMatch {
619 path: path.clone(),
620 kind: if *is_dir {
621 PathMatchKind::Directory
622 } else {
623 PathMatchKind::File
624 },
625 score: u32::MAX,
626 }];
627 }
628 }
629
630 let mut exact_matches: Vec<PathMatch> = Vec::new();
632 for (path, is_dir) in all_paths {
633 let name = Path::new(path)
634 .file_name()
635 .map(|n| n.to_string_lossy().to_lowercase())
636 .unwrap_or_default();
637 let stem = Path::new(path)
638 .file_stem()
639 .map(|n| n.to_string_lossy().to_lowercase())
640 .unwrap_or_default();
641 let name_normalized = normalize_for_match(&name);
642 let stem_normalized = normalize_for_match(&stem);
643
644 if name == query_lower
645 || stem == query_lower
646 || name_normalized == query_normalized
647 || stem_normalized == query_normalized
648 {
649 exact_matches.push(PathMatch {
650 path: path.clone(),
651 kind: if *is_dir {
652 PathMatchKind::Directory
653 } else {
654 PathMatchKind::File
655 },
656 score: u32::MAX - 1,
657 });
658 }
659 }
660
661 if !exact_matches.is_empty() {
662 return exact_matches;
663 }
664
665 if query.contains('/') || query.contains('\\') {
668 let query_suffix = query.replace('\\', "/");
669 let mut suffix_matches: Vec<PathMatch> = Vec::new();
670 for (path, is_dir) in all_paths {
671 let path_normalized = path.replace('\\', "/");
672 if path_normalized.ends_with(&query_suffix)
673 || path_normalized.ends_with(&format!("/{}", query_suffix))
674 {
675 suffix_matches.push(PathMatch {
676 path: path.clone(),
677 kind: if *is_dir {
678 PathMatchKind::Directory
679 } else {
680 PathMatchKind::File
681 },
682 score: u32::MAX - 2,
683 });
684 }
685 }
686 if !suffix_matches.is_empty() {
687 return suffix_matches;
688 }
689 }
690
691 let mut matcher = Matcher::new(Config::DEFAULT);
693 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
694
695 let mut fuzzy_matches: Vec<PathMatch> = Vec::new();
696
697 for (path, is_dir) in all_paths {
698 let mut buf = Vec::new();
699 if let Some(score) =
700 pattern.score(nucleo_matcher::Utf32Str::new(path, &mut buf), &mut matcher)
701 {
702 fuzzy_matches.push(PathMatch {
703 path: path.clone(),
704 kind: if *is_dir {
705 PathMatchKind::Directory
706 } else {
707 PathMatchKind::File
708 },
709 score,
710 });
711 }
712 }
713
714 fuzzy_matches.sort_by(|a, b| b.score.cmp(&a.score));
716 fuzzy_matches.truncate(10);
717
718 fuzzy_matches
719}
720
721pub fn is_glob_pattern(pattern: &str) -> bool {
723 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use std::fs;
730 use tempfile::tempdir;
731
732 fn no_aliases(_name: &str) -> Option<Vec<String>> {
733 None
734 }
735
736 #[test]
737 fn test_exact_match() {
738 let dir = tempdir().unwrap();
739 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
740 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
741
742 let matches = resolve("src/myapp/cli.py", dir.path(), None);
743 assert_eq!(matches.len(), 1);
744 assert_eq!(matches[0].path, "src/myapp/cli.py");
745 }
746
747 #[test]
748 fn test_filename_match() {
749 let dir = tempdir().unwrap();
750 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
751 fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
752
753 let matches = resolve("dwim.py", dir.path(), None);
754 assert_eq!(matches.len(), 1);
755 assert_eq!(matches[0].path, "src/myapp/dwim.py");
756 }
757
758 #[test]
759 fn test_stem_match() {
760 let dir = tempdir().unwrap();
761 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
762 fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
763
764 let matches = resolve("dwim", dir.path(), None);
765 assert_eq!(matches.len(), 1);
766 assert_eq!(matches[0].path, "src/myapp/dwim.py");
767 }
768
769 #[test]
770 fn test_underscore_hyphen_equivalence() {
771 let dir = tempdir().unwrap();
772 fs::create_dir_all(dir.path().join("docs")).unwrap();
773 fs::write(dir.path().join("docs/prior-art.md"), "").unwrap();
774
775 let matches = resolve("prior_art", dir.path(), None);
777 assert_eq!(matches.len(), 1);
778 assert_eq!(matches[0].path, "docs/prior-art.md");
779
780 let matches = resolve("prior-art", dir.path(), None);
782 assert_eq!(matches.len(), 1);
783 assert_eq!(matches[0].path, "docs/prior-art.md");
784
785 let matches = resolve("docs/prior_art.md", dir.path(), None);
787 assert_eq!(matches.len(), 1);
788 assert_eq!(matches[0].path, "docs/prior-art.md");
789 }
790
791 #[test]
792 fn test_unified_path_file_only() {
793 let dir = tempdir().unwrap();
794 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
795 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
796
797 let result = resolve_unified("src/myapp/cli.py", dir.path(), &no_aliases, None);
798 assert!(result.is_some());
799 let u = result.unwrap();
800 assert_eq!(u.file_path, "src/myapp/cli.py");
801 assert!(u.symbol_path.is_empty());
802 assert!(!u.is_directory);
803 }
804
805 #[test]
806 fn test_unified_path_with_symbol() {
807 let dir = tempdir().unwrap();
808 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
809 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
810
811 let result = resolve_unified("src/myapp/cli.py/Foo/bar", dir.path(), &no_aliases, None);
813 assert!(result.is_some());
814 let u = result.unwrap();
815 assert_eq!(u.file_path, "src/myapp/cli.py");
816 assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
817 assert!(!u.is_directory);
818 }
819
820 #[test]
821 fn test_unified_path_directory() {
822 let dir = tempdir().unwrap();
823 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
824 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
825
826 let result = resolve_unified("src/myapp", dir.path(), &no_aliases, None);
827 assert!(result.is_some());
828 let u = result.unwrap();
829 assert_eq!(u.file_path, "src/myapp");
830 assert!(u.symbol_path.is_empty());
831 assert!(u.is_directory);
832 }
833
834 #[test]
835 fn test_unified_path_rust_style_separator() {
836 let dir = tempdir().unwrap();
837 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
838 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
839
840 let result = resolve_unified("src/myapp/cli.py::Foo::bar", dir.path(), &no_aliases, None);
842 assert!(result.is_some());
843 let u = result.unwrap();
844 assert_eq!(u.file_path, "src/myapp/cli.py");
845 assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
846 }
847
848 #[test]
849 fn test_unified_path_hash_separator() {
850 let dir = tempdir().unwrap();
851 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
852 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
853
854 let result = resolve_unified("src/myapp/cli.py#Foo", dir.path(), &no_aliases, None);
856 assert!(result.is_some());
857 let u = result.unwrap();
858 assert_eq!(u.file_path, "src/myapp/cli.py");
859 assert_eq!(u.symbol_path, vec!["Foo"]);
860 }
861
862 #[test]
863 fn test_unified_path_colon_separator() {
864 let dir = tempdir().unwrap();
865 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
866 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
867
868 let result = resolve_unified("src/myapp/cli.py:Foo:bar", dir.path(), &no_aliases, None);
870 assert!(result.is_some());
871 let u = result.unwrap();
872 assert_eq!(u.file_path, "src/myapp/cli.py");
873 assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
874 }
875
876 #[test]
877 fn test_unified_path_fuzzy_file() {
878 let dir = tempdir().unwrap();
879 fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
880 fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
881
882 let result = resolve_unified("cli.py/Foo", dir.path(), &no_aliases, None);
884 assert!(result.is_some());
885 let u = result.unwrap();
886 assert_eq!(u.file_path, "src/myapp/cli.py");
887 assert_eq!(u.symbol_path, vec!["Foo"]);
888 }
889
890 #[test]
891 fn test_unified_path_absolute() {
892 let dir = tempdir().unwrap();
893 let abs_path = dir.path().join("test.py");
894 fs::write(&abs_path, "def foo(): pass").unwrap();
895
896 let abs_str = abs_path.to_string_lossy().to_string();
898 let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
899 assert!(result.is_some());
900 let u = result.unwrap();
901 assert_eq!(u.file_path, abs_str);
902 assert!(u.symbol_path.is_empty());
903 assert!(!u.is_directory);
904 }
905
906 #[test]
907 fn test_unified_path_absolute_with_symbol() {
908 let dir = tempdir().unwrap();
909 let abs_path = dir.path().join("test.py");
910 fs::write(&abs_path, "def foo(): pass").unwrap();
911
912 let query = format!("{}/foo", abs_path.to_string_lossy());
914 let result = resolve_unified(&query, Path::new("/some/other/root"), &no_aliases, None);
915 assert!(result.is_some());
916 let u = result.unwrap();
917 assert_eq!(u.file_path, abs_path.to_string_lossy().to_string());
918 assert_eq!(u.symbol_path, vec!["foo"]);
919 }
920
921 #[test]
922 fn test_unified_path_unicode() {
923 let dir = tempdir().unwrap();
924 let unicode_dir = dir.path().join("日本語");
925 fs::create_dir_all(&unicode_dir).unwrap();
926 let unicode_file = unicode_dir.join("テスト.py");
927 fs::write(&unicode_file, "def hello(): pass").unwrap();
928
929 let abs_str = unicode_file.to_string_lossy().to_string();
931 let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
932 assert!(result.is_some());
933 let u = result.unwrap();
934 assert_eq!(u.file_path, abs_str);
935 assert!(!u.is_directory);
936 }
937}