bevy_asset/
path.rs

1use crate::io::AssetSourceId;
2use atomicow::CowArc;
3use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize};
4use core::{
5    fmt::{Debug, Display},
6    hash::Hash,
7    ops::Deref,
8};
9use derive_more::derive::{Display, Error};
10use serde::{de::Visitor, Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13/// Represents a path to an asset in a "virtual filesystem".
14///
15/// Asset paths consist of three main parts:
16/// * [`AssetPath::source`]: The name of the [`AssetSource`](crate::io::AssetSource) to load the asset from.
17///     This is optional. If one is not set the default source will be used (which is the `assets` folder by default).
18/// * [`AssetPath::path`]: The "virtual filesystem path" pointing to an asset source file.
19/// * [`AssetPath::label`]: An optional "named sub asset". When assets are loaded, they are
20///     allowed to load "sub assets" of any type, which are identified by a named "label".
21///
22/// Asset paths are generally constructed (and visualized) as strings:
23///
24/// ```no_run
25/// # use bevy_asset::{Asset, AssetServer, Handle};
26/// # use bevy_reflect::TypePath;
27/// #
28/// # #[derive(Asset, TypePath, Default)]
29/// # struct Mesh;
30/// #
31/// # #[derive(Asset, TypePath, Default)]
32/// # struct Scene;
33/// #
34/// # let asset_server: AssetServer = panic!();
35/// // This loads the `my_scene.scn` base asset from the default asset source.
36/// let scene: Handle<Scene> = asset_server.load("my_scene.scn");
37///
38/// // This loads the `PlayerMesh` labeled asset from the `my_scene.scn` base asset in the default asset source.
39/// let mesh: Handle<Mesh> = asset_server.load("my_scene.scn#PlayerMesh");
40///
41/// // This loads the `my_scene.scn` base asset from a custom 'remote' asset source.
42/// let scene: Handle<Scene> = asset_server.load("remote://my_scene.scn");
43/// ```
44///
45/// [`AssetPath`] implements [`From`] for `&'static str`, `&'static Path`, and `&'a String`,
46/// which allows us to optimize the static cases.
47/// This means that the common case of `asset_server.load("my_scene.scn")` when it creates and
48/// clones internal owned [`AssetPaths`](AssetPath).
49/// This also means that you should use [`AssetPath::parse`] in cases where `&str` is the explicit type.
50#[derive(Eq, PartialEq, Hash, Clone, Default, Reflect)]
51#[reflect(opaque)]
52#[reflect(Debug, PartialEq, Hash, Serialize, Deserialize)]
53pub struct AssetPath<'a> {
54    source: AssetSourceId<'a>,
55    path: CowArc<'a, Path>,
56    label: Option<CowArc<'a, str>>,
57}
58
59impl<'a> Debug for AssetPath<'a> {
60    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
61        Display::fmt(self, f)
62    }
63}
64
65impl<'a> Display for AssetPath<'a> {
66    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
67        if let AssetSourceId::Name(name) = self.source() {
68            write!(f, "{name}://")?;
69        }
70        write!(f, "{}", self.path.display())?;
71        if let Some(label) = &self.label {
72            write!(f, "#{label}")?;
73        }
74        Ok(())
75    }
76}
77
78/// An error that occurs when parsing a string type to create an [`AssetPath`] fails, such as during [`AssetPath::parse`].
79#[derive(Error, Display, Debug, PartialEq, Eq)]
80pub enum ParseAssetPathError {
81    /// Error that occurs when the [`AssetPath::source`] section of a path string contains the [`AssetPath::label`] delimiter `#`. E.g. `bad#source://file.test`.
82    #[display("Asset source must not contain a `#` character")]
83    InvalidSourceSyntax,
84    /// Error that occurs when the [`AssetPath::label`] section of a path string contains the [`AssetPath::source`] delimiter `://`. E.g. `source://file.test#bad://label`.
85    #[display("Asset label must not contain a `://` substring")]
86    InvalidLabelSyntax,
87    /// Error that occurs when a path string has an [`AssetPath::source`] delimiter `://` with no characters preceding it. E.g. `://file.test`.
88    #[display("Asset source must be at least one character. Either specify the source before the '://' or remove the `://`")]
89    MissingSource,
90    /// Error that occurs when a path string has an [`AssetPath::label`] delimiter `#` with no characters succeeding it. E.g. `file.test#`
91    #[display("Asset label must be at least one character. Either specify the label after the '#' or remove the '#'")]
92    MissingLabel,
93}
94
95impl<'a> AssetPath<'a> {
96    /// Creates a new [`AssetPath`] from a string in the asset path format:
97    /// * An asset at the root: `"scene.gltf"`
98    /// * An asset nested in some folders: `"some/path/scene.gltf"`
99    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
100    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
101    ///
102    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
103    /// and reference counting for [`AssetPath::into_owned`].
104    ///
105    /// # Panics
106    /// Panics if the asset path is in an invalid format. Use [`AssetPath::try_parse`] for a fallible variant
107    pub fn parse(asset_path: &'a str) -> AssetPath<'a> {
108        Self::try_parse(asset_path).unwrap()
109    }
110
111    /// Creates a new [`AssetPath`] from a string in the asset path format:
112    /// * An asset at the root: `"scene.gltf"`
113    /// * An asset nested in some folders: `"some/path/scene.gltf"`
114    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
115    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
116    ///
117    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
118    /// and reference counting for [`AssetPath::into_owned`].
119    ///
120    /// This will return a [`ParseAssetPathError`] if `asset_path` is in an invalid format.
121    pub fn try_parse(asset_path: &'a str) -> Result<AssetPath<'a>, ParseAssetPathError> {
122        let (source, path, label) = Self::parse_internal(asset_path)?;
123        Ok(Self {
124            source: match source {
125                Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)),
126                None => AssetSourceId::Default,
127            },
128            path: CowArc::Borrowed(path),
129            label: label.map(CowArc::Borrowed),
130        })
131    }
132
133    // Attempts to Parse a &str into an `AssetPath`'s `AssetPath::source`, `AssetPath::path`, and `AssetPath::label` components.
134    fn parse_internal(
135        asset_path: &str,
136    ) -> Result<(Option<&str>, &Path, Option<&str>), ParseAssetPathError> {
137        let chars = asset_path.char_indices();
138        let mut source_range = None;
139        let mut path_range = 0..asset_path.len();
140        let mut label_range = None;
141
142        // Loop through the characters of the passed in &str to accomplish the following:
143        // 1. Search for the first instance of the `://` substring. If the `://` substring is found,
144        //  store the range of indices representing everything before the `://` substring as the `source_range`.
145        // 2. Search for the last instance of the `#` character. If the `#` character is found,
146        //  store the range of indices representing everything after the `#` character as the `label_range`
147        // 3. Set the `path_range` to be everything in between the `source_range` and `label_range`,
148        //  excluding the `://` substring and `#` character.
149        // 4. Verify that there are no `#` characters in the `AssetPath::source` and no `://` substrings in the `AssetPath::label`
150        let mut source_delimiter_chars_matched = 0;
151        let mut last_found_source_index = 0;
152        for (index, char) in chars {
153            match char {
154                ':' => {
155                    source_delimiter_chars_matched = 1;
156                }
157                '/' => {
158                    match source_delimiter_chars_matched {
159                        1 => {
160                            source_delimiter_chars_matched = 2;
161                        }
162                        2 => {
163                            // If we haven't found our first `AssetPath::source` yet, check to make sure it is valid and then store it.
164                            if source_range.is_none() {
165                                // If the `AssetPath::source` contains a `#` character, it is invalid.
166                                if label_range.is_some() {
167                                    return Err(ParseAssetPathError::InvalidSourceSyntax);
168                                }
169                                source_range = Some(0..index - 2);
170                                path_range.start = index + 1;
171                            }
172                            last_found_source_index = index - 2;
173                            source_delimiter_chars_matched = 0;
174                        }
175                        _ => {}
176                    }
177                }
178                '#' => {
179                    path_range.end = index;
180                    label_range = Some(index + 1..asset_path.len());
181                    source_delimiter_chars_matched = 0;
182                }
183                _ => {
184                    source_delimiter_chars_matched = 0;
185                }
186            }
187        }
188        // If we found an `AssetPath::label`
189        if let Some(range) = label_range.clone() {
190            // If the `AssetPath::label` contained a `://` substring, it is invalid.
191            if range.start <= last_found_source_index {
192                return Err(ParseAssetPathError::InvalidLabelSyntax);
193            }
194        }
195        // Try to parse the range of indices that represents the `AssetPath::source` portion of the `AssetPath` to make sure it is not empty.
196        // This would be the case if the input &str was something like `://some/file.test`
197        let source = match source_range {
198            Some(source_range) => {
199                if source_range.is_empty() {
200                    return Err(ParseAssetPathError::MissingSource);
201                }
202                Some(&asset_path[source_range])
203            }
204            None => None,
205        };
206        // Try to parse the range of indices that represents the `AssetPath::label` portion of the `AssetPath` to make sure it is not empty.
207        // This would be the case if the input &str was something like `some/file.test#`.
208        let label = match label_range {
209            Some(label_range) => {
210                if label_range.is_empty() {
211                    return Err(ParseAssetPathError::MissingLabel);
212                }
213                Some(&asset_path[label_range])
214            }
215            None => None,
216        };
217
218        let path = Path::new(&asset_path[path_range]);
219        Ok((source, path, label))
220    }
221
222    /// Creates a new [`AssetPath`] from a [`Path`].
223    #[inline]
224    pub fn from_path(path: &'a Path) -> AssetPath<'a> {
225        AssetPath {
226            path: CowArc::Borrowed(path),
227            source: AssetSourceId::Default,
228            label: None,
229        }
230    }
231
232    /// Gets the "asset source", if one was defined. If none was defined, the default source
233    /// will be used.
234    #[inline]
235    pub fn source(&self) -> &AssetSourceId {
236        &self.source
237    }
238
239    /// Gets the "sub-asset label".
240    #[inline]
241    pub fn label(&self) -> Option<&str> {
242        self.label.as_deref()
243    }
244
245    /// Gets the "sub-asset label".
246    #[inline]
247    pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
248        self.label.clone()
249    }
250
251    /// Gets the path to the asset in the "virtual filesystem".
252    #[inline]
253    pub fn path(&self) -> &Path {
254        self.path.deref()
255    }
256
257    /// Gets the path to the asset in the "virtual filesystem" without a label (if a label is currently set).
258    #[inline]
259    pub fn without_label(&self) -> AssetPath<'_> {
260        Self {
261            source: self.source.clone(),
262            path: self.path.clone(),
263            label: None,
264        }
265    }
266
267    /// Removes a "sub-asset label" from this [`AssetPath`], if one was set.
268    #[inline]
269    pub fn remove_label(&mut self) {
270        self.label = None;
271    }
272
273    /// Takes the "sub-asset label" from this [`AssetPath`], if one was set.
274    #[inline]
275    pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
276        self.label.take()
277    }
278
279    /// Returns this asset path with the given label. This will replace the previous
280    /// label if it exists.
281    #[inline]
282    pub fn with_label(self, label: impl Into<CowArc<'a, str>>) -> AssetPath<'a> {
283        AssetPath {
284            source: self.source,
285            path: self.path,
286            label: Some(label.into()),
287        }
288    }
289
290    /// Returns this asset path with the given asset source. This will replace the previous asset
291    /// source if it exists.
292    #[inline]
293    pub fn with_source(self, source: impl Into<AssetSourceId<'a>>) -> AssetPath<'a> {
294        AssetPath {
295            source: source.into(),
296            path: self.path,
297            label: self.label,
298        }
299    }
300
301    /// Returns an [`AssetPath`] for the parent folder of this path, if there is a parent folder in the path.
302    pub fn parent(&self) -> Option<AssetPath<'a>> {
303        let path = match &self.path {
304            CowArc::Borrowed(path) => CowArc::Borrowed(path.parent()?),
305            CowArc::Static(path) => CowArc::Static(path.parent()?),
306            CowArc::Owned(path) => path.parent()?.to_path_buf().into(),
307        };
308        Some(AssetPath {
309            source: self.source.clone(),
310            label: None,
311            path,
312        })
313    }
314
315    /// Converts this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
316    /// If internally a value is a static reference, the static reference will be used unchanged.
317    /// If internally a value is an "owned [`Arc`]", it will remain unchanged.
318    ///
319    /// [`Arc`]: std::sync::Arc
320    pub fn into_owned(self) -> AssetPath<'static> {
321        AssetPath {
322            source: self.source.into_owned(),
323            path: self.path.into_owned(),
324            label: self.label.map(CowArc::into_owned),
325        }
326    }
327
328    /// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
329    /// If internally a value is a static reference, the static reference will be used unchanged.
330    /// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned.
331    ///
332    /// [`Arc`]: std::sync::Arc
333    #[inline]
334    pub fn clone_owned(&self) -> AssetPath<'static> {
335        self.clone().into_owned()
336    }
337
338    /// Resolves a relative asset path via concatenation. The result will be an `AssetPath` which
339    /// is resolved relative to this "base" path.
340    ///
341    /// ```
342    /// # use bevy_asset::AssetPath;
343    /// assert_eq!(AssetPath::parse("a/b").resolve("c"), Ok(AssetPath::parse("a/b/c")));
344    /// assert_eq!(AssetPath::parse("a/b").resolve("./c"), Ok(AssetPath::parse("a/b/c")));
345    /// assert_eq!(AssetPath::parse("a/b").resolve("../c"), Ok(AssetPath::parse("a/c")));
346    /// assert_eq!(AssetPath::parse("a/b").resolve("c.png"), Ok(AssetPath::parse("a/b/c.png")));
347    /// assert_eq!(AssetPath::parse("a/b").resolve("/c"), Ok(AssetPath::parse("c")));
348    /// assert_eq!(AssetPath::parse("a/b.png").resolve("#c"), Ok(AssetPath::parse("a/b.png#c")));
349    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve("#d"), Ok(AssetPath::parse("a/b.png#d")));
350    /// ```
351    ///
352    /// There are several cases:
353    ///
354    /// If the `path` argument begins with `#`, then it is considered an asset label, in which case
355    /// the result is the base path with the label portion replaced.
356    ///
357    /// If the path argument begins with '/', then it is considered a 'full' path, in which
358    /// case the result is a new `AssetPath` consisting of the base path asset source
359    /// (if there is one) with the path and label portions of the relative path. Note that a 'full'
360    /// asset path is still relative to the asset source root, and not necessarily an absolute
361    /// filesystem path.
362    ///
363    /// If the `path` argument begins with an asset source (ex: `http://`) then the entire base
364    /// path is replaced - the result is the source, path and label (if any) of the `path`
365    /// argument.
366    ///
367    /// Otherwise, the `path` argument is considered a relative path. The result is concatenated
368    /// using the following algorithm:
369    ///
370    /// * The base path and the `path` argument are concatenated.
371    /// * Path elements consisting of "/." or "&lt;name&gt;/.." are removed.
372    ///
373    /// If there are insufficient segments in the base path to match the ".." segments,
374    /// then any left-over ".." segments are left as-is.
375    pub fn resolve(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
376        self.resolve_internal(path, false)
377    }
378
379    /// Resolves an embedded asset path via concatenation. The result will be an `AssetPath` which
380    /// is resolved relative to this path. This is similar in operation to `resolve`, except that
381    /// the 'file' portion of the base path (that is, any characters after the last '/')
382    /// is removed before concatenation, in accordance with the behavior specified in
383    /// IETF RFC 1808 "Relative URIs".
384    ///
385    /// The reason for this behavior is that embedded URIs which start with "./" or "../" are
386    /// relative to the *directory* containing the asset, not the asset file. This is consistent
387    /// with the behavior of URIs in `JavaScript`, CSS, HTML and other web file formats. The
388    /// primary use case for this method is resolving relative paths embedded within asset files,
389    /// which are relative to the asset in which they are contained.
390    ///
391    /// ```
392    /// # use bevy_asset::AssetPath;
393    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c"), Ok(AssetPath::parse("a/c")));
394    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("./c"), Ok(AssetPath::parse("a/c")));
395    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("../c"), Ok(AssetPath::parse("c")));
396    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c.png"), Ok(AssetPath::parse("a/c.png")));
397    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("/c"), Ok(AssetPath::parse("c")));
398    /// assert_eq!(AssetPath::parse("a/b.png").resolve_embed("#c"), Ok(AssetPath::parse("a/b.png#c")));
399    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve_embed("#d"), Ok(AssetPath::parse("a/b.png#d")));
400    /// ```
401    pub fn resolve_embed(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
402        self.resolve_internal(path, true)
403    }
404
405    fn resolve_internal(
406        &self,
407        path: &str,
408        replace: bool,
409    ) -> Result<AssetPath<'static>, ParseAssetPathError> {
410        if let Some(label) = path.strip_prefix('#') {
411            // It's a label only
412            Ok(self.clone_owned().with_label(label.to_owned()))
413        } else {
414            let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
415            let mut base_path = PathBuf::from(self.path());
416            if replace && !self.path.to_str().unwrap().ends_with('/') {
417                // No error if base is empty (per RFC 1808).
418                base_path.pop();
419            }
420
421            // Strip off leading slash
422            let mut is_absolute = false;
423            let rpath = match rpath.strip_prefix("/") {
424                Ok(p) => {
425                    is_absolute = true;
426                    p
427                }
428                _ => rpath,
429            };
430
431            let mut result_path = if !is_absolute && source.is_none() {
432                base_path
433            } else {
434                PathBuf::new()
435            };
436            result_path.push(rpath);
437            result_path = normalize_path(result_path.as_path());
438
439            Ok(AssetPath {
440                source: match source {
441                    Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
442                    None => self.source.clone_owned(),
443                },
444                path: CowArc::Owned(result_path.into()),
445                label: rlabel.map(|l| CowArc::Owned(l.into())),
446            })
447        }
448    }
449
450    /// Returns the full extension (including multiple '.' values).
451    /// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
452    ///
453    /// Also strips out anything following a `?` to handle query parameters in URIs
454    pub fn get_full_extension(&self) -> Option<String> {
455        let file_name = self.path().file_name()?.to_str()?;
456        let index = file_name.find('.')?;
457        let mut extension = file_name[index + 1..].to_lowercase();
458
459        // Strip off any query parameters
460        let query = extension.find('?');
461        if let Some(offset) = query {
462            extension.truncate(offset);
463        }
464
465        Some(extension)
466    }
467
468    pub(crate) fn iter_secondary_extensions(full_extension: &str) -> impl Iterator<Item = &str> {
469        full_extension.chars().enumerate().filter_map(|(i, c)| {
470            if c == '.' {
471                Some(&full_extension[i + 1..])
472            } else {
473                None
474            }
475        })
476    }
477}
478
479impl AssetPath<'static> {
480    /// Indicates this [`AssetPath`] should have a static lifetime.
481    #[inline]
482    pub fn as_static(self) -> Self {
483        let Self {
484            source,
485            path,
486            label,
487        } = self;
488
489        let source = source.as_static();
490        let path = path.as_static();
491        let label = label.map(CowArc::as_static);
492
493        Self {
494            source,
495            path,
496            label,
497        }
498    }
499
500    /// Constructs an [`AssetPath`] with a static lifetime.
501    #[inline]
502    pub fn from_static(value: impl Into<Self>) -> Self {
503        value.into().as_static()
504    }
505}
506
507impl<'a> From<&'a str> for AssetPath<'a> {
508    #[inline]
509    fn from(asset_path: &'a str) -> Self {
510        let (source, path, label) = Self::parse_internal(asset_path).unwrap();
511
512        AssetPath {
513            source: source.into(),
514            path: CowArc::Borrowed(path),
515            label: label.map(CowArc::Borrowed),
516        }
517    }
518}
519
520impl<'a> From<&'a String> for AssetPath<'a> {
521    #[inline]
522    fn from(asset_path: &'a String) -> Self {
523        AssetPath::parse(asset_path.as_str())
524    }
525}
526
527impl From<String> for AssetPath<'static> {
528    #[inline]
529    fn from(asset_path: String) -> Self {
530        AssetPath::parse(asset_path.as_str()).into_owned()
531    }
532}
533
534impl<'a> From<&'a Path> for AssetPath<'a> {
535    #[inline]
536    fn from(path: &'a Path) -> Self {
537        Self {
538            source: AssetSourceId::Default,
539            path: CowArc::Borrowed(path),
540            label: None,
541        }
542    }
543}
544
545impl From<PathBuf> for AssetPath<'static> {
546    #[inline]
547    fn from(path: PathBuf) -> Self {
548        Self {
549            source: AssetSourceId::Default,
550            path: path.into(),
551            label: None,
552        }
553    }
554}
555
556impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
557    fn from(value: &'a AssetPath<'b>) -> Self {
558        value.clone()
559    }
560}
561
562impl<'a> From<AssetPath<'a>> for PathBuf {
563    fn from(value: AssetPath<'a>) -> Self {
564        value.path().to_path_buf()
565    }
566}
567
568impl<'a> Serialize for AssetPath<'a> {
569    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
570    where
571        S: serde::Serializer,
572    {
573        self.to_string().serialize(serializer)
574    }
575}
576
577impl<'de> Deserialize<'de> for AssetPath<'static> {
578    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
579    where
580        D: serde::Deserializer<'de>,
581    {
582        deserializer.deserialize_string(AssetPathVisitor)
583    }
584}
585
586struct AssetPathVisitor;
587
588impl<'de> Visitor<'de> for AssetPathVisitor {
589    type Value = AssetPath<'static>;
590
591    fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
592        formatter.write_str("string AssetPath")
593    }
594
595    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
596    where
597        E: serde::de::Error,
598    {
599        Ok(AssetPath::parse(v).into_owned())
600    }
601
602    fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
603    where
604        E: serde::de::Error,
605    {
606        Ok(AssetPath::from(v))
607    }
608}
609
610/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
611/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
612pub(crate) fn normalize_path(path: &Path) -> PathBuf {
613    let mut result_path = PathBuf::new();
614    for elt in path.iter() {
615        if elt == "." {
616            // Skip
617        } else if elt == ".." {
618            if !result_path.pop() {
619                // Preserve ".." if insufficient matches (per RFC 1808).
620                result_path.push(elt);
621            }
622        } else {
623            result_path.push(elt);
624        }
625    }
626    result_path
627}
628
629#[cfg(test)]
630mod tests {
631    use crate::AssetPath;
632    use std::path::Path;
633
634    #[test]
635    fn parse_asset_path() {
636        let result = AssetPath::parse_internal("a/b.test");
637        assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
638
639        let result = AssetPath::parse_internal("http://a/b.test");
640        assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
641
642        let result = AssetPath::parse_internal("http://a/b.test#Foo");
643        assert_eq!(
644            result,
645            Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
646        );
647
648        let result = AssetPath::parse_internal("localhost:80/b.test");
649        assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
650
651        let result = AssetPath::parse_internal("http://localhost:80/b.test");
652        assert_eq!(
653            result,
654            Ok((Some("http"), Path::new("localhost:80/b.test"), None))
655        );
656
657        let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
658        assert_eq!(
659            result,
660            Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
661        );
662
663        let result = AssetPath::parse_internal("#insource://a/b.test");
664        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
665
666        let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
667        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
668
669        let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
670        assert!(
671            result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
672                || result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
673        );
674
675        let result = AssetPath::parse_internal("http://");
676        assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
677
678        let result = AssetPath::parse_internal("://x");
679        assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
680
681        let result = AssetPath::parse_internal("a/b.test#");
682        assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
683    }
684
685    #[test]
686    fn test_parent() {
687        // Parent consumes path segments, returns None when insufficient
688        let result = AssetPath::from("a/b.test");
689        assert_eq!(result.parent(), Some(AssetPath::from("a")));
690        assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
691        assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
692
693        // Parent cannot consume asset source
694        let result = AssetPath::from("http://a");
695        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
696        assert_eq!(result.parent().unwrap().parent(), None);
697
698        // Parent consumes labels
699        let result = AssetPath::from("http://a#Foo");
700        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
701    }
702
703    #[test]
704    fn test_with_source() {
705        let result = AssetPath::from("http://a#Foo");
706        assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
707    }
708
709    #[test]
710    fn test_without_label() {
711        let result = AssetPath::from("http://a#Foo");
712        assert_eq!(result.without_label(), AssetPath::from("http://a"));
713    }
714
715    #[test]
716    fn test_resolve_full() {
717        // A "full" path should ignore the base path.
718        let base = AssetPath::from("alice/bob#carol");
719        assert_eq!(
720            base.resolve("/joe/next").unwrap(),
721            AssetPath::from("joe/next")
722        );
723        assert_eq!(
724            base.resolve_embed("/joe/next").unwrap(),
725            AssetPath::from("joe/next")
726        );
727        assert_eq!(
728            base.resolve("/joe/next#dave").unwrap(),
729            AssetPath::from("joe/next#dave")
730        );
731        assert_eq!(
732            base.resolve_embed("/joe/next#dave").unwrap(),
733            AssetPath::from("joe/next#dave")
734        );
735    }
736
737    #[test]
738    fn test_resolve_implicit_relative() {
739        // A path with no initial directory separator should be considered relative.
740        let base = AssetPath::from("alice/bob#carol");
741        assert_eq!(
742            base.resolve("joe/next").unwrap(),
743            AssetPath::from("alice/bob/joe/next")
744        );
745        assert_eq!(
746            base.resolve_embed("joe/next").unwrap(),
747            AssetPath::from("alice/joe/next")
748        );
749        assert_eq!(
750            base.resolve("joe/next#dave").unwrap(),
751            AssetPath::from("alice/bob/joe/next#dave")
752        );
753        assert_eq!(
754            base.resolve_embed("joe/next#dave").unwrap(),
755            AssetPath::from("alice/joe/next#dave")
756        );
757    }
758
759    #[test]
760    fn test_resolve_explicit_relative() {
761        // A path which begins with "./" or "../" is treated as relative
762        let base = AssetPath::from("alice/bob#carol");
763        assert_eq!(
764            base.resolve("./martin#dave").unwrap(),
765            AssetPath::from("alice/bob/martin#dave")
766        );
767        assert_eq!(
768            base.resolve_embed("./martin#dave").unwrap(),
769            AssetPath::from("alice/martin#dave")
770        );
771        assert_eq!(
772            base.resolve("../martin#dave").unwrap(),
773            AssetPath::from("alice/martin#dave")
774        );
775        assert_eq!(
776            base.resolve_embed("../martin#dave").unwrap(),
777            AssetPath::from("martin#dave")
778        );
779    }
780
781    #[test]
782    fn test_resolve_trailing_slash() {
783        // A path which begins with "./" or "../" is treated as relative
784        let base = AssetPath::from("alice/bob/");
785        assert_eq!(
786            base.resolve("./martin#dave").unwrap(),
787            AssetPath::from("alice/bob/martin#dave")
788        );
789        assert_eq!(
790            base.resolve_embed("./martin#dave").unwrap(),
791            AssetPath::from("alice/bob/martin#dave")
792        );
793        assert_eq!(
794            base.resolve("../martin#dave").unwrap(),
795            AssetPath::from("alice/martin#dave")
796        );
797        assert_eq!(
798            base.resolve_embed("../martin#dave").unwrap(),
799            AssetPath::from("alice/martin#dave")
800        );
801    }
802
803    #[test]
804    fn test_resolve_canonicalize() {
805        // Test that ".." and "." are removed after concatenation.
806        let base = AssetPath::from("alice/bob#carol");
807        assert_eq!(
808            base.resolve("./martin/stephan/..#dave").unwrap(),
809            AssetPath::from("alice/bob/martin#dave")
810        );
811        assert_eq!(
812            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
813            AssetPath::from("alice/martin#dave")
814        );
815        assert_eq!(
816            base.resolve("../martin/.#dave").unwrap(),
817            AssetPath::from("alice/martin#dave")
818        );
819        assert_eq!(
820            base.resolve_embed("../martin/.#dave").unwrap(),
821            AssetPath::from("martin#dave")
822        );
823        assert_eq!(
824            base.resolve("/martin/stephan/..#dave").unwrap(),
825            AssetPath::from("martin#dave")
826        );
827        assert_eq!(
828            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
829            AssetPath::from("martin#dave")
830        );
831    }
832
833    #[test]
834    fn test_resolve_canonicalize_base() {
835        // Test that ".." and "." are removed after concatenation even from the base path.
836        let base = AssetPath::from("alice/../bob#carol");
837        assert_eq!(
838            base.resolve("./martin/stephan/..#dave").unwrap(),
839            AssetPath::from("bob/martin#dave")
840        );
841        assert_eq!(
842            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
843            AssetPath::from("martin#dave")
844        );
845        assert_eq!(
846            base.resolve("../martin/.#dave").unwrap(),
847            AssetPath::from("martin#dave")
848        );
849        assert_eq!(
850            base.resolve_embed("../martin/.#dave").unwrap(),
851            AssetPath::from("../martin#dave")
852        );
853        assert_eq!(
854            base.resolve("/martin/stephan/..#dave").unwrap(),
855            AssetPath::from("martin#dave")
856        );
857        assert_eq!(
858            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
859            AssetPath::from("martin#dave")
860        );
861    }
862
863    #[test]
864    fn test_resolve_canonicalize_with_source() {
865        // Test that ".." and "." are removed after concatenation.
866        let base = AssetPath::from("source://alice/bob#carol");
867        assert_eq!(
868            base.resolve("./martin/stephan/..#dave").unwrap(),
869            AssetPath::from("source://alice/bob/martin#dave")
870        );
871        assert_eq!(
872            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
873            AssetPath::from("source://alice/martin#dave")
874        );
875        assert_eq!(
876            base.resolve("../martin/.#dave").unwrap(),
877            AssetPath::from("source://alice/martin#dave")
878        );
879        assert_eq!(
880            base.resolve_embed("../martin/.#dave").unwrap(),
881            AssetPath::from("source://martin#dave")
882        );
883        assert_eq!(
884            base.resolve("/martin/stephan/..#dave").unwrap(),
885            AssetPath::from("source://martin#dave")
886        );
887        assert_eq!(
888            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
889            AssetPath::from("source://martin#dave")
890        );
891    }
892
893    #[test]
894    fn test_resolve_absolute() {
895        // Paths beginning with '/' replace the base path
896        let base = AssetPath::from("alice/bob#carol");
897        assert_eq!(
898            base.resolve("/martin/stephan").unwrap(),
899            AssetPath::from("martin/stephan")
900        );
901        assert_eq!(
902            base.resolve_embed("/martin/stephan").unwrap(),
903            AssetPath::from("martin/stephan")
904        );
905        assert_eq!(
906            base.resolve("/martin/stephan#dave").unwrap(),
907            AssetPath::from("martin/stephan/#dave")
908        );
909        assert_eq!(
910            base.resolve_embed("/martin/stephan#dave").unwrap(),
911            AssetPath::from("martin/stephan/#dave")
912        );
913    }
914
915    #[test]
916    fn test_resolve_asset_source() {
917        // Paths beginning with 'source://' replace the base path
918        let base = AssetPath::from("alice/bob#carol");
919        assert_eq!(
920            base.resolve("source://martin/stephan").unwrap(),
921            AssetPath::from("source://martin/stephan")
922        );
923        assert_eq!(
924            base.resolve_embed("source://martin/stephan").unwrap(),
925            AssetPath::from("source://martin/stephan")
926        );
927        assert_eq!(
928            base.resolve("source://martin/stephan#dave").unwrap(),
929            AssetPath::from("source://martin/stephan/#dave")
930        );
931        assert_eq!(
932            base.resolve_embed("source://martin/stephan#dave").unwrap(),
933            AssetPath::from("source://martin/stephan/#dave")
934        );
935    }
936
937    #[test]
938    fn test_resolve_label() {
939        // A relative path with only a label should replace the label portion
940        let base = AssetPath::from("alice/bob#carol");
941        assert_eq!(
942            base.resolve("#dave").unwrap(),
943            AssetPath::from("alice/bob#dave")
944        );
945        assert_eq!(
946            base.resolve_embed("#dave").unwrap(),
947            AssetPath::from("alice/bob#dave")
948        );
949    }
950
951    #[test]
952    fn test_resolve_insufficient_elements() {
953        // Ensure that ".." segments are preserved if there are insufficient elements to remove them.
954        let base = AssetPath::from("alice/bob#carol");
955        assert_eq!(
956            base.resolve("../../joe/next").unwrap(),
957            AssetPath::from("joe/next")
958        );
959        assert_eq!(
960            base.resolve_embed("../../joe/next").unwrap(),
961            AssetPath::from("../joe/next")
962        );
963    }
964
965    #[test]
966    fn test_get_extension() {
967        let result = AssetPath::from("http://a.tar.gz#Foo");
968        assert_eq!(result.get_full_extension(), Some("tar.gz".to_string()));
969
970        let result = AssetPath::from("http://a#Foo");
971        assert_eq!(result.get_full_extension(), None);
972
973        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
974        assert_eq!(result.get_full_extension(), Some("tar.bz2".to_string()));
975    }
976}