Skip to main content

yapi_core/tools/
path.rs

1//! Resolving paths the model writes: `@` prefixes, `~`, `file://`, odd spaces, and
2//! the macOS screenshot and quote variants of existing files.
3
4use std::path::{Component, Path, PathBuf};
5
6use unicode_normalization::UnicodeNormalization;
7
8fn is_unicode_space(c: char) -> bool {
9    matches!(
10        c,
11        '\u{00A0}' | '\u{2000}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}'
12    )
13}
14
15/// The user's home directory, as Node's `os.homedir()` finds it: `HOME`, even
16/// when empty, else the user database. `/` when neither has one.
17pub fn home_dir() -> PathBuf {
18    // The standard library passes over an empty `HOME`, which Node keeps.
19    std::env::var_os("HOME")
20        .map(PathBuf::from)
21        .or_else(std::env::home_dir)
22        .unwrap_or_else(|| PathBuf::from("/"))
23}
24
25/// Normalizes a path as pi does for tool input: Unicode spaces become spaces, a
26/// leading `@` is dropped, `~` expands, and `file://` URLs become paths.
27pub fn normalize(input: &str) -> String {
28    let mut text: String = input
29        .chars()
30        .map(|c| if is_unicode_space(c) { ' ' } else { c })
31        .collect();
32    if let Some(rest) = text.strip_prefix('@') {
33        text = rest.to_owned();
34    }
35    expand(&text)
36}
37
38/// Expands `~` and `file://` URLs.
39pub fn expand(text: &str) -> String {
40    // pi's `fileURLToPath`.
41    if text.starts_with("file://") {
42        return url::Url::parse(text)
43            .ok()
44            .and_then(|url| url.to_file_path().ok())
45            .map_or_else(
46                || text.to_owned(),
47                |path| path.to_string_lossy().into_owned(),
48            );
49    }
50    expand_home(text)
51}
52
53/// Node's `pathToFileURL` for an absolute path.
54pub fn file_url(path: &Path) -> String {
55    url::Url::from_file_path(path)
56        .map(String::from)
57        .unwrap_or_else(|_| format!("file://{}", path.display()))
58}
59
60/// The local path a `file:` URL names, the reverse of [`file_url`], or `None`
61/// for any other URL.
62pub fn file_url_path(url: &str) -> Option<PathBuf> {
63    let url = url::Url::parse(url).ok()?;
64    if url.scheme() != "file" {
65        return None;
66    }
67    url.to_file_path().ok()
68}
69
70/// `~` and `~/…` name the home directory, as in a shell.
71pub fn expand_home(text: &str) -> String {
72    if text == "~" {
73        return home_dir().to_string_lossy().into_owned();
74    }
75    match text.strip_prefix("~/") {
76        Some(rest) => home_dir().join(rest).to_string_lossy().into_owned(),
77        None => text.to_owned(),
78    }
79}
80
81/// Lexically resolves `path` against `base`: absolute result, no `.` or `..`.
82pub fn resolve_lexically(base: &Path, path: &Path) -> PathBuf {
83    let joined = if path.is_absolute() {
84        path.to_path_buf()
85    } else {
86        base.join(path)
87    };
88    let mut result = PathBuf::new();
89    for component in joined.components() {
90        match component {
91            Component::ParentDir => {
92                result.pop();
93            }
94            Component::CurDir => {}
95            other => result.push(other.as_os_str()),
96        }
97    }
98    result
99}
100
101/// Node's `path.relative` for absolute, lexically resolved paths: the steps from
102/// `from` to `to`, `""` when they are the same.
103pub fn relative(from: &Path, to: &Path) -> String {
104    use std::path::Component;
105    let parts = |path: &Path| -> Vec<String> {
106        resolve_lexically(Path::new("/"), path)
107            .components()
108            .filter_map(|component| match component {
109                Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
110                _ => None,
111            })
112            .collect()
113    };
114    let (from, to) = (parts(from), parts(to));
115    let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
116    let mut steps: Vec<String> = vec!["..".to_owned(); from.len() - common];
117    steps.extend(to[common..].iter().cloned());
118    steps.join("/")
119}
120
121/// Resolves a tool path against the working directory.
122pub fn resolve_to_cwd(path: &str, cwd: &Path) -> PathBuf {
123    resolve_lexically(cwd, Path::new(&normalize(path)))
124}
125
126/// Like [`resolve_to_cwd`], but when the file does not exist, tries the variants
127/// pi tries: narrow no-break space before AM/PM, the decomposed Unicode form
128/// (NFD), curly apostrophes.
129pub fn resolve_read_path(path: &str, cwd: &Path) -> PathBuf {
130    let resolved = resolve_to_cwd(path, cwd);
131    if resolved.exists() {
132        return resolved;
133    }
134    let text = resolved.to_string_lossy().into_owned();
135    let am_pm = text
136        .replace(" AM.", "\u{202F}AM.")
137        .replace(" PM.", "\u{202F}PM.")
138        .replace(" am.", "\u{202F}am.")
139        .replace(" pm.", "\u{202F}pm.");
140    let nfd: String = text.nfd().collect();
141    let curly = text.replace('\'', "\u{2019}");
142    let nfd_curly = nfd.replace('\'', "\u{2019}");
143    for candidate in [am_pm, nfd, curly, nfd_curly] {
144        if candidate != text && Path::new(&candidate).exists() {
145            return PathBuf::from(candidate);
146        }
147    }
148    resolved
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn resolves_like_pi() {
157        let cwd = Path::new("/work/project");
158        assert_eq!(
159            resolve_to_cwd("@src/../lib.rs", cwd),
160            PathBuf::from("/work/project/lib.rs")
161        );
162        assert_eq!(resolve_to_cwd("/tmp/./a", cwd), PathBuf::from("/tmp/a"));
163        assert_eq!(
164            resolve_to_cwd("file:///tmp/a%20b", cwd),
165            PathBuf::from("/tmp/a b")
166        );
167        assert_eq!(normalize("a\u{00A0}b"), "a b");
168    }
169
170    #[test]
171    fn relative_paths_like_node() {
172        assert_eq!(
173            relative(Path::new("/a/b"), Path::new("/a/b/c/d.txt")),
174            "c/d.txt"
175        );
176        assert_eq!(relative(Path::new("/a/b"), Path::new("/a/x")), "../x");
177        assert_eq!(relative(Path::new("/a/b/"), Path::new("/a/b")), "");
178    }
179}