use std::path::{Path, PathBuf};
const LONGEST: usize = 1024;
#[derive(Clone, Debug, PartialEq)]
pub struct Found {
pub path: PathBuf,
pub dir: bool,
pub line: Option<u32>,
}
fn split_line(word: &str) -> (&str, Option<u32>) {
let num = |s: &str| !s.is_empty() && s.len() <= 9 && s.bytes().all(|b| b.is_ascii_digit());
let mut path = word;
let mut line = None;
if let Some((head, col)) = path.rsplit_once(':') {
if num(col) {
if let Some((p, l)) = head.rsplit_once(':') {
if num(l) {
path = p;
line = l.parse().ok();
}
}
if line.is_none() {
path = head;
line = col.parse().ok();
}
}
}
(path, line.filter(|l| *l > 0))
}
pub fn find(word: &str, bases: &[PathBuf], home: Option<&Path>) -> Option<Found> {
let word = word.trim();
if word.is_empty() || word.len() > LONGEST || word.chars().any(char::is_control) {
return None;
}
let (path, line) = split_line(word);
for (p, line) in [(path, line), (word, None)] {
if p.is_empty() {
continue;
}
for at in candidates(p, bases, home) {
if let Ok(canon) = at.canonicalize() {
let dir = canon.is_dir();
return Some(Found {
path: canon,
dir,
line: if dir { None } else { line },
});
}
}
if line.is_none() {
break;
}
}
None
}
fn candidates(p: &str, bases: &[PathBuf], home: Option<&Path>) -> Vec<PathBuf> {
if p == "~" {
return home.map(Path::to_path_buf).into_iter().collect();
}
if let Some(rest) = p.strip_prefix("~/") {
return home.map(|h| h.join(rest)).into_iter().collect();
}
let path = Path::new(p);
if path.is_absolute() {
return vec![path.to_path_buf()];
}
bases.iter().map(|b| b.join(path)).collect()
}
pub fn under<'a>(path: &Path, roots: &'a [(String, PathBuf)]) -> Option<(&'a str, String)> {
roots
.iter()
.filter_map(|(id, root)| {
let rest = path.strip_prefix(root).ok()?;
Some((id.as_str(), root, rest))
})
.max_by_key(|(_, root, _)| root.components().count())
.map(|(id, _, rest)| (id, rest.to_string_lossy().replace('\\', "/")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::tempdir::Dir;
struct T(Dir);
impl T {
fn path(&self) -> &Path {
&self.0.path
}
}
fn tree() -> T {
let t = T(Dir::new("snyvi-resolve"));
std::fs::create_dir_all(t.path().join("desk/src")).unwrap();
std::fs::create_dir_all(t.path().join("desk/sub")).unwrap();
std::fs::create_dir_all(t.path().join("home/.claude")).unwrap();
std::fs::write(t.path().join("desk/src/app.js"), "x").unwrap();
std::fs::write(t.path().join("desk/README.md"), "x").unwrap();
std::fs::write(t.path().join("desk/sub/README.md"), "x").unwrap();
std::fs::write(t.path().join("home/.claude/settings.json"), "{}").unwrap();
t
}
#[test]
fn a_line_after_the_path_is_the_line_and_a_column_is_dropped() {
assert_eq!(split_line("src/app.js:120"), ("src/app.js", Some(120)));
assert_eq!(split_line("src/app.js:120:5"), ("src/app.js", Some(120)));
assert_eq!(split_line("src/app.js"), ("src/app.js", None));
assert_eq!(split_line("src/app.js:"), ("src/app.js:", None));
assert_eq!(split_line("src/app.js:0"), ("src/app.js", None));
assert_eq!(split_line("C:/x"), ("C:/x", None));
}
#[test]
fn the_bases_are_tried_in_order_and_only_what_exists_is_a_path() {
let t = tree();
let desk = t.path().join("desk").canonicalize().unwrap();
let sub = desk.join("sub");
let bases = [sub.clone(), desk.clone()];
let f = find("README.md", &bases, None).unwrap();
assert_eq!(f.path, sub.join("README.md"));
let f = find("src/app.js:12:3", &bases, None).unwrap();
assert_eq!(
(f.path, f.dir, f.line),
(desk.join("src/app.js"), false, Some(12))
);
assert_eq!(find("src/nope.js", &bases, None), None);
assert_eq!(find("", &bases, None), None);
assert_eq!(find("a\nb", &bases, None), None);
}
#[test]
fn a_folder_has_no_line_and_a_name_with_a_colon_is_still_found() {
let t = tree();
let desk = t.path().join("desk").canonicalize().unwrap();
let f = find("src", std::slice::from_ref(&desk), None).unwrap();
assert!(f.dir && f.line.is_none());
let f = find("./src/", std::slice::from_ref(&desk), None).unwrap();
assert_eq!(f.path, desk.join("src"));
#[cfg(unix)]
{
std::fs::write(desk.join("a:12"), "x").unwrap();
let f = find("a:12", std::slice::from_ref(&desk), None).unwrap();
assert_eq!((f.path, f.line), (desk.join("a:12"), None));
}
}
#[test]
fn home_and_absolute_paths_need_no_base() {
let t = tree();
let home = t.path().join("home").canonicalize().unwrap();
let f = find("~/.claude/settings.json", &[], Some(&home)).unwrap();
assert_eq!(f.path, home.join(".claude/settings.json"));
assert!(find("~", &[], Some(&home)).unwrap().dir);
assert_eq!(find("~/.claude/x", &[], Some(&home)), None);
let abs = home.join(".claude/settings.json");
assert_eq!(find(abs.to_str().unwrap(), &[], None).unwrap().path, abs);
assert_eq!(
find("/no/such/snyvi/path", std::slice::from_ref(&home), None),
None
);
}
#[test]
fn a_file_opens_under_the_deepest_folder_already_open() {
let roots = vec![
("a".to_string(), PathBuf::from("/w")),
("b".to_string(), PathBuf::from("/w/snyvi")),
("c".to_string(), PathBuf::from("/other")),
];
assert_eq!(
under(Path::new("/w/snyvi/src/app.js"), &roots),
Some(("b", "src/app.js".to_string()))
);
assert_eq!(
under(Path::new("/w/x.md"), &roots),
Some(("a", "x.md".to_string()))
);
assert_eq!(under(Path::new("/elsewhere/x.md"), &roots), None);
}
}