Skip to main content

music21/common/
pathtools.rs

1//! music21's `common.pathTools`, as far as it needs no Python.
2
3use std::path::Path;
4
5/// The index of the last `..` among a path's parts, if there is one.
6/// music21's `cleanpath` collapses a `..` only where the folders up to and
7/// including the last one exist, so this is how far it has to look.
8///
9/// ```
10/// use music21::common::pathtools::last_parent_dir;
11///
12/// assert_eq!(last_parent_dir(&["/", "a", "..", "b", "..", "c"]), Some(4));
13/// assert_eq!(last_parent_dir(&["a", "b"]), None);
14/// ```
15pub fn last_parent_dir<S: AsRef<str>>(parts: &[S]) -> Option<usize> {
16    parts.iter().rposition(|part| part.as_ref() == "..")
17}
18
19/// A path as music21's `common.cleanpath` leaves it: `~` expanded, made
20/// absolute against the working directory and normalized, and environment
21/// variables expanded.
22///
23/// A `..` is collapsed only where the directories before it exist, as the
24/// system would; otherwise the path is joined to the working directory as
25/// written.
26///
27/// ```
28/// use music21::common::pathtools::clean_path;
29///
30/// assert_eq!(clean_path("/usr//bin/./lilypond"), "/usr/bin/lilypond");
31/// assert!(clean_path("relative/file.xml").ends_with("/relative/file.xml"));
32/// ```
33pub fn clean_path(path: &str) -> String {
34    let expanded = expand_user(path);
35    let parts: Vec<&str> = expanded.split('/').collect();
36    let normalizable = match last_parent_dir(&parts) {
37        Some(last) => climbs_through_directories(&parts[..=last]),
38        None => true,
39    };
40    let cleaned = if normalizable {
41        absolute(&normalized(&expanded))
42    } else {
43        join(&working_directory(), &expanded)
44    };
45    expand_variables(&cleaned)
46}
47
48/// Whether every `..` among the parts climbs out of a directory that
49/// exists, as POSIX asks before resolving `a/..`. Each one is checked on
50/// its own: Windows resolves `a/..` on paper, so asking it about the
51/// whole path would say yes where `a` is missing.
52fn climbs_through_directories(parts: &[&str]) -> bool {
53    for (index, part) in parts.iter().enumerate() {
54        if *part != ".." {
55            continue;
56        }
57        let climbed = parts[..index].join("/");
58        let climbed = if climbed.is_empty() && index > 0 {
59            "/".to_owned()
60        } else if climbed.is_empty() {
61            ".".to_owned()
62        } else {
63            climbed
64        };
65        if !Path::new(&climbed).is_dir() {
66            return false;
67        }
68    }
69    true
70}
71
72/// `~` and `~user` at the start expanded, as `os.path.expanduser` does on
73/// a POSIX system: `~` from `HOME`.
74pub(crate) fn expand_user(path: &str) -> String {
75    if !path.starts_with('~') {
76        return path.to_owned();
77    }
78    let end = path.find('/').unwrap_or(path.len());
79    let home = if end == 1 {
80        std::env::var("HOME").ok()
81    } else {
82        // Another user's home: where it would be beside this user's.
83        std::env::var("HOME").ok().and_then(|home| {
84            Path::new(&home)
85                .parent()
86                .map(|parent| parent.join(&path[1..end]).to_string_lossy().into_owned())
87        })
88    };
89    let Some(home) = home else {
90        return path.to_owned();
91    };
92    let home = home.trim_end_matches('/');
93    let home = if home.is_empty() { "/" } else { home };
94    let rest = &path[end..];
95    if home == "/" && rest.starts_with('/') {
96        return rest.to_owned();
97    }
98    format!("{home}{rest}")
99}
100
101/// A path with `.`, doubled slashes and `name/..` taken out, as
102/// `os.path.normpath` takes them out on a POSIX system.
103fn normalized(path: &str) -> String {
104    if path.is_empty() {
105        return ".".to_owned();
106    }
107    let leading = if path.starts_with("//") && !path.starts_with("///") {
108        "//"
109    } else if path.starts_with('/') {
110        "/"
111    } else {
112        ""
113    };
114    let mut kept: Vec<&str> = Vec::new();
115    for part in path.split('/') {
116        if part.is_empty() || part == "." {
117            continue;
118        }
119        let climbs = part == "..";
120        if climbs && kept.last().is_some_and(|last| *last != "..") {
121            kept.pop();
122            continue;
123        }
124        if climbs && !leading.is_empty() {
125            continue;
126        }
127        kept.push(part);
128    }
129    let joined = format!("{leading}{}", kept.join("/"));
130    if joined.is_empty() {
131        return ".".to_owned();
132    }
133    joined
134}
135
136/// The working directory, as text.
137fn working_directory() -> String {
138    std::env::current_dir()
139        .map(|directory| directory.to_string_lossy().into_owned())
140        .unwrap_or_else(|_| "/".to_owned())
141}
142
143/// `os.path.join` of two paths: the second alone where it is absolute.
144fn join(first: &str, second: &str) -> String {
145    if second.starts_with('/') || first.is_empty() {
146        return second.to_owned();
147    }
148    if first.ends_with('/') {
149        return format!("{first}{second}");
150    }
151    format!("{first}/{second}")
152}
153
154/// `os.path.abspath` of a path already normalized.
155fn absolute(path: &str) -> String {
156    if path.starts_with('/') {
157        return path.to_owned();
158    }
159    normalized(&join(&working_directory(), path))
160}
161
162/// `$name` and `${name}` replaced by the variable's value, as
163/// `os.path.expandvars` does on a POSIX system; one that is not set is
164/// left as written.
165fn expand_variables(path: &str) -> String {
166    if !path.contains('$') {
167        return path.to_owned();
168    }
169    let is_name = |c: char| c.is_ascii_alphanumeric() || c == '_';
170    let mut expanded = String::new();
171    let mut rest = path;
172    while let Some(dollar) = rest.find('$') {
173        expanded.push_str(&rest[..dollar]);
174        let after = &rest[dollar + 1..];
175        let (name, written_length) = if let Some(braced) = after.strip_prefix('{') {
176            match braced.find('}') {
177                Some(close) => (&braced[..close], close + 2),
178                None => ("", 0),
179            }
180        } else {
181            let length = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
182            (&after[..length], length)
183        };
184        let value = if name.is_empty() {
185            None
186        } else {
187            std::env::var(name).ok()
188        };
189        match value {
190            Some(value) => expanded.push_str(&value),
191            None => expanded.push_str(&rest[dollar..=dollar + written_length]),
192        }
193        rest = &after[written_length..];
194    }
195    expanded.push_str(rest);
196    expanded
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    /// Only a whole part counts, not one that merely holds two dots.
204    #[test]
205    fn only_whole_parent_parts_count() {
206        assert_eq!(last_parent_dir(&["...", "a..", "..b"]), None);
207        assert_eq!(last_parent_dir(&[".."]), Some(0));
208        assert_eq!(last_parent_dir::<&str>(&[]), None);
209    }
210
211    /// Each `..` is asked about on its own, so the answer does not hang on
212    /// how the platform resolves `missing/..`.
213    #[test]
214    fn a_climb_needs_the_directory_it_leaves() {
215        assert!(climbs_through_directories(&["src", ".."]));
216        assert!(!climbs_through_directories(&["no-such-directory", ".."]));
217        assert!(!climbs_through_directories(&[
218            "src",
219            "no-such-directory",
220            "..",
221            ".."
222        ]));
223    }
224
225    #[test]
226    fn dot_dot_after_a_missing_directory_is_kept() {
227        let cleaned = clean_path("tinyNotation: /../../x.xml");
228        assert!(
229            cleaned.ends_with("/tinyNotation: /../../x.xml"),
230            "{cleaned}"
231        );
232    }
233
234    #[test]
235    fn normalizing_matches_posix_normpath() {
236        assert_eq!(normalized("//a/b"), "//a/b");
237        assert_eq!(normalized("///a/../b"), "/b");
238        assert_eq!(normalized("../a/./b/"), "../a/b");
239        assert_eq!(normalized("/.."), "/");
240        assert_eq!(normalized("a/.."), ".");
241    }
242
243    #[test]
244    fn unset_variables_are_left_as_written() {
245        assert_eq!(
246            expand_variables("/x/$MUSIC21_RS_SURELY_UNSET/${ALSO_UNSET_HERE}"),
247            "/x/$MUSIC21_RS_SURELY_UNSET/${ALSO_UNSET_HERE}"
248        );
249    }
250}