bevy_asset/
path.rs

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