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#[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#[derive(Error, Display, Debug, PartialEq, Eq)]
80pub enum ParseAssetPathError {
81 #[display("Asset source must not contain a `#` character")]
83 InvalidSourceSyntax,
84 #[display("Asset label must not contain a `://` substring")]
86 InvalidLabelSyntax,
87 #[display("Asset source must be at least one character. Either specify the source before the '://' or remove the `://`")]
89 MissingSource,
90 #[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 pub fn parse(asset_path: &'a str) -> AssetPath<'a> {
108 Self::try_parse(asset_path).unwrap()
109 }
110
111 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 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 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 source_range.is_none() {
165 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 let Some(range) = label_range.clone() {
190 if range.start <= last_found_source_index {
192 return Err(ParseAssetPathError::InvalidLabelSyntax);
193 }
194 }
195 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 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 #[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 #[inline]
235 pub fn source(&self) -> &AssetSourceId {
236 &self.source
237 }
238
239 #[inline]
241 pub fn label(&self) -> Option<&str> {
242 self.label.as_deref()
243 }
244
245 #[inline]
247 pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
248 self.label.clone()
249 }
250
251 #[inline]
253 pub fn path(&self) -> &Path {
254 self.path.deref()
255 }
256
257 #[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 #[inline]
269 pub fn remove_label(&mut self) {
270 self.label = None;
271 }
272
273 #[inline]
275 pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
276 self.label.take()
277 }
278
279 #[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 #[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 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 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 #[inline]
334 pub fn clone_owned(&self) -> AssetPath<'static> {
335 self.clone().into_owned()
336 }
337
338 pub fn resolve(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
376 self.resolve_internal(path, false)
377 }
378
379 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 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 base_path.pop();
419 }
420
421 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 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 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 #[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 #[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
610pub(crate) fn normalize_path(path: &Path) -> PathBuf {
613 let mut result_path = PathBuf::new();
614 for elt in path.iter() {
615 if elt == "." {
616 } else if elt == ".." {
618 if !result_path.pop() {
619 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}