Skip to main content

urly/
path.rs

1use std::borrow::Cow;
2
3use crate::chars::{self, PATH, split_at_char};
4use crate::error::InvalidUrl;
5use crate::query::Query;
6use crate::quoting::{Component, unquote, unquote_path_safe};
7
8/// Percent-encoded URL path.
9#[repr(transparent)]
10pub struct Path(str);
11
12str_type!(Path);
13str_eq!(Path);
14
15impl Path {
16    /// Strictly validates a path: `*( pchar / "/" )`.
17    pub fn new(src: &str) -> Result<&Path, InvalidUrl> {
18        chars::check(src, &PATH)?;
19        Ok(Path::from_str_unchecked(src))
20    }
21
22    /// Converts a static string to a path.
23    ///
24    /// # Panics
25    ///
26    /// Panics if the path is not valid.
27    pub fn from_static(src: &'static str) -> &'static Path {
28        match Path::new(src) {
29            Ok(path) => path,
30            Err(e) => panic!("invalid static path {src:?}: {e}"),
31        }
32    }
33
34    /// Returns `true` if the path is empty.
35    pub fn is_empty(&self) -> bool {
36        self.0.is_empty()
37    }
38
39    /// Returns `true` if the path starts with `/`.
40    pub fn is_absolute(&self) -> bool {
41        self.0.starts_with('/')
42    }
43
44    /// Returns the decoded path.
45    ///
46    /// ```
47    /// use urly::Path;
48    ///
49    /// assert_eq!(Path::from_static("/a%20b/c%2Fd").decode(), "/a b/c/d");
50    /// ```
51    pub fn decode(&self) -> Cow<'_, str> {
52        unquote(&self.0, Component::Path)
53    }
54
55    /// Returns the decoded path, keeping `%2F` and `%25` encoded, so the result
56    /// can still be split into segments.
57    ///
58    /// ```
59    /// use urly::Path;
60    ///
61    /// assert_eq!(Path::from_static("/a%20b/c%2Fd").decode_safe(), "/a b/c%2Fd");
62    /// ```
63    pub fn decode_safe(&self) -> Cow<'_, str> {
64        unquote_path_safe(&self.0)
65    }
66
67    /// Returns an iterator over the percent-encoded segments.
68    ///
69    /// ```
70    /// use urly::Path;
71    ///
72    /// let segments: Vec<_> = Path::from_static("/a/b%20c/").segments().collect();
73    /// assert_eq!(segments, ["a", "b%20c", ""]);
74    /// assert_eq!(Path::from_static("").segments().count(), 0);
75    /// ```
76    pub fn segments(&self) -> Segments<'_> {
77        let path = self.0.strip_prefix('/').unwrap_or(&self.0);
78        Segments((!self.0.is_empty()).then(|| path.split('/')))
79    }
80
81    /// Returns the last segment, unless it is empty, `.` or `..`.
82    ///
83    /// ```
84    /// use urly::Path;
85    ///
86    /// assert_eq!(Path::from_static("/a/file.tar.gz").file_name(), Some("file.tar.gz"));
87    /// assert_eq!(Path::from_static("/a/").file_name(), None);
88    /// ```
89    pub fn file_name(&self) -> Option<&str> {
90        self.0
91            .rsplit('/')
92            .next()
93            .filter(|name| !matches!(*name, "" | "." | ".."))
94    }
95
96    /// Returns the file name without its last extension.
97    ///
98    /// ```
99    /// use urly::Path;
100    ///
101    /// assert_eq!(Path::from_static("/file.tar.gz").file_stem(), Some("file.tar"));
102    /// assert_eq!(Path::from_static("/.hidden").file_stem(), Some(".hidden"));
103    /// ```
104    pub fn file_stem(&self) -> Option<&str> {
105        self.file_name().map(|name| split_extension(name).0)
106    }
107
108    /// Returns the last extension of the file name, without the dot.
109    ///
110    /// ```
111    /// use urly::Path;
112    ///
113    /// assert_eq!(Path::from_static("/file.tar.gz").extension(), Some("gz"));
114    /// assert_eq!(Path::from_static("/.hidden").extension(), None);
115    /// ```
116    pub fn extension(&self) -> Option<&str> {
117        self.file_name().and_then(|name| split_extension(name).1)
118    }
119
120    /// Returns all extensions of the file name, without the leading dot.
121    ///
122    /// ```
123    /// use urly::Path;
124    ///
125    /// assert_eq!(Path::from_static("/file.tar.gz").extensions(), Some("tar.gz"));
126    /// assert_eq!(Path::from_static("/file").extensions(), None);
127    /// ```
128    pub fn extensions(&self) -> Option<&str> {
129        let name = self.file_name()?;
130        let start = usize::from(name.starts_with('.'));
131        name[start..].find('.').map(|i| &name[start + i + 1..])
132    }
133}
134
135fn split_extension(name: &str) -> (&str, Option<&str>) {
136    match name.rfind('.') {
137        None | Some(0) => (name, None),
138        Some(i) => (&name[..i], Some(&name[i + 1..])),
139    }
140}
141
142/// Iterator over percent-encoded path segments, see [`Path::segments`].
143#[derive(Clone, Debug)]
144pub struct Segments<'a>(Option<std::str::Split<'a, char>>);
145
146impl<'a> Iterator for Segments<'a> {
147    type Item = &'a str;
148
149    fn next(&mut self) -> Option<&'a str> {
150        self.0.as_mut()?.next()
151    }
152}
153
154impl DoubleEndedIterator for Segments<'_> {
155    fn next_back(&mut self) -> Option<Self::Item> {
156        self.0.as_mut()?.next_back()
157    }
158}
159
160/// Path with an optional query: `path ["?" query]`.
161#[repr(transparent)]
162pub struct PathAndQuery(str);
163
164str_type!(PathAndQuery);
165str_eq!(PathAndQuery);
166
167impl PathAndQuery {
168    /// Strictly validates a path and query.
169    ///
170    /// ```
171    /// use urly::PathAndQuery;
172    ///
173    /// let pq = PathAndQuery::new("/search?q=rust").unwrap();
174    /// assert_eq!(pq.path(), "/search");
175    /// assert_eq!(pq.query().unwrap(), "q=rust");
176    /// ```
177    pub fn new(src: &str) -> Result<&PathAndQuery, InvalidUrl> {
178        let (path, query) = split_at_char(src, '?');
179        Path::new(path)?;
180        if let Some(query) = query {
181            Query::new(query).map_err(|e| e.offset(path.len() + 1))?;
182        }
183        Ok(PathAndQuery::from_str_unchecked(src))
184    }
185
186    /// Converts a static string to a path and query.
187    ///
188    /// # Panics
189    ///
190    /// Panics if the path or query is not valid.
191    pub fn from_static(src: &'static str) -> &'static PathAndQuery {
192        match PathAndQuery::new(src) {
193            Ok(pq) => pq,
194            Err(e) => panic!("invalid static path and query {src:?}: {e}"),
195        }
196    }
197
198    /// Returns the path.
199    pub fn path(&self) -> &Path {
200        Path::from_str_unchecked(split_at_char(&self.0, '?').0)
201    }
202
203    /// Returns the query, if present.
204    pub fn query(&self) -> Option<&Query> {
205        split_at_char(&self.0, '?').1.map(Query::from_str_unchecked)
206    }
207}