music21/common/
pathtools.rs1use std::path::Path;
4
5pub fn last_parent_dir<S: AsRef<str>>(parts: &[S]) -> Option<usize> {
16 parts.iter().rposition(|part| part.as_ref() == "..")
17}
18
19pub 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
48fn 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
72pub(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 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
101fn 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
136fn 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
143fn 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
154fn absolute(path: &str) -> String {
156 if path.starts_with('/') {
157 return path.to_owned();
158 }
159 normalized(&join(&working_directory(), path))
160}
161
162fn 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 #[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 #[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}