Skip to content
Merged
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8c2cda9
Add AssetPath::resolve_path and resolve_embed_path methods
Eyad3skr Jan 7, 2026
828504a
Add AssetPath::resolve_path and resolve_embed_path methods
Eyad3skr Jan 7, 2026
2fa8817
fixing formatting with cargo fmt --all
Eyad3skr Jan 7, 2026
61098c1
removed blank space between docs and methods I created
Eyad3skr Jan 7, 2026
34bd56e
String variants renamed to *_str., modified namings in unit tests to…
Eyad3skr Jan 7, 2026
5373d9b
Fix resolve_embed calls to use resolve_embed_str
Eyad3skr Jan 7, 2026
25ef7c2
fixing docs, removed duplicates, made it minimal and to the point as …
Eyad3skr Jan 7, 2026
d099025
extended resolve_str and resolve_embed_str unit tests to include also…
Eyad3skr Jan 8, 2026
f28f047
explaining in migrations guides (assetpath-resilve-semantics.md) what…
Eyad3skr Jan 8, 2026
86b2ad7
fixing md linting error for ci in assetpath-resolve-semantics.md
Eyad3skr Jan 8, 2026
65934b1
fixing md linting error for ci in assetpath-resolve-semantics.md
Eyad3skr Jan 8, 2026
005a371
confusing explanation of migaration fixed and removed blank space
Eyad3skr Jan 8, 2026
bfeeb6d
linter is not linting
Eyad3skr Jan 8, 2026
b44f587
linter may lint this time
Eyad3skr Jan 8, 2026
4e107a0
docs: modify migration guide for AssetPath::resolve API change to pas…
Eyad3skr Jan 8, 2026
8343031
guidline title: '' to double-quotes
Eyad3skr Jan 8, 2026
6b0b8c6
fix markdown lint spacing
Eyad3skr Jan 8, 2026
5fe21a8
removed before and after
Eyad3skr Jan 8, 2026
7e69298
reduced title from H1 to H2
Eyad3skr Jan 8, 2026
4fa10f3
removed code block in md migration guideline
Eyad3skr Jan 8, 2026
1abc2f6
last but not least everyone
Eyad3skr Jan 8, 2026
ba14cda
adding a trailing newline
Eyad3skr Jan 8, 2026
ede5156
adding a trailing newline
Eyad3skr Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 246 additions & 32 deletions crates/bevy_asset/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,121 @@ impl<'a> AssetPath<'a> {
self.resolve_internal(path, true)
}

/// Resolves `path` relative to `self`, using the same rules as [`AssetPath::resolve`],
/// but without reparsing from a string.
Copy link
Contributor

@Shatur Shatur Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe change the description of resolve instead and mention that it parses the asset path from the string?

///
/// This is equivalent to `self.resolve(&path.to_string())` for all `AssetPath` values.
///
/// Notes / edge cases (kept consistent with `resolve`):
/// - If `path` formats as `#label` (default source, empty path, label set), this replaces
/// `self`'s label while keeping the base path.
/// - If `path` formats as a "full" path like `/foo/bar`, this is treated as rooted at the
/// asset source root (not the filesystem).
/// - If `path` includes an explicit source (`name://...`), it replaces the base source.
///
/// This method is additive and does not change the behavior of [`AssetPath::resolve`].
pub fn resolve_path(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
// Check if this is a "label-only" case: default source + empty path + label set
let is_label_only = matches!(path.source(), AssetSourceId::Default)
&& path.path().as_os_str().is_empty()
&& path.label().is_some();

if is_label_only {
// Label-only: replace label on base path
self.clone_owned()
.with_label(path.label().unwrap().to_owned())
} else {
// Determine if the input has an explicit (non-default) source
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to avoid duplicating the code in English. Use comments when the intent is not clear or when they add additional information. This one just duplicates what is already written.
Same for most of the comments.

Suggested change
// Determine if the input has an explicit (non-default) source

let explicit_source = match path.source() {
AssetSourceId::Default => None,
AssetSourceId::Name(name) => Some(name.as_ref()),
};

self.resolve_from_parts(
false, // replace=false for resolve (not resolve_embed)
explicit_source,
path.path(),
path.label(),
)
}
}

/// Resolves an embedded asset path relative to `self`, using the same rules as
/// [`AssetPath::resolve_embed`], but without reparsing from a string.
///
/// This is equivalent to `self.resolve_embed(&path.to_string())` for all `AssetPath` values.
///
/// Embedded resolution differs from [`AssetPath::resolve_path`] in that the "file portion" of
/// the base path is removed before concatenation (RFC 1808 behavior).
///
/// This method is additive and does not change the behavior of [`AssetPath::resolve_embed`].
pub fn resolve_embed_path(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
// Check if this is a "label-only" case: default source + empty path + label set
let is_label_only = matches!(path.source(), AssetSourceId::Default)
&& path.path().as_os_str().is_empty()
&& path.label().is_some();

if is_label_only {
// Label-only: replace label on base path
self.clone_owned()
.with_label(path.label().unwrap().to_owned())
} else {
// Determine if the input has an explicit (non-default) source
let explicit_source = match path.source() {
AssetSourceId::Default => None,
AssetSourceId::Name(name) => Some(name.as_ref()),
};

self.resolve_from_parts(
true, // replace=true for resolve_embed
explicit_source,
path.path(),
path.label(),
)
}
}

