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}