Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/crates/bevy_asset/src/path.rs
9354 views
1
use crate::io::AssetSourceId;
2
use alloc::{
3
borrow::ToOwned,
4
string::{String, ToString},
5
};
6
use atomicow::CowArc;
7
use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize};
8
use core::{
9
fmt::{Debug, Display},
10
hash::Hash,
11
ops::Deref,
12
};
13
use serde::{de::Visitor, Deserialize, Serialize};
14
use std::path::{Path, PathBuf};
15
use 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)]
57
pub struct AssetPath<'a> {
58
source: AssetSourceId<'a>,
59
path: CowArc<'a, Path>,
60
label: Option<CowArc<'a, str>>,
61
}
62
63
impl<'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
69
impl<'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)]
84
pub 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
99
impl<'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 [`PathBuf`].
227
#[inline]
228
pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> {
229
AssetPath {
230
path: CowArc::Owned(path_buf.into()),
231
source: AssetSourceId::Default,
232
label: None,
233
}
234
}
235
236
/// Creates a new [`AssetPath`] from a [`Path`].
237
#[inline]
238
pub fn from_path(path: &'a Path) -> AssetPath<'a> {
239
AssetPath {
240
path: CowArc::Borrowed(path),
241
source: AssetSourceId::Default,
242
label: None,
243
}
244
}
245
246
/// Gets the "asset source", if one was defined. If none was defined, the default source
247
/// will be used.
248
#[inline]
249
pub fn source(&self) -> &AssetSourceId<'_> {
250
&self.source
251
}
252
253
/// Gets the "sub-asset label".
254
#[inline]
255
pub fn label(&self) -> Option<&str> {
256
self.label.as_deref()
257
}
258
259
/// Gets the "sub-asset label".
260
#[inline]
261
pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
262
self.label.clone()
263
}
264
265
/// Gets the path to the asset in the "virtual filesystem".
266
#[inline]
267
pub fn path(&self) -> &Path {
268
self.path.deref()
269
}
270
271
/// Gets the path to the asset in the "virtual filesystem" without a label (if a label is currently set).
272
#[inline]
273
pub fn without_label(&self) -> AssetPath<'_> {
274
Self {
275
source: self.source.clone(),
276
path: self.path.clone(),
277
label: None,
278
}
279
}
280
281
/// Removes a "sub-asset label" from this [`AssetPath`], if one was set.
282
#[inline]
283
pub fn remove_label(&mut self) {
284
self.label = None;
285
}
286
287
/// Takes the "sub-asset label" from this [`AssetPath`], if one was set.
288
#[inline]
289
pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
290
self.label.take()
291
}
292
293
/// Returns this asset path with the given label. This will replace the previous
294
/// label if it exists.
295
#[inline]
296
pub fn with_label(self, label: impl Into<CowArc<'a, str>>) -> AssetPath<'a> {
297
AssetPath {
298
source: self.source,
299
path: self.path,
300
label: Some(label.into()),
301
}
302
}
303
304
/// Returns this asset path with the given asset source. This will replace the previous asset
305
/// source if it exists.
306
#[inline]
307
pub fn with_source(self, source: impl Into<AssetSourceId<'a>>) -> AssetPath<'a> {
308
AssetPath {
309
source: source.into(),
310
path: self.path,
311
label: self.label,
312
}
313
}
314
315
/// Returns an [`AssetPath`] for the parent folder of this path, if there is a parent folder in the path.
316
pub fn parent(&self) -> Option<AssetPath<'a>> {
317
let path = match &self.path {
318
CowArc::Borrowed(path) => CowArc::Borrowed(path.parent()?),
319
CowArc::Static(path) => CowArc::Static(path.parent()?),
320
CowArc::Owned(path) => path.parent()?.to_path_buf().into(),
321
};
322
Some(AssetPath {
323
source: self.source.clone(),
324
label: None,
325
path,
326
})
327
}
328
329
/// Converts this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
330
/// If internally a value is a static reference, the static reference will be used unchanged.
331
/// If internally a value is an "owned [`Arc`]", it will remain unchanged.
332
///
333
/// [`Arc`]: alloc::sync::Arc
334
pub fn into_owned(self) -> AssetPath<'static> {
335
AssetPath {
336
source: self.source.into_owned(),
337
path: self.path.into_owned(),
338
label: self.label.map(CowArc::into_owned),
339
}
340
}
341
342
/// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
343
/// If internally a value is a static reference, the static reference will be used unchanged.
344
/// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned.
345
///
346
/// [`Arc`]: alloc::sync::Arc
347
#[inline]
348
pub fn clone_owned(&self) -> AssetPath<'static> {
349
self.clone().into_owned()
350
}
351
352
/// Resolves an [`AssetPath`] relative to `self`.
353
///
354
/// Semantics:
355
/// - If `path` is label-only (default source, empty path, label set), replace `self`'s label.
356
/// - If `path` begins with `/`, treat it as rooted at the asset-source root (not the filesystem).
357
/// - If `path` has an explicit source (`name://...`), it replaces the base source.
358
/// - Relative segments are concatenated and normalized (`.`/`..` removal), preserving extra `..` if the base underflows.
359
///
360
/// ```
361
/// # use bevy_asset::AssetPath;
362
/// let base = AssetPath::parse("a/b");
363
/// assert_eq!(base.resolve(&AssetPath::parse("c")), AssetPath::parse("a/b/c"));
364
/// assert_eq!(base.resolve(&AssetPath::parse("./c")), AssetPath::parse("a/b/c"));
365
/// assert_eq!(base.resolve(&AssetPath::parse("../c")), AssetPath::parse("a/c"));
366
/// assert_eq!(base.resolve(&AssetPath::parse("c.png")), AssetPath::parse("a/b/c.png"));
367
/// assert_eq!(base.resolve(&AssetPath::parse("/c")), AssetPath::parse("c"));
368
/// assert_eq!(AssetPath::parse("a/b.png").resolve(&AssetPath::parse("#c")), AssetPath::parse("a/b.png#c"));
369
/// assert_eq!(AssetPath::parse("a/b.png#c").resolve(&AssetPath::parse("#d")), AssetPath::parse("a/b.png#d"));
370
/// ```
371
///
372
/// See also [`AssetPath::resolve_str`].
373
pub fn resolve(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
374
let is_label_only = matches!(path.source(), AssetSourceId::Default)
375
&& path.path().as_os_str().is_empty()
376
&& path.label().is_some();
377
378
if is_label_only {
379
self.clone_owned()
380
.with_label(path.label().unwrap().to_owned())
381
} else {
382
let explicit_source = match path.source() {
383
AssetSourceId::Default => None,
384
AssetSourceId::Name(name) => Some(name.as_ref()),
385
};
386
387
self.resolve_from_parts(false, explicit_source, path.path(), path.label())
388
}
389
}
390
391
/// Resolves an [`AssetPath`] relative to `self` using embedded (RFC 1808) semantics.
392
///
393
/// Semantics:
394
/// - Remove the "file portion" of the base before concatenation (unless the base ends with `/`).
395
/// - Otherwise identical to [`AssetPath::resolve`].
396
///
397
/// ```
398
/// # use bevy_asset::AssetPath;
399
/// let base = AssetPath::parse("a/b");
400
/// assert_eq!(base.resolve_embed(&AssetPath::parse("c")), AssetPath::parse("a/c"));
401
/// assert_eq!(base.resolve_embed(&AssetPath::parse("./c")), AssetPath::parse("a/c"));
402
/// assert_eq!(base.resolve_embed(&AssetPath::parse("../c")), AssetPath::parse("c"));
403
/// assert_eq!(base.resolve_embed(&AssetPath::parse("c.png")), AssetPath::parse("a/c.png"));
404
/// assert_eq!(base.resolve_embed(&AssetPath::parse("/c")), AssetPath::parse("c"));
405
/// assert_eq!(AssetPath::parse("a/b.png").resolve_embed(&AssetPath::parse("#c")), AssetPath::parse("a/b.png#c"));
406
/// assert_eq!(AssetPath::parse("a/b.png#c").resolve_embed(&AssetPath::parse("#d")), AssetPath::parse("a/b.png#d"));
407
/// ```
408
///
409
/// See also [`AssetPath::resolve_embed_str`].
410
pub fn resolve_embed(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
411
let is_label_only = matches!(path.source(), AssetSourceId::Default)
412
&& path.path().as_os_str().is_empty()
413
&& path.label().is_some();
414
415
if is_label_only {
416
self.clone_owned()
417
.with_label(path.label().unwrap().to_owned())
418
} else {
419
let explicit_source = match path.source() {
420
AssetSourceId::Default => None,
421
AssetSourceId::Name(name) => Some(name.as_ref()),
422
};
423
424
self.resolve_from_parts(true, explicit_source, path.path(), path.label())
425
}
426
}
427
428
/// Parses `path` as an [`AssetPath`], then resolves it relative to `self`.
429
///
430
/// Returns an error if parsing fails.
431
///
432
/// For more details, see [`AssetPath::resolve`].
433
pub fn resolve_str(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
434
self.resolve_internal(path, false)
435
}
436
437
/// Parses `path` as an [`AssetPath`], then resolves it relative to `self` using embedded
438
/// (RFC 1808) semantics.
439
///
440
/// Returns an error if parsing fails.
441
///
442
/// For more details, see [`AssetPath::resolve_embed`].
443
pub fn resolve_embed_str(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
444
self.resolve_internal(path, true)
445
}
446
447
fn resolve_from_parts(
448
&self,
449
replace: bool,
450
source: Option<&str>,
451
rpath: &Path,
452
rlabel: Option<&str>,
453
) -> AssetPath<'static> {
454
let mut base_path = PathBuf::from(self.path());
455
if replace && !self.path.to_str().unwrap().ends_with('/') {
456
// No error if base is empty (per RFC 1808).
457
base_path.pop();
458
}
459
460
// Strip off leading slash
461
let mut is_absolute = false;
462
let rpath = match rpath.strip_prefix("/") {
463
Ok(p) => {
464
is_absolute = true;
465
p
466
}
467
_ => rpath,
468
};
469
470
let mut result_path = if !is_absolute && source.is_none() {
471
base_path
472
} else {
473
PathBuf::new()
474
};
475
result_path.push(rpath);
476
result_path = normalize_path(result_path.as_path());
477
478
AssetPath {
479
source: match source {
480
Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
481
None => self.source.clone_owned(),
482
},
483
path: CowArc::Owned(result_path.into()),
484
label: rlabel.map(|l| CowArc::Owned(l.into())),
485
}
486
}
487
488
fn resolve_internal(
489
&self,
490
path: &str,
491
replace: bool,
492
) -> Result<AssetPath<'static>, ParseAssetPathError> {
493
if let Some(label) = path.strip_prefix('#') {
494
// It's a label only
495
Ok(self.clone_owned().with_label(label.to_owned()))
496
} else {
497
let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
498
Ok(self.resolve_from_parts(replace, source, rpath, rlabel))
499
}
500
}
501
502
/// Returns the full extension (including multiple '.' values).
503
/// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
504
///
505
/// Also strips out anything following a `?` to handle query parameters in URIs
506
pub fn get_full_extension(&self) -> Option<String> {
507
let file_name = self.path().file_name()?.to_str()?;
508
let index = file_name.find('.')?;
509
let mut extension = file_name[index + 1..].to_owned();
510
511
// Strip off any query parameters
512
let query = extension.find('?');
513
if let Some(offset) = query {
514
extension.truncate(offset);
515
}
516
517
Some(extension)
518
}
519
520
pub(crate) fn iter_secondary_extensions(full_extension: &str) -> impl Iterator<Item = &str> {
521
full_extension.char_indices().filter_map(|(i, c)| {
522
if c == '.' {
523
Some(&full_extension[i + 1..])
524
} else {
525
None
526
}
527
})
528
}
529
530
/// Returns `true` if this [`AssetPath`] points to a file that is
531
/// outside of its [`AssetSource`](crate::io::AssetSource) folder.
532
///
533
/// ## Example
534
/// ```
535
/// # use bevy_asset::AssetPath;
536
/// // Inside the default AssetSource.
537
/// let path = AssetPath::parse("thingy.png");
538
/// assert!( ! path.is_unapproved());
539
/// let path = AssetPath::parse("gui/thingy.png");
540
/// assert!( ! path.is_unapproved());
541
///
542
/// // Inside a different AssetSource.
543
/// let path = AssetPath::parse("embedded://thingy.png");
544
/// assert!( ! path.is_unapproved());
545
///
546
/// // Exits the `AssetSource`s directory.
547
/// let path = AssetPath::parse("../thingy.png");
548
/// assert!(path.is_unapproved());
549
/// let path = AssetPath::parse("folder/../../thingy.png");
550
/// assert!(path.is_unapproved());
551
///
552
/// // This references the linux root directory.
553
/// let path = AssetPath::parse("/home/thingy.png");
554
/// assert!(path.is_unapproved());
555
/// ```
556
pub fn is_unapproved(&self) -> bool {
557
use std::path::Component;
558
let mut simplified = PathBuf::new();
559
for component in self.path.components() {
560
match component {
561
Component::Prefix(_) | Component::RootDir => return true,
562
Component::CurDir => {}
563
Component::ParentDir => {
564
if !simplified.pop() {
565
return true;
566
}
567
}
568
Component::Normal(os_str) => simplified.push(os_str),
569
}
570
}
571
572
false
573
}
574
}
575
576
// This is only implemented for static lifetimes to ensure `Path::clone` does not allocate
577
// by ensuring that this is stored as a `CowArc::Static`.
578
// Please read https://github.com/bevyengine/bevy/issues/19844 before changing this!
579
impl From<&'static str> for AssetPath<'static> {
580
#[inline]
581
fn from(asset_path: &'static str) -> Self {
582
let (source, path, label) = Self::parse_internal(asset_path).unwrap();
583
AssetPath {
584
source: source.into(),
585
path: CowArc::Static(path),
586
label: label.map(CowArc::Static),
587
}
588
}
589
}
590
591
impl<'a> From<&'a String> for AssetPath<'a> {
592
#[inline]
593
fn from(asset_path: &'a String) -> Self {
594
AssetPath::parse(asset_path.as_str())
595
}
596
}
597
598
impl From<String> for AssetPath<'static> {
599
#[inline]
600
fn from(asset_path: String) -> Self {
601
AssetPath::parse(asset_path.as_str()).into_owned()
602
}
603
}
604
605
impl From<&'static Path> for AssetPath<'static> {
606
#[inline]
607
fn from(path: &'static Path) -> Self {
608
Self {
609
source: AssetSourceId::Default,
610
path: CowArc::Static(path),
611
label: None,
612
}
613
}
614
}
615
616
impl From<PathBuf> for AssetPath<'static> {
617
#[inline]
618
fn from(path: PathBuf) -> Self {
619
Self {
620
source: AssetSourceId::Default,
621
path: path.into(),
622
label: None,
623
}
624
}
625
}
626
627
impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
628
fn from(value: &'a AssetPath<'b>) -> Self {
629
value.clone()
630
}
631
}
632
633
impl<'a> From<AssetPath<'a>> for PathBuf {
634
fn from(value: AssetPath<'a>) -> Self {
635
value.path().to_path_buf()
636
}
637
}
638
639
impl<'a> Serialize for AssetPath<'a> {
640
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
641
where
642
S: serde::Serializer,
643
{
644
self.to_string().serialize(serializer)
645
}
646
}
647
648
impl<'de> Deserialize<'de> for AssetPath<'static> {
649
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
650
where
651
D: serde::Deserializer<'de>,
652
{
653
deserializer.deserialize_string(AssetPathVisitor)
654
}
655
}
656
657
struct AssetPathVisitor;
658
659
impl<'de> Visitor<'de> for AssetPathVisitor {
660
type Value = AssetPath<'static>;
661
662
fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
663
formatter.write_str("string AssetPath")
664
}
665
666
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
667
where
668
E: serde::de::Error,
669
{
670
Ok(AssetPath::parse(v).into_owned())
671
}
672
673
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
674
where
675
E: serde::de::Error,
676
{
677
Ok(AssetPath::from(v))
678
}
679
}
680
681
/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
682
/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
683
pub(crate) fn normalize_path(path: &Path) -> PathBuf {
684
let mut result_path = PathBuf::new();
685
for elt in path.iter() {
686
if elt == "." {
687
// Skip
688
} else if elt == ".." {
689
// Note: If the result_path ends in `..`, Path::file_name returns None, so we'll end up
690
// preserving it.
691
if result_path.file_name().is_some() {
692
// This assert is just a sanity check - we already know the path has a file_name, so
693
// we know there is something to pop.
694
assert!(result_path.pop());
695
} else {
696
// Preserve ".." if insufficient matches (per RFC 1808).
697
result_path.push(elt);
698
}
699
} else {
700
result_path.push(elt);
701
}
702
}
703
result_path
704
}
705
706
#[cfg(test)]
707
mod tests {
708
use crate::AssetPath;
709
use alloc::string::ToString;
710
use std::path::Path;
711
712
#[test]
713
fn parse_asset_path() {
714
let result = AssetPath::parse_internal("a/b.test");
715
assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
716
717
let result = AssetPath::parse_internal("http://a/b.test");
718
assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
719
720
let result = AssetPath::parse_internal("http://a/b.test#Foo");
721
assert_eq!(
722
result,
723
Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
724
);
725
726
let result = AssetPath::parse_internal("localhost:80/b.test");
727
assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
728
729
let result = AssetPath::parse_internal("http://localhost:80/b.test");
730
assert_eq!(
731
result,
732
Ok((Some("http"), Path::new("localhost:80/b.test"), None))
733
);
734
735
let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
736
assert_eq!(
737
result,
738
Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
739
);
740
741
let result = AssetPath::parse_internal("#insource://a/b.test");
742
assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
743
744
let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
745
assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
746
747
let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
748
assert!(
749
result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
750
|| result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
751
);
752
753
let result = AssetPath::parse_internal("http://");
754
assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
755
756
let result = AssetPath::parse_internal("://x");
757
assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
758
759
let result = AssetPath::parse_internal("a/b.test#");
760
assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
761
}
762
763
#[test]
764
fn test_parent() {
765
// Parent consumes path segments, returns None when insufficient
766
let result = AssetPath::from("a/b.test");
767
assert_eq!(result.parent(), Some(AssetPath::from("a")));
768
assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
769
assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
770
771
// Parent cannot consume asset source
772
let result = AssetPath::from("http://a");
773
assert_eq!(result.parent(), Some(AssetPath::from("http://")));
774
assert_eq!(result.parent().unwrap().parent(), None);
775
776
// Parent consumes labels
777
let result = AssetPath::from("http://a#Foo");
778
assert_eq!(result.parent(), Some(AssetPath::from("http://")));
779
}
780
781
#[test]
782
fn test_with_source() {
783
let result = AssetPath::from("http://a#Foo");
784
assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
785
}
786
787
#[test]
788
fn test_without_label() {
789
let result = AssetPath::from("http://a#Foo");
790
assert_eq!(result.without_label(), AssetPath::from("http://a"));
791
}
792
793
#[test]
794
fn test_resolve_full() {
795
// A "full" path should ignore the base path.
796
let base = AssetPath::from("alice/bob#carol");
797
assert_eq!(
798
base.resolve_str("/joe/next").unwrap(),
799
AssetPath::from("joe/next")
800
);
801
assert_eq!(
802
base.resolve(&AssetPath::parse("/joe/next")),
803
AssetPath::from("joe/next")
804
);
805
assert_eq!(
806
base.resolve_embed_str("/joe/next").unwrap(),
807
AssetPath::from("joe/next")
808
);
809
assert_eq!(
810
base.resolve_embed(&AssetPath::parse("/joe/next")),
811
AssetPath::from("joe/next")
812
);
813
assert_eq!(
814
base.resolve_str("/joe/next#dave").unwrap(),
815
AssetPath::from("joe/next#dave")
816
);
817
assert_eq!(
818
base.resolve(&AssetPath::parse("/joe/next#dave")),
819
AssetPath::from("joe/next#dave")
820
);
821
assert_eq!(
822
base.resolve_embed_str("/joe/next#dave").unwrap(),
823
AssetPath::from("joe/next#dave")
824
);
825
assert_eq!(
826
base.resolve_embed(&AssetPath::parse("/joe/next#dave")),
827
AssetPath::from("joe/next#dave")
828
);
829
}
830
831
#[test]
832
fn test_resolve_implicit_relative() {
833
// A path with no initial directory separator should be considered relative.
834
let base = AssetPath::from("alice/bob#carol");
835
assert_eq!(
836
base.resolve_str("joe/next").unwrap(),
837
AssetPath::from("alice/bob/joe/next")
838
);
839
assert_eq!(
840
base.resolve(&AssetPath::parse("joe/next")),
841
AssetPath::from("alice/bob/joe/next")
842
);
843
assert_eq!(
844
base.resolve_embed_str("joe/next").unwrap(),
845
AssetPath::from("alice/joe/next")
846
);
847
assert_eq!(
848
base.resolve_embed(&AssetPath::parse("joe/next")),
849
AssetPath::from("alice/joe/next")
850
);
851
assert_eq!(
852
base.resolve_str("joe/next#dave").unwrap(),
853
AssetPath::from("alice/bob/joe/next#dave")
854
);
855
assert_eq!(
856
base.resolve(&AssetPath::parse("joe/next#dave")),
857
AssetPath::from("alice/bob/joe/next#dave")
858
);
859
assert_eq!(
860
base.resolve_embed_str("joe/next#dave").unwrap(),
861
AssetPath::from("alice/joe/next#dave")
862
);
863
assert_eq!(
864
base.resolve_embed(&AssetPath::parse("joe/next#dave")),
865
AssetPath::from("alice/joe/next#dave")
866
);
867
}
868
869
#[test]
870
fn test_resolve_explicit_relative() {
871
// A path which begins with "./" or "../" is treated as relative
872
let base = AssetPath::from("alice/bob#carol");
873
assert_eq!(
874
base.resolve_str("./martin#dave").unwrap(),
875
AssetPath::from("alice/bob/martin#dave")
876
);
877
assert_eq!(
878
base.resolve(&AssetPath::parse("./martin#dave")),
879
AssetPath::from("alice/bob/martin#dave")
880
);
881
assert_eq!(
882
base.resolve_embed_str("./martin#dave").unwrap(),
883
AssetPath::from("alice/martin#dave")
884
);
885
assert_eq!(
886
base.resolve_embed(&AssetPath::parse("./martin#dave")),
887
AssetPath::from("alice/martin#dave")
888
);
889
assert_eq!(
890
base.resolve_str("../martin#dave").unwrap(),
891
AssetPath::from("alice/martin#dave")
892
);
893
assert_eq!(
894
base.resolve(&AssetPath::parse("../martin#dave")),
895
AssetPath::from("alice/martin#dave")
896
);
897
assert_eq!(
898
base.resolve_embed_str("../martin#dave").unwrap(),
899
AssetPath::from("martin#dave")
900
);
901
assert_eq!(
902
base.resolve_embed(&AssetPath::parse("../martin#dave")),
903
AssetPath::from("martin#dave")
904
);
905
}
906
907
#[test]
908
fn test_resolve_trailing_slash() {
909
// A path which begins with "./" or "../" is treated as relative
910
let base = AssetPath::from("alice/bob/");
911
assert_eq!(
912
base.resolve_str("./martin#dave").unwrap(),
913
AssetPath::from("alice/bob/martin#dave")
914
);
915
assert_eq!(
916
base.resolve(&AssetPath::parse("./martin#dave")),
917
AssetPath::from("alice/bob/martin#dave")
918
);
919
assert_eq!(
920
base.resolve_embed_str("./martin#dave").unwrap(),
921
AssetPath::from("alice/bob/martin#dave")
922
);
923
assert_eq!(
924
base.resolve_embed(&AssetPath::parse("./martin#dave")),
925
AssetPath::from("alice/bob/martin#dave")
926
);
927
assert_eq!(
928
base.resolve_str("../martin#dave").unwrap(),
929
AssetPath::from("alice/martin#dave")
930
);
931
assert_eq!(
932
base.resolve(&AssetPath::parse("../martin#dave")),
933
AssetPath::from("alice/martin#dave")
934
);
935
assert_eq!(
936
base.resolve_embed_str("../martin#dave").unwrap(),
937
AssetPath::from("alice/martin#dave")
938
);
939
assert_eq!(
940
base.resolve_embed(&AssetPath::parse("../martin#dave")),
941
AssetPath::from("alice/martin#dave")
942
);
943
}
944
945
#[test]
946
fn test_resolve_canonicalize() {
947
// Test that ".." and "." are removed after concatenation.
948
let base = AssetPath::from("alice/bob#carol");
949
assert_eq!(
950
base.resolve_str("./martin/stephan/..#dave").unwrap(),
951
AssetPath::from("alice/bob/martin#dave")
952
);
953
assert_eq!(
954
base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
955
AssetPath::from("alice/bob/martin#dave")
956
);
957
assert_eq!(
958
base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
959
AssetPath::from("alice/martin#dave")
960
);
961
assert_eq!(
962
base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
963
AssetPath::from("alice/martin#dave")
964
);
965
assert_eq!(
966
base.resolve_str("../martin/.#dave").unwrap(),
967
AssetPath::from("alice/martin#dave")
968
);
969
assert_eq!(
970
base.resolve(&AssetPath::parse("../martin/.#dave")),
971
AssetPath::from("alice/martin#dave")
972
);
973
assert_eq!(
974
base.resolve_embed_str("../martin/.#dave").unwrap(),
975
AssetPath::from("martin#dave")
976
);
977
assert_eq!(
978
base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
979
AssetPath::from("martin#dave")
980
);
981
assert_eq!(
982
base.resolve_str("/martin/stephan/..#dave").unwrap(),
983
AssetPath::from("martin#dave")
984
);
985
assert_eq!(
986
base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
987
AssetPath::from("martin#dave")
988
);
989
assert_eq!(
990
base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
991
AssetPath::from("martin#dave")
992
);
993
assert_eq!(
994
base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
995
AssetPath::from("martin#dave")
996
);
997
}
998
999
#[test]
1000
fn test_resolve_canonicalize_base() {
1001
// Test that ".." and "." are removed after concatenation even from the base path.
1002
let base = AssetPath::from("alice/../bob#carol");
1003
assert_eq!(
1004
base.resolve_str("./martin/stephan/..#dave").unwrap(),
1005
AssetPath::from("bob/martin#dave")
1006
);
1007
assert_eq!(
1008
base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1009
AssetPath::from("bob/martin#dave")
1010
);
1011
assert_eq!(
1012
base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1013
AssetPath::from("martin#dave")
1014
);
1015
assert_eq!(
1016
base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1017
AssetPath::from("martin#dave")
1018
);
1019
assert_eq!(
1020
base.resolve_str("../martin/.#dave").unwrap(),
1021
AssetPath::from("martin#dave")
1022
);
1023
assert_eq!(
1024
base.resolve(&AssetPath::parse("../martin/.#dave")),
1025
AssetPath::from("martin#dave")
1026
);
1027
assert_eq!(
1028
base.resolve_embed_str("../martin/.#dave").unwrap(),
1029
AssetPath::from("../martin#dave")
1030
);
1031
assert_eq!(
1032
base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1033
AssetPath::from("../martin#dave")
1034
);
1035
assert_eq!(
1036
base.resolve_str("/martin/stephan/..#dave").unwrap(),
1037
AssetPath::from("martin#dave")
1038
);
1039
assert_eq!(
1040
base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1041
AssetPath::from("martin#dave")
1042
);
1043
assert_eq!(
1044
base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1045
AssetPath::from("martin#dave")
1046
);
1047
assert_eq!(
1048
base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1049
AssetPath::from("martin#dave")
1050
);
1051
}
1052
1053
#[test]
1054
fn test_resolve_canonicalize_with_source() {
1055
// Test that ".." and "." are removed after concatenation.
1056
let base = AssetPath::from("source://alice/bob#carol");
1057
assert_eq!(
1058
base.resolve_str("./martin/stephan/..#dave").unwrap(),
1059
AssetPath::from("source://alice/bob/martin#dave")
1060
);
1061
assert_eq!(
1062
base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1063
AssetPath::from("source://alice/bob/martin#dave")
1064
);
1065
assert_eq!(
1066
base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1067
AssetPath::from("source://alice/martin#dave")
1068
);
1069
assert_eq!(
1070
base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1071
AssetPath::from("source://alice/martin#dave")
1072
);
1073
assert_eq!(
1074
base.resolve_str("../martin/.#dave").unwrap(),
1075
AssetPath::from("source://alice/martin#dave")
1076
);
1077
assert_eq!(
1078
base.resolve(&AssetPath::parse("../martin/.#dave")),
1079
AssetPath::from("source://alice/martin#dave")
1080
);
1081
assert_eq!(
1082
base.resolve_embed_str("../martin/.#dave").unwrap(),
1083
AssetPath::from("source://martin#dave")
1084
);
1085
assert_eq!(
1086
base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1087
AssetPath::from("source://martin#dave")
1088
);
1089
assert_eq!(
1090
base.resolve_str("/martin/stephan/..#dave").unwrap(),
1091
AssetPath::from("source://martin#dave")
1092
);
1093
assert_eq!(
1094
base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1095
AssetPath::from("source://martin#dave")
1096
);
1097
assert_eq!(
1098
base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1099
AssetPath::from("source://martin#dave")
1100
);
1101
assert_eq!(
1102
base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1103
AssetPath::from("source://martin#dave")
1104
);
1105
}
1106
1107
#[test]
1108
fn test_resolve_absolute() {
1109
// Paths beginning with '/' replace the base path
1110
let base = AssetPath::from("alice/bob#carol");
1111
assert_eq!(
1112
base.resolve_str("/martin/stephan").unwrap(),
1113
AssetPath::from("martin/stephan")
1114
);
1115
assert_eq!(
1116
base.resolve(&AssetPath::parse("/martin/stephan")),
1117
AssetPath::from("martin/stephan")
1118
);
1119
assert_eq!(
1120
base.resolve_embed_str("/martin/stephan").unwrap(),
1121
AssetPath::from("martin/stephan")
1122
);
1123
assert_eq!(
1124
base.resolve_embed(&AssetPath::parse("/martin/stephan")),
1125
AssetPath::from("martin/stephan")
1126
);
1127
assert_eq!(
1128
base.resolve_str("/martin/stephan#dave").unwrap(),
1129
AssetPath::from("martin/stephan/#dave")
1130
);
1131
assert_eq!(
1132
base.resolve(&AssetPath::parse("/martin/stephan#dave")),
1133
AssetPath::from("martin/stephan/#dave")
1134
);
1135
assert_eq!(
1136
base.resolve_embed_str("/martin/stephan#dave").unwrap(),
1137
AssetPath::from("martin/stephan/#dave")
1138
);
1139
assert_eq!(
1140
base.resolve_embed(&AssetPath::parse("/martin/stephan#dave")),
1141
AssetPath::from("martin/stephan/#dave")
1142
);
1143
}
1144
1145
#[test]
1146
fn test_resolve_asset_source() {
1147
// Paths beginning with 'source://' replace the base path
1148
let base = AssetPath::from("alice/bob#carol");
1149
assert_eq!(
1150
base.resolve_str("source://martin/stephan").unwrap(),
1151
AssetPath::from("source://martin/stephan")
1152
);
1153
assert_eq!(
1154
base.resolve(&AssetPath::parse("source://martin/stephan")),
1155
AssetPath::from("source://martin/stephan")
1156
);
1157
assert_eq!(
1158
base.resolve_embed_str("source://martin/stephan").unwrap(),
1159
AssetPath::from("source://martin/stephan")
1160
);
1161
assert_eq!(
1162
base.resolve_embed(&AssetPath::parse("source://martin/stephan")),
1163
AssetPath::from("source://martin/stephan")
1164
);
1165
assert_eq!(
1166
base.resolve_str("source://martin/stephan#dave").unwrap(),
1167
AssetPath::from("source://martin/stephan/#dave")
1168
);
1169
assert_eq!(
1170
base.resolve(&AssetPath::parse("source://martin/stephan#dave")),
1171
AssetPath::from("source://martin/stephan/#dave")
1172
);
1173
assert_eq!(
1174
base.resolve_embed_str("source://martin/stephan#dave")
1175
.unwrap(),
1176
AssetPath::from("source://martin/stephan/#dave")
1177
);
1178
assert_eq!(
1179
base.resolve_embed(&AssetPath::parse("source://martin/stephan#dave")),
1180
AssetPath::from("source://martin/stephan/#dave")
1181
);
1182
}
1183
1184
#[test]
1185
fn test_resolve_label() {
1186
// A relative path with only a label should replace the label portion
1187
let base = AssetPath::from("alice/bob#carol");
1188
assert_eq!(
1189
base.resolve_str("#dave").unwrap(),
1190
AssetPath::from("alice/bob#dave")
1191
);
1192
assert_eq!(
1193
base.resolve(&AssetPath::parse("#dave")),
1194
AssetPath::from("alice/bob#dave")
1195
);
1196
assert_eq!(
1197
base.resolve_embed_str("#dave").unwrap(),
1198
AssetPath::from("alice/bob#dave")
1199
);
1200
assert_eq!(
1201
base.resolve_embed(&AssetPath::parse("#dave")),
1202
AssetPath::from("alice/bob#dave")
1203
);
1204
}
1205
1206
#[test]
1207
fn test_resolve_insufficient_elements() {
1208
// Ensure that ".." segments are preserved if there are insufficient elements to remove them.
1209
let base = AssetPath::from("alice/bob#carol");
1210
assert_eq!(
1211
base.resolve_str("../../joe/next").unwrap(),
1212
AssetPath::from("joe/next")
1213
);
1214
assert_eq!(
1215
base.resolve(&AssetPath::parse("../../joe/next")),
1216
AssetPath::from("joe/next")
1217
);
1218
assert_eq!(
1219
base.resolve_embed_str("../../joe/next").unwrap(),
1220
AssetPath::from("../joe/next")
1221
);
1222
assert_eq!(
1223
base.resolve_embed(&AssetPath::parse("../../joe/next")),
1224
AssetPath::from("../joe/next")
1225
);
1226
}
1227
1228
#[test]
1229
fn resolve_embed_relative_to_external_path() {
1230
let base = AssetPath::from("../../a/b.gltf");
1231
assert_eq!(
1232
base.resolve_embed_str("c.bin").unwrap(),
1233
AssetPath::from("../../a/c.bin")
1234
);
1235
assert_eq!(
1236
base.resolve_embed(&AssetPath::parse("c.bin")),
1237
AssetPath::from("../../a/c.bin")
1238
);
1239
}
1240
1241
#[test]
1242
fn resolve_relative_to_external_path() {
1243
let base = AssetPath::from("../../a/b.gltf");
1244
assert_eq!(
1245
base.resolve_str("c.bin").unwrap(),
1246
AssetPath::from("../../a/b.gltf/c.bin")
1247
);
1248
assert_eq!(
1249
base.resolve(&AssetPath::parse("c.bin")),
1250
AssetPath::from("../../a/b.gltf/c.bin")
1251
);
1252
}
1253
1254
#[test]
1255
fn test_get_extension() {
1256
let result = AssetPath::from("http://a.tar.gz#Foo");
1257
assert_eq!(result.get_full_extension(), Some("tar.gz".to_string()));
1258
1259
let result = AssetPath::from("http://a#Foo");
1260
assert_eq!(result.get_full_extension(), None);
1261
1262
let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1263
assert_eq!(result.get_full_extension(), Some("tar.bz2".to_string()));
1264
1265
let result = AssetPath::from("asset.Custom");
1266
assert_eq!(result.get_full_extension(), Some("Custom".to_string()));
1267
}
1268
}
1269
1270