fn resolve_from_parts(
&self,
replace: bool,
source: Option<&str>,
rpath: &Path,
rlabel: Option<&str>,
) -> AssetPath<'static> {
let mut base_path = PathBuf::from(self.path());
if replace && !self.path.to_str().unwrap().ends_with('/') {
// No error if base is empty
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why (per RFC 1808) in the comment was removed?

base_path.pop();
}

// Strip off leading slash
let mut is_absolute = false;
let rpath = match rpath.strip_prefix("/") {
Ok(p) => {
is_absolute = true;
p
}
_ => rpath,
};

let mut result_path = if !is_absolute && source.is_none() {
base_path
} else {
PathBuf::new()
};
result_path.push(rpath);
result_path = normalize_path(result_path.as_path());

AssetPath {
source: match source {
Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
None => self.source.clone_owned(),
},
path: CowArc::Owned(result_path.into()),
label: rlabel.map(|l| CowArc::Owned(l.into())),
}
}

fn resolve_internal(
&self,
path: &str,
Expand All @@ -426,38 +541,7 @@ impl<'a> AssetPath<'a> {
Ok(self.clone_owned().with_label(label.to_owned()))
} else {
let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
let mut base_path = PathBuf::from(self.path());
if replace && !self.path.to_str().unwrap().ends_with('/') {
// No error if base is empty (per RFC 1808).
base_path.pop();
}

// Strip off leading slash
let mut is_absolute = false;
let rpath = match rpath.strip_prefix("/") {
Ok(p) => {
is_absolute = true;
p
}
_ => rpath,
};

let mut result_path = if !is_absolute && source.is_none() {
base_path
} else {
PathBuf::new()
};
result_path.push(rpath);
result_path = normalize_path(result_path.as_path());

Ok(AssetPath {
source: match source {
Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
None => self.source.clone_owned(),
},
path: CowArc::Owned(result_path.into()),
label: rlabel.map(|l| CowArc::Owned(l.into())),
})
Ok(self.resolve_from_parts(replace, source, rpath, rlabel))
}
}

Expand Down Expand Up @@ -1020,6 +1104,136 @@ mod tests {
);
}

#[test]
fn resolve_path_equivalence() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to just extend each test below. I.e. in addition to resolve_str, just add the regular resolve. This scales much better.

// Test that resolve_path(&AssetPath) is equivalent to resolve(&str)
let base = AssetPath::parse("a/b.gltf");

// Simple relative path
let rel = AssetPath::parse("c.bin");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// Relative with ./
let rel = AssetPath::parse("./c.bin");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// Relative with ../
let rel = AssetPath::parse("../c.bin");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// With label
let rel = AssetPath::parse("c.bin#Mesh");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// Label-only (special case)
let rel = AssetPath::parse("#NewLabel");
assert_eq!(base.resolve_path(&rel), base.resolve("#NewLabel").unwrap());

// Full path with leading /
let rel = AssetPath::parse("/c.bin");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// Full path with label
let rel = AssetPath::parse("/c.bin#Mesh");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// With explicit source
let rel = AssetPath::parse("remote://c.bin");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);

// With explicit source and label
let rel = AssetPath::parse("remote://c.bin#Mesh");
assert_eq!(
base.resolve_path(&rel),
base.resolve(&rel.to_string()).unwrap()
);
}

#[test]
fn resolve_embed_path_equivalence() {
// Test that resolve_embed_path(&AssetPath) is equivalent to resolve_embed(&str)
let base = AssetPath::parse("a/b.gltf");

// Simple relative path
let rel = AssetPath::parse("c.bin");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// Relative with ./
let rel = AssetPath::parse("./c.bin");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// Relative with ../
let rel = AssetPath::parse("../c.bin");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// With label
let rel = AssetPath::parse("c.bin#Mesh");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// Label-only (special case)
let rel = AssetPath::parse("#NewLabel");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed("#NewLabel").unwrap()
);

// Full path with leading /
let rel = AssetPath::parse("/c.bin");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// With explicit source
let rel = AssetPath::parse("remote://c.bin");
assert_eq!(
base.resolve_embed_path(&rel),
base.resolve_embed(&rel.to_string()).unwrap()
);

// Test the RFC 1808 "file portion removal" behavior
let base = AssetPath::parse("a/b");
let rel = AssetPath::parse("c");
// resolve_embed removes "b" before concatenating
assert_eq!(base.resolve_embed_path(&rel), AssetPath::parse("a/c"));

// Verify different from resolve
assert_ne!(base.resolve_path(&rel), base.resolve_embed_path(&rel));
}

#[test]
fn test_get_extension() {
let result = AssetPath::from("http://a.tar.gz#Foo");
Expand Down