Skip to main content

ferroui_base/utilities/
uri_extensions.rs

1use crate::platform::ASSET_SCHEME;
2use crate::utilities::{FormatError, Uri};
3
4/// Helpers for the URIs that identify assets.
5pub struct UriExtensions;
6
7impl UriExtensions {
8    /// Whether the URI is an absolute embedded-resource URI.
9    pub fn is_absolute_resm(uri: &Uri) -> bool {
10        uri.is_absolute_uri() && Self::is_resm(uri)
11    }
12
13    /// Whether the URI has the embedded-resource scheme. Panics for a
14    /// relative URI.
15    pub fn is_resm(uri: &Uri) -> bool {
16        uri.scheme() == "resm"
17    }
18
19    /// Whether the URI has the asset scheme ([`ASSET_SCHEME`]). Panics for a
20    /// relative URI.
21    pub fn is_asset(uri: &Uri) -> bool {
22        uri.scheme() == ASSET_SCHEME
23    }
24
25    /// Whether the URI names a font collection (`fonts:` scheme). Panics for
26    /// a relative URI.
27    pub fn is_font_collection(uri: &Uri) -> bool {
28        uri.scheme() == crate::media::FontManager::FONT_COLLECTION_SCHEME
29    }
30
31    /// Makes a relative URI absolute using `base_uri`; absolute URIs are
32    /// returned unchanged. Fails when the URI is relative and `base_uri` is
33    /// missing or relative.
34    pub fn ensure_absolute(uri: &Uri, base_uri: Option<&Uri>) -> Result<Uri, FormatError> {
35        if uri.is_absolute_uri() {
36            return Ok(uri.clone());
37        }
38        let Some(base_uri) = base_uri else {
39            return Err(FormatError::from_string(format!("Relative uri {uri} without base url")));
40        };
41        if !base_uri.is_absolute_uri() {
42            return Err(FormatError::from_string(format!("Base uri {base_uri} is relative")));
43        }
44        Ok(Uri::combine(base_uri, uri))
45    }
46
47    /// The authority (host) of an absolute URI, or an empty string when it
48    /// has none. Panics for a relative URI.
49    pub fn authority(uri: &Uri) -> &str {
50        let text = uri.absolute_uri();
51        let rest = &text[uri.scheme().len() + 1..];
52        match rest.strip_prefix("//") {
53            Some(stripped) => &stripped[..stripped.find(['/', '?', '#']).unwrap_or(stripped.len())],
54            None => "",
55        }
56    }
57
58    /// The unescaped path of an absolute URI.
59    pub fn get_unescape_absolute_path(uri: &Uri) -> String {
60        Self::unescape_data_string(uri.absolute_path())
61    }
62
63    /// The unescaped text of an absolute URI.
64    pub fn get_unescape_absolute_uri(uri: &Uri) -> String {
65        Self::unescape_data_string(uri.absolute_uri())
66    }
67
68    /// The value of the `assembly` parameter of the query of an absolute
69    /// URI, or an empty string.
70    pub fn get_assembly_name_from_query(uri: &Uri) -> String {
71        const ASSEMBLY: &[u8] = b"assembly";
72
73        let query = Self::unescape_data_string(uri.query());
74        let query = query.as_bytes();
75
76        // Skip the '?'
77        let mut current_index = 1;
78        while current_index < query.len() {
79            let mut is_find = false;
80            let mut i = 0;
81            while i < ASSEMBLY.len() {
82                if current_index < query.len() && query[current_index] == ASSEMBLY[i] {
83                    is_find = i == ASSEMBLY.len() - 1;
84                } else {
85                    break;
86                }
87                current_index += 1;
88                i += 1;
89            }
90
91            // Skip the '='
92            current_index += 1;
93
94            let begin_index = current_index;
95            while current_index < query.len() && query[current_index] != b'&' {
96                current_index += 1;
97            }
98
99            if is_find {
100                // A parameter without a value ends the query before its
101                // value begins.
102                let end_index = current_index.min(query.len());
103                let begin_index = begin_index.min(end_index);
104                return String::from_utf8_lossy(&query[begin_index..end_index]).into_owned();
105            }
106
107            current_index += 1;
108        }
109
110        String::new()
111    }
112
113    /// Converts the percent-encoded sequences of a string to the characters
114    /// they represent. Sequences that do not form valid UTF-8 are left as
115    /// they are.
116    pub fn unescape_data_string(s: &str) -> String {
117        if !s.contains('%') {
118            return s.to_owned();
119        }
120
121        fn hex(b: u8) -> Option<u8> {
122            (b as char).to_digit(16).map(|d| d as u8)
123        }
124
125        let bytes = s.as_bytes();
126        let mut result = String::with_capacity(s.len());
127        let mut literal_start = 0;
128        let mut i = 0;
129        while i < bytes.len() {
130            if bytes[i] != b'%' {
131                i += 1;
132                continue;
133            }
134
135            // Collect a run of consecutive escape sequences.
136            let run_start = i;
137            let mut decoded = Vec::new();
138            while i + 2 < bytes.len() && bytes[i] == b'%' {
139                match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
140                    (Some(high), Some(low)) => {
141                        decoded.push(high * 16 + low);
142                        i += 3;
143                    }
144                    _ => break,
145                }
146            }
147            if decoded.is_empty() {
148                // A '%' that does not start an escape sequence.
149                i += 1;
150                continue;
151            }
152
153            result.push_str(&s[literal_start..run_start]);
154            let mut offset = 0;
155            while offset < decoded.len() {
156                match std::str::from_utf8(&decoded[offset..]) {
157                    Ok(valid) => {
158                        result.push_str(valid);
159                        offset = decoded.len();
160                    }
161                    Err(error) => {
162                        let valid = error.valid_up_to();
163                        result.push_str(std::str::from_utf8(&decoded[offset..offset + valid]).unwrap_or_default());
164                        let invalid = error.error_len().unwrap_or(decoded.len() - offset - valid);
165                        let source_start = run_start + (offset + valid) * 3;
166                        result.push_str(&s[source_start..source_start + invalid * 3]);
167                        offset += valid + invalid;
168                    }
169                }
170            }
171            literal_start = i;
172        }
173        result.push_str(&s[literal_start..]);
174        result
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::utilities::UriKind;
182
183    fn absolute(s: &str) -> Uri {
184        Uri::absolute(s).unwrap()
185    }
186
187    #[test]
188    fn assembly_name_from_query_parsed() {
189        const KEY: &str = "assembly";
190        const VALUE: &str = "Ferro.Themes.Simple";
191
192        let uri = absolute(&format!("resm:Ferro.Themes.Simple.Accents.BaseLight.xaml?{KEY}={VALUE}"));
193        let name = UriExtensions::get_assembly_name_from_query(&uri);
194
195        assert_eq!(VALUE, name);
196    }
197
198    #[test]
199    fn assembly_name_from_empty_query_not_parsed() {
200        let uri = absolute("resm:Ferro.Themes.Simple.Accents.BaseLight.xaml");
201        let name = UriExtensions::get_assembly_name_from_query(&uri);
202
203        assert_eq!("", name);
204    }
205
206    // --- not from upstream ---
207
208    #[test]
209    fn schemes() {
210        let asset = absolute(&format!("{ASSET_SCHEME}://Lib/Assets/a.png"));
211        assert!(UriExtensions::is_asset(&asset));
212        assert!(!UriExtensions::is_resm(&asset));
213        assert!(!UriExtensions::is_absolute_resm(&asset));
214        let resm = absolute("resm:Lib.Assets.a.png?assembly=Lib");
215        assert!(UriExtensions::is_absolute_resm(&resm));
216        assert!(!UriExtensions::is_asset(&resm));
217        let relative = Uri::new("Assets/a.png", UriKind::Relative).unwrap();
218        assert!(!UriExtensions::is_absolute_resm(&relative));
219    }
220
221    #[test]
222    fn authority_and_paths() {
223        let asset = absolute(&format!("{ASSET_SCHEME}://Lib.Name/Assets/a%20b.png"));
224        assert_eq!("lib.name", UriExtensions::authority(&asset));
225        assert_eq!("/Assets/a b.png", UriExtensions::get_unescape_absolute_path(&asset));
226        assert_eq!(
227            format!("{ASSET_SCHEME}://lib.name/Assets/a b.png"),
228            UriExtensions::get_unescape_absolute_uri(&asset)
229        );
230        assert_eq!("", UriExtensions::authority(&absolute("resm:Lib.a.png")));
231    }
232
233    #[test]
234    fn ensure_absolute() {
235        let base = absolute(&format!("{ASSET_SCHEME}://lib/Views/Main.xaml"));
236        let relative = Uri::new("../Assets/a.png", UriKind::Relative).unwrap();
237        let result = UriExtensions::ensure_absolute(&relative, Some(&base)).unwrap();
238        assert_eq!(format!("{ASSET_SCHEME}://lib/Assets/a.png"), result.absolute_uri());
239        assert!(UriExtensions::ensure_absolute(&relative, None).is_err());
240        assert!(UriExtensions::ensure_absolute(&relative, Some(&relative)).is_err());
241        assert_eq!(base, UriExtensions::ensure_absolute(&base, None).unwrap());
242    }
243
244    #[test]
245    fn assembly_name_from_query() {
246        for (uri, expected) in [
247            ("resm:a.png?assembly=Lib", "Lib"),
248            ("resm:a.png?x=1&assembly=Lib.Name", "Lib.Name"),
249            ("resm:a.png?assembly=Lib&x=1", "Lib"),
250            ("resm:a.png?assembly=My%20Lib", "My Lib"),
251            ("resm:a.png?x=1", ""),
252            ("resm:a.png", ""),
253            ("resm:a.png?as", ""),
254            ("resm:a.png?assembly", ""),
255        ] {
256            assert_eq!(expected, UriExtensions::get_assembly_name_from_query(&absolute(uri)), "{uri}");
257        }
258    }
259
260    #[test]
261    fn unescape() {
262        assert_eq!("a b", UriExtensions::unescape_data_string("a%20b"));
263        assert_eq!("plain", UriExtensions::unescape_data_string("plain"));
264        assert_eq!("ż/ó", UriExtensions::unescape_data_string("%C5%BC/%C3%B3"));
265        assert_eq!("100%", UriExtensions::unescape_data_string("100%"));
266        assert_eq!("%zz%2", UriExtensions::unescape_data_string("%zz%2"));
267        assert_eq!("a%FFb", UriExtensions::unescape_data_string("a%FFb"));
268        assert_eq!("%C5 ", UriExtensions::unescape_data_string("%C5%20"));
269    }
270}