asciidork_core/
path.rs

1#[derive(Debug, Clone, PartialEq, Eq)]
2enum Component {
3  UriScheme(String),
4  DrivePrefix(String),
5  Root,
6  CurrentDir,
7  ParentDir,
8  Normal(String),
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Path {
13  separator: char,
14  components: Vec<Component>,
15}
16
17impl Path {
18  pub fn new_specifying_separator(path: impl Into<String>, separator: char) -> Path {
19    Path::from_optional_sep(path, Some(separator))
20  }
21
22  pub fn new(path: impl Into<String>) -> Path {
23    Path::from_optional_sep(path, None)
24  }
25
26  fn from_optional_sep(path: impl Into<String>, separator: Option<char>) -> Path {
27    let path: String = path.into();
28    let mut path = path.as_str();
29    let mut components = Vec::with_capacity(4);
30    let inferred_separator = match drive_prefix(path) {
31      Some(prefix) => {
32        components.push(Component::DrivePrefix(prefix));
33        path = &path[2..];
34        '\\'
35      }
36      _ => {
37        if path.contains('\\') {
38          '\\'
39        } else if path.contains('/') {
40          '/'
41        } else {
42          std::path::MAIN_SEPARATOR
43        }
44      }
45    };
46    if path.starts_with(inferred_separator) {
47      components.push(Component::Root);
48      path = &path[1..];
49    }
50    path.split(inferred_separator).for_each(|s| match s {
51      "" => {}
52      "." => components.push(Component::CurrentDir),
53      ".." => components.push(Component::ParentDir),
54      "https:" => components.push(Component::UriScheme("https".to_string())),
55      "http:" => components.push(Component::UriScheme("http".to_string())),
56      _ => components.push(Component::Normal(s.to_string())),
57    });
58    Path {
59      separator: separator.unwrap_or(inferred_separator),
60      components,
61    }
62  }
63
64  pub fn push(&mut self, other: impl Into<Path>) {
65    let other: Path = other.into();
66    if other.is_absolute() {
67      self.components.clear();
68    }
69    self.components.extend(other.components);
70  }
71
72  pub fn is_absolute(&self) -> bool {
73    if self.is_uri() && matches!(self.components.first(), Some(Component::UriScheme(_))) {
74      return true;
75    }
76    self.components.first() == Some(&Component::Root)
77      || (matches!(self.components.first(), Some(Component::DrivePrefix(_)))
78        && matches!(self.components.get(1), Some(Component::Root)))
79  }
80
81  pub fn is_relative(&self) -> bool {
82    !self.is_absolute()
83  }
84
85  pub fn pop(&mut self) -> bool {
86    if self.components.len() > 1 {
87      self.components.pop();
88      true
89    } else {
90      false
91    }
92  }
93
94  pub fn join(&self, other: impl Into<Path>) -> Path {
95    let mut joined = self.clone();
96    joined.push(other);
97    joined
98  }
99
100  pub fn file_name(&self) -> &str {
101    self._file_name(self.components.len() - 1)
102  }
103
104  fn _file_name(&self, idx: usize) -> &str {
105    match self.components.get(idx) {
106      Some(Component::Normal(s)) => s,
107      Some(Component::CurrentDir) => self._file_name(idx - 1),
108      _ => "",
109    }
110  }
111
112  pub fn file_stem(&self) -> &str {
113    let file_name = self.file_name();
114    file_name
115      .rsplit_once('.')
116      .map(|(before, _)| before)
117      .unwrap_or(file_name)
118  }
119
120  pub fn extension(&self) -> &str {
121    let filename = self.file_name();
122    if let Some(idx) = filename.rfind('.') {
123      &filename[idx..]
124    } else {
125      ""
126    }
127  }
128
129  pub fn dirname(&self) -> String {
130    if self.components.len() == 1 && self.components[0] == Component::Root {
131      return self.to_string();
132    }
133    if self.components.len() == 2
134      && matches!(self.components[0], Component::DrivePrefix(_))
135      && self.components[1] == Component::Root
136    {
137      return self.to_string();
138    }
139    let mut path = String::with_capacity(32);
140    for (i, component) in self.components.iter().enumerate() {
141      if i == self.components.len() - 1 {
142        break;
143      }
144      match component {
145        Component::UriScheme(s) => {
146          path.push_str(s);
147          path.push(':');
148        }
149        Component::DrivePrefix(s) => path.push_str(s),
150        Component::Root => path.push(self.separator),
151        Component::CurrentDir => path.push('.'),
152        Component::ParentDir => path.push_str(".."),
153        Component::Normal(s) => path.push_str(s),
154      }
155      if i < self.components.len() - 2
156        && component != &Component::Root
157        && !matches!(component, Component::DrivePrefix(_))
158      {
159        path.push(self.separator);
160      }
161    }
162    path
163  }
164
165  #[expect(clippy::missing_const_for_fn, reason = "false positive")]
166  pub fn is_uri(&self) -> bool {
167    matches!(self.components.first(), Some(Component::UriScheme(_)))
168  }
169}
170
171impl From<std::path::PathBuf> for Path {
172  fn from(path: std::path::PathBuf) -> Self {
173    Path::new(path.to_string_lossy())
174  }
175}
176
177impl From<&str> for Path {
178  fn from(path: &str) -> Self {
179    Path::new(path)
180  }
181}
182
183impl std::fmt::Display for Path {
184  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185    let mut path = String::with_capacity(32);
186    for (i, component) in self.components.iter().enumerate() {
187      match component {
188        Component::UriScheme(s) => {
189          path.push_str(s);
190          path.push_str(":/");
191        }
192        Component::DrivePrefix(s) => path.push_str(s),
193        Component::Root => path.push(self.separator),
194        Component::CurrentDir => path.push('.'),
195        Component::ParentDir => path.push_str(".."),
196        Component::Normal(s) => path.push_str(s),
197      }
198      if i < self.components.len() - 1
199        && component != &Component::Root
200        && !matches!(component, Component::DrivePrefix(_))
201      {
202        path.push(self.separator);
203      }
204    }
205    write!(f, "{}", path)
206  }
207}
208
209fn drive_prefix(path: &str) -> Option<String> {
210  if path.len() < 2 {
211    return None;
212  }
213  let bytes = path.as_bytes();
214  if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
215    let prefix = path[..2].to_string();
216    Some(prefix)
217  } else {
218    None
219  }
220}
221
222#[cfg(test)]
223mod tests {
224  use super::*;
225
226  fn path(s: &str) -> Path {
227    Path::new(s)
228  }
229
230  #[test]
231  fn path_new() {
232    let path = Path::new("/usr/local");
233    assert_eq!(path.separator, '/');
234    assert_eq!(path.components.len(), 3);
235    assert_eq!(path.components[0], Component::Root);
236    assert_eq!(path.components[1], Component::Normal("usr".to_string()));
237    assert_eq!(path.components[2], Component::Normal("local".to_string()));
238    assert_eq!(path.to_string(), "/usr/local");
239    let path = Path::new("/usr/local/");
240    assert_eq!(path.to_string(), "/usr/local");
241  }
242
243  #[test]
244  fn path_new_windows() {
245    let mut path = Path::new(r#"c:\windows\foo.dll"#);
246    assert_eq!(path.separator, '\\');
247    assert_eq!(path.components.len(), 4);
248    assert_eq!(path.components[0], Component::DrivePrefix("c:".to_string()));
249    assert_eq!(path.components[1], Component::Root);
250    assert_eq!(path.components[2], Component::Normal("windows".to_string()));
251    assert_eq!(path.components[3], Component::Normal("foo.dll".to_string()));
252    assert_eq!(path.to_string(), r#"c:\windows\foo.dll"#);
253    path.pop();
254    path.push("baz");
255    path.push("qux.dll");
256    assert_eq!(path.to_string(), r#"c:\windows\baz\qux.dll"#);
257    assert!(!path.is_uri());
258  }
259
260  #[test]
261  fn path_new_uri() {
262    let path = Path::new(r#"https://example.com/path"#);
263    assert_eq!(path.separator, '/');
264    assert_eq!(path.components.len(), 3);
265    assert_eq!(
266      path.components[0],
267      Component::UriScheme("https".to_string())
268    );
269    assert_eq!(
270      path.components[1],
271      Component::Normal("example.com".to_string())
272    );
273    assert_eq!(path.components[2], Component::Normal("path".to_string()));
274    assert_eq!(path.to_string(), "https://example.com/path");
275    assert!(path.is_uri());
276  }
277
278  #[test]
279  fn path_is_absolute() {
280    assert!(path("/usr/local").is_absolute());
281    assert!(!path("usr/local").is_absolute());
282    assert!(path(r#"c:\foo"#).is_absolute());
283    assert!(path(r#"\foo"#).is_absolute());
284    assert!(path(r#"http://foo.com"#).is_absolute());
285    assert!(!path(r#"c:foo"#).is_absolute());
286  }
287
288  #[test]
289  fn path_push_pop() {
290    let mut path = Path::new("/usr/local");
291    path.push("bin");
292    assert_eq!(path.components.len(), 4);
293    assert_eq!(path.components[3], Component::Normal("bin".to_string()));
294    assert_eq!(path.to_string(), "/usr/local/bin");
295    assert!(path.pop());
296    assert_eq!(path.to_string(), "/usr/local");
297    assert!(path.pop());
298    assert_eq!(path.to_string(), "/usr");
299    assert!(path.pop());
300    assert_eq!(path.to_string(), "/");
301    assert!(!path.pop());
302    assert_eq!(path.to_string(), "/");
303    // pushing an absolute path replaces
304    let mut path = Path::new("/usr/local");
305    path.push("/bin");
306    assert_eq!(path.to_string(), "/bin");
307  }
308
309  #[test]
310  fn path_join() {
311    let path = Path::new("/etc");
312    assert_eq!(path.to_string(), "/etc");
313    let joined = path.join("passwd");
314    assert_eq!(path.to_string(), "/etc");
315    assert_eq!(joined.to_string(), "/etc/passwd");
316  }
317
318  #[test]
319  fn path_dirname() {
320    assert_eq!("", &path("foo.txt").dirname());
321    assert_eq!("bar", &path("bar/foo.txt").dirname());
322    assert_eq!("/", &path("/foo.txt").dirname());
323    assert_eq!("/", &path("/").dirname());
324    assert_eq!("c:\\foo", path("c:\\foo\\baz.adoc").dirname());
325    assert_eq!("c:\\", path("c:\\").dirname());
326  }
327
328  #[test]
329  fn path_extension() {
330    assert_eq!(".txt", path("foo/bar/baz.txt").extension());
331    assert_eq!(".asciidoc", path("foo/bar/baz.asciidoc").extension());
332    assert_eq!(".txt", path("baz.txt").extension());
333    assert_eq!("", path("foo/bar/baz").extension());
334    assert_eq!("", path("foo").extension());
335    assert_eq!("", path("foo/b.ar/baz").extension());
336  }
337
338  #[test]
339  fn path_file_name() {
340    assert_eq!("bin", path("bin").file_name());
341    assert_eq!("bin", path("bin/").file_name());
342    assert_eq!("foo.txt", path("tmp/foo.txt").file_name());
343    assert_eq!("foo.txt", path("foo.txt/.").file_name());
344    assert_eq!("foo.txt", path("foo.txt/./././././.").file_name());
345    assert_eq!("foo.txt", path("foo.txt/.//").file_name());
346    assert_eq!("", path("foo.txt/..").file_name());
347    assert_eq!("", path("/").file_name());
348    assert_eq!("", path("c:\\").file_name());
349    assert_eq!("foo", path("c:\\foo").file_name());
350    assert_eq!("foo", path("\\foo").file_name());
351  }
352
353  #[test]
354  fn path_file_stem() {
355    assert_eq!("bin", path("bin").file_stem());
356    assert_eq!("bin", path("bin/").file_stem());
357    assert_eq!("foo", path("foo.rs").file_stem());
358    assert_eq!("foo", path("/weird.txt/foo.bar/foo.rs").file_stem());
359    assert_eq!("foo.tar", path("foo.tar.gz").file_stem());
360  }
361
362  #[test]
363  fn join_uri_relative() {
364    let src = Path::new("https://example.com/foo/bar");
365    let dir = Path::new(src.dirname());
366    assert_eq!(dir.to_string(), "https://example.com/foo");
367    let rel = Path::new("baz");
368    let abs = dir.join(rel);
369    assert_eq!(abs.to_string(), "https://example.com/foo/baz");
370    assert!(abs.is_uri());
371  }
372}