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