Skip to main content

ferroui_base/utilities/
uri.rs

1//! A minimal counterpart of .NET's `System.Uri`, covering what resource and
2//! font keys need: absolute/relative detection, scheme, path, resolution of a
3//! relative reference against a base and value equality.
4
5use std::fmt;
6use std::hash::{Hash, Hasher};
7use std::rc::Rc;
8
9/// Defines the kinds of [`Uri`] accepted when parsing.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum UriKind {
12    /// The kind is determined from the string.
13    RelativeOrAbsolute,
14    /// The string must be an absolute URI (it has a scheme).
15    Absolute,
16    /// The string must be a relative reference.
17    Relative,
18}
19
20/// Error returned when a string is not a valid URI of the requested kind.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct UriFormatError {
23    message: &'static str,
24}
25
26impl fmt::Display for UriFormatError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(self.message)
29    }
30}
31
32impl std::error::Error for UriFormatError {}
33
34/// A uniform resource identifier.
35///
36/// Cloning is cheap (one reference count).
37#[derive(Clone)]
38pub struct Uri {
39    original: Rc<str>,
40    /// For absolute URIs: the canonical text (scheme lower-cased).
41    absolute: Option<Rc<str>>,
42    /// Length of the scheme in `absolute`.
43    scheme_length: usize,
44}
45
46fn scheme_length(s: &str) -> Option<usize> {
47    let colon = s.find(':')?;
48    let scheme = &s[..colon];
49    let mut chars = scheme.chars();
50    let first = chars.next()?;
51    // A single letter followed by ':' is a drive letter, not a scheme.
52    if scheme.len() < 2 || !first.is_ascii_alphabetic() {
53        return None;
54    }
55    if chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
56        Some(colon)
57    } else {
58        None
59    }
60}
61
62impl Uri {
63    /// Parses a URI of the given kind (C# `new Uri(string, UriKind)`).
64    pub fn new(uri_string: &str, kind: UriKind) -> Result<Uri, UriFormatError> {
65        Self::try_create(uri_string, kind).ok_or(UriFormatError {
66            message: "Invalid URI: The format of the URI could not be determined.",
67        })
68    }
69
70    /// Parses an absolute URI (C# `new Uri(string)`).
71    pub fn absolute(uri_string: &str) -> Result<Uri, UriFormatError> {
72        Self::new(uri_string, UriKind::Absolute)
73    }
74
75    /// Parses a URI of the given kind (C# `Uri.TryCreate`).
76    pub fn try_create(uri_string: &str, kind: UriKind) -> Option<Uri> {
77        let scheme = scheme_length(uri_string);
78
79        match (kind, scheme) {
80            (UriKind::Absolute, None) | (UriKind::Relative, Some(_)) => None,
81            (_, Some(length)) => {
82                if uri_string.len() == length + 1 {
83                    // A scheme without anything after it.
84                    return None;
85                }
86                let mut canonical = String::with_capacity(uri_string.len());
87                canonical.push_str(&uri_string[..length].to_ascii_lowercase());
88                let rest = &uri_string[length..];
89                // The authority (host) is case-insensitive and canonically lower case.
90                match rest.strip_prefix("://") {
91                    Some(after) => {
92                        let authority_length = after.find(['/', '?', '#']).unwrap_or(after.len());
93                        canonical.push_str("://");
94                        canonical.push_str(&after[..authority_length].to_ascii_lowercase());
95                        canonical.push_str(&after[authority_length..]);
96                    }
97                    None => canonical.push_str(rest),
98                }
99                Some(Uri { original: Rc::from(uri_string), absolute: Some(Rc::from(canonical)), scheme_length: length })
100            }
101            (_, None) => Some(Uri { original: Rc::from(uri_string), absolute: None, scheme_length: 0 }),
102        }
103    }
104
105    /// Resolves `relative` against the absolute `base` (C# `new Uri(Uri, Uri)`).
106    ///
107    /// Panics when `base` is not absolute. An absolute `relative` is returned unchanged.
108    pub fn combine(base: &Uri, relative: &Uri) -> Uri {
109        if relative.is_absolute_uri() {
110            return relative.clone();
111        }
112
113        let base_text = base.absolute.as_deref().expect("The base URI must be absolute.");
114        let reference: &str = &relative.original;
115
116        // Split the base into "scheme:[//authority]" and path (query/fragment dropped).
117        let after_scheme = base.scheme_length + 1;
118        let (prefix_end, path_end) = {
119            let rest = &base_text[after_scheme..];
120            let authority_length = if let Some(stripped) = rest.strip_prefix("//") {
121                2 + stripped.find(['/', '?', '#']).unwrap_or(stripped.len())
122            } else {
123                0
124            };
125            let path_start = after_scheme + authority_length;
126            let path_length = base_text[path_start..].find(['?', '#']).unwrap_or(base_text.len() - path_start);
127            (path_start, path_start + path_length)
128        };
129        let prefix = &base_text[..prefix_end];
130        let base_path = &base_text[prefix_end..path_end];
131
132        let (reference_path, reference_suffix) = match reference.find(['?', '#']) {
133            Some(index) => (&reference[..index], &reference[index..]),
134            None => (reference, ""),
135        };
136
137        let merged = if reference_path.is_empty() {
138            base_path.to_owned()
139        } else if reference_path.starts_with('/') {
140            reference_path.to_owned()
141        } else {
142            let directory = match base_path.rfind('/') {
143                Some(index) => &base_path[..=index],
144                None => {
145                    if prefix.ends_with(':') {
146                        ""
147                    } else {
148                        "/"
149                    }
150                }
151            };
152            format!("{directory}{reference_path}")
153        };
154
155        let mut result = String::with_capacity(prefix.len() + merged.len() + reference_suffix.len());
156        result.push_str(prefix);
157        result.push_str(&remove_dot_segments(&merged));
158        result.push_str(reference_suffix);
159
160        Uri::try_create(&result, UriKind::Absolute).expect("combining with an absolute base gives an absolute URI")
161    }
162
163    /// Whether the URI is absolute (C# `IsAbsoluteUri`).
164    #[inline]
165    pub fn is_absolute_uri(&self) -> bool {
166        self.absolute.is_some()
167    }
168
169    /// The string the URI was created from (C# `OriginalString`).
170    #[inline]
171    pub fn original_string(&self) -> &str {
172        &self.original
173    }
174
175    /// The lower-cased scheme (C# `Scheme`).
176    ///
177    /// Panics for a relative URI (C# throws `InvalidOperationException`).
178    pub fn scheme(&self) -> &str {
179        &self.expect_absolute()[..self.scheme_length]
180    }
181
182    /// The canonical absolute text (C# `AbsoluteUri`).
183    ///
184    /// Panics for a relative URI (C# throws `InvalidOperationException`).
185    pub fn absolute_uri(&self) -> &str {
186        self.expect_absolute()
187    }
188
189    /// The path component (C# `AbsolutePath`).
190    ///
191    /// Panics for a relative URI (C# throws `InvalidOperationException`).
192    pub fn absolute_path(&self) -> &str {
193        let text = self.expect_absolute();
194        let rest = &text[self.scheme_length + 1..];
195        let path = match rest.strip_prefix("//") {
196            Some(stripped) => &stripped[stripped.find(['/', '?', '#']).unwrap_or(stripped.len())..],
197            None => rest,
198        };
199        &path[..path.find(['?', '#']).unwrap_or(path.len())]
200    }
201
202    /// The query component including the leading `?`, or an empty string (C# `Query`).
203    pub fn query(&self) -> &str {
204        let text = self.expect_absolute();
205        match text.find('?') {
206            Some(start) => {
207                let query = &text[start..];
208                &query[..query.find('#').unwrap_or(query.len())]
209            }
210            None => "",
211        }
212    }
213
214    /// The local operating-system representation of a file name (C# `LocalPath`):
215    /// the unescaped path, which for a file URI of a drive path (`file:///C:/dir`)
216    /// is the drive path with back slashes (`C:\dir`) and for a file URI with a
217    /// host is the UNC path (`\\host\share`).
218    ///
219    /// Panics for a relative URI (C# throws `InvalidOperationException`).
220    pub fn local_path(&self) -> String {
221        use super::UriExtensions;
222
223        let text = self.expect_absolute();
224        if self.scheme() != "file" {
225            return UriExtensions::unescape_data_string(self.absolute_path());
226        }
227
228        let rest = &text[self.scheme_length + 1..];
229        let (authority, path) = match rest.strip_prefix("//") {
230            Some(stripped) => stripped.split_at(stripped.find(['/', '?', '#']).unwrap_or(stripped.len())),
231            None => ("", rest),
232        };
233        let path = &path[..path.find(['?', '#']).unwrap_or(path.len())];
234
235        if !authority.is_empty() {
236            return UriExtensions::unescape_data_string(&format!("\\\\{authority}{}", path.replace('/', "\\")));
237        }
238
239        let bytes = path.as_bytes();
240        let is_drive_path = bytes.len() >= 3
241            && bytes[0] == b'/'
242            && bytes[1].is_ascii_alphabetic()
243            && bytes[2] == b':'
244            && bytes.get(3).is_none_or(|b| *b == b'/');
245        if is_drive_path {
246            return UriExtensions::unescape_data_string(&path[1..].replace('/', "\\"));
247        }
248
249        UriExtensions::unescape_data_string(path)
250    }
251
252    /// Makes a relative URI absolute using `base_uri`; absolute URIs are returned unchanged.
253    ///
254    /// Panics when the URI is relative and `base_uri` is missing or relative
255    /// (C# throws `ArgumentException`).
256    pub fn ensure_absolute(&self, base_uri: Option<&Uri>) -> Uri {
257        if self.is_absolute_uri() {
258            return self.clone();
259        }
260        let Some(base_uri) = base_uri else {
261            panic!("Relative uri {self} without base url");
262        };
263        if !base_uri.is_absolute_uri() {
264            panic!("Base uri {base_uri} is relative");
265        }
266        Uri::combine(base_uri, self)
267    }
268
269    fn expect_absolute(&self) -> &str {
270        self.absolute.as_deref().expect("This operation is not supported for a relative URI.")
271    }
272}
273
274fn remove_dot_segments(path: &str) -> String {
275    let mut output: Vec<&str> = Vec::new();
276    let absolute = path.starts_with('/');
277    let trailing_slash = path.ends_with('/') || path.ends_with("/.") || path.ends_with("/..");
278
279    for segment in path.split('/') {
280        match segment {
281            "" | "." => {}
282            ".." => {
283                output.pop();
284            }
285            other => output.push(other),
286        }
287    }
288
289    let mut result = String::with_capacity(path.len());
290    if absolute {
291        result.push('/');
292    }
293    result.push_str(&output.join("/"));
294    if trailing_slash && !result.ends_with('/') {
295        result.push('/');
296    }
297    result
298}
299
300/// The text without its fragment: as in .NET, the fragment does not take part
301/// in the comparison of absolute URIs.
302fn without_fragment(text: &str) -> &str {
303    &text[..text.find('#').unwrap_or(text.len())]
304}
305
306impl PartialEq for Uri {
307    fn eq(&self, other: &Self) -> bool {
308        match (&self.absolute, &other.absolute) {
309            (Some(a), Some(b)) => without_fragment(a) == without_fragment(b),
310            (None, None) => self.original == other.original,
311            _ => false,
312        }
313    }
314}
315
316impl Eq for Uri {}
317
318impl Hash for Uri {
319    fn hash<H: Hasher>(&self, state: &mut H) {
320        match &self.absolute {
321            Some(absolute) => without_fragment(absolute).hash(state),
322            None => self.original.hash(state),
323        }
324    }
325}
326
327impl fmt::Display for Uri {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        match &self.absolute {
330            Some(absolute) => f.write_str(absolute),
331            None => f.write_str(&self.original),
332        }
333    }
334}
335
336impl fmt::Debug for Uri {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        fmt::Display::fmt(self, f)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn absolute_and_relative() {
348        let uri = Uri::absolute("Resm:Fonts.Assets?assembly=App#Name").unwrap();
349        assert!(uri.is_absolute_uri());
350        assert_eq!(uri.scheme(), "resm");
351        assert_eq!(uri.absolute_path(), "Fonts.Assets");
352        assert_eq!(uri.query(), "?assembly=App");
353
354        assert!(Uri::try_create("/Assets/Fonts", UriKind::Absolute).is_none());
355        assert!(Uri::try_create("/Assets/Fonts", UriKind::Relative).is_some());
356        assert!(Uri::try_create("fonts:Inter", UriKind::Relative).is_none());
357        assert!(Uri::try_create("C:x", UriKind::Absolute).is_none());
358    }
359
360    #[test]
361    fn combine_resolves_against_base() {
362        let base = Uri::absolute("app://Host/dir/page.xaml").unwrap();
363        let relative = Uri::new("/Assets/Fonts", UriKind::Relative).unwrap();
364        assert_eq!(Uri::combine(&base, &relative).to_string(), "app://host/Assets/Fonts");
365
366        let relative = Uri::new("../Fonts/#Name", UriKind::Relative).unwrap();
367        assert_eq!(Uri::combine(&base, &relative).to_string(), "app://host/Fonts/#Name");
368
369        let relative = Uri::new("Fonts", UriKind::Relative).unwrap();
370        assert_eq!(relative.ensure_absolute(Some(&base)).absolute_path(), "/dir/Fonts");
371    }
372
373    #[test]
374    fn equality_ignores_scheme_case() {
375        assert_eq!(Uri::absolute("FONTS:SystemFonts").unwrap(), Uri::absolute("fonts:SystemFonts").unwrap());
376        assert_ne!(Uri::absolute("fonts:a").unwrap(), Uri::absolute("fonts:b").unwrap());
377        assert_eq!(Uri::absolute("resm:A.B#MyFont").unwrap(), Uri::absolute("resm:A.B").unwrap());
378        assert_eq!(Uri::absolute("app://Host/Path").unwrap().absolute_uri(), "app://host/Path");
379    }
380}