Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding AssetPath::resolve() method. #9528

Merged
merged 10 commits into from
Oct 26, 2023
368 changes: 368 additions & 0 deletions crates/bevy_asset/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,124 @@ impl<'a> AssetPath<'a> {
self.clone().into_owned()
}

/// Resolves a relative asset path via concatenation. The result will be an `AssetPath` which
cart marked this conversation as resolved.
Show resolved Hide resolved
/// is resolved relative to this "base" path. There are several cases:
///
/// If the `path` argument begins with `#`, then it is considered an asset label, in which case
/// the result is the base path with the label portion replaced.
///
/// If the path argument begins with '/', then it is considered an 'full' path, in which
/// case the result the result is a new `AssetPath` consisting of the base path asset source
/// (if any) with the path and label portions of the relative path. Note that a 'full'
/// asset path is still relative to the asset source root, and not necessarily an absolute
/// filesystem path.
///
/// If the `path` argument begins with an asset source (ex: `http://`) then the entire base
/// path is replaced - the result is the source, path and label (if any) of the `path`
/// argument.
///
/// Otherwise, the `path` argument is considered a relative path. The result is concatenated
/// using the following algorithm:
///
/// * The base path and the `path` argument are concatenated.
/// * Path elements consisting of "/." or "&lt;name&gt;/.." are removed.
///
/// If there are insufficient segments in the base path to match the ".." segments,
/// then any left-over ".." segments are left as-is.
pub fn resolve<'b>(&'a self, path: &'b str) -> Result<AssetPath<'a>, ParseAssetPathError> {
self.resolve_internal(path, false)
}

/// Resolves an embedded asset path via concatenation. The result will be an `AssetPath` which
/// is resolved relative to this path. This is similar in operation to `resolve`, except that
/// the the 'file' portion of the base path (that is, any characters after the last '/')
/// is removed before concatenation, in accordance with the behavior specified in
/// IETF RFC 1808 "Relative URIs".
///
/// The reason for this behavior is that embedded URIs which start with "./" or "../" are
/// relative to the *directory* containing the asset, not the asset file. This is consistent
/// with the behavior of URIs in `JavaScript`, CSS, HTML and other web file formats. The
/// primary use case for this method is resolving relative paths embedded within asset files,
/// which are relative to the asset in which they are contained.
///
/// So for example, the path `"x/y/z#foo"` combined with a relative path of `"./a#bar"`
/// yields `"x/y/a#bar"`.
///
/// Paths beginning with '/' or '#' are handled exactly the same as with `resolve()`.
pub fn resolve_embed<'b>(
&'a self,
path: &'b str,
) -> Result<AssetPath<'a>, ParseAssetPathError> {
self.resolve_internal(path, true)
}

fn resolve_internal<'b>(
cart marked this conversation as resolved.
Show resolved Hide resolved
&'a self,
path: &'b str,
replace: bool,
) -> Result<AssetPath<'a>, ParseAssetPathError> {
if let Some(label) = path.strip_prefix('#') {
// It's a label only
Ok(self.clone().into_owned().with_label(label.to_owned()))
cart marked this conversation as resolved.
Show resolved Hide resolved
} 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 = PathBuf::new();
if !is_absolute && source.is_none() {
for elt in base_path.iter() {
if elt == "." {
// Skip
} else if elt == ".." {
if !result_path.pop() {
// Preserve ".." if insufficient matches (per RFC 1808).
result_path.push(elt);
}
} else {
result_path.push(elt);
}
}
}

let rel_path = PathBuf::from(rpath);
cart marked this conversation as resolved.
Show resolved Hide resolved
for elt in rel_path.iter() {
if elt == "." {
// Skip
} else if elt == ".." {
if !result_path.pop() {
// Preserve ".." if insufficient matches (per RFC 1808).
result_path.push(elt);
}
} else {
result_path.push(elt);
}
}

Ok(AssetPath {
source: match source {
Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
None => self.source.to_owned(),
},
path: CowArc::Owned(result_path.as_path().to_owned().into()),
cart marked this conversation as resolved.
Show resolved Hide resolved
label: rlabel.map(|l| CowArc::Owned(l.into())),
})
}
}

/// Returns the full extension (including multiple '.' values).
/// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
pub fn get_full_extension(&self) -> Option<String> {
Expand Down Expand Up @@ -574,4 +692,254 @@ mod tests {
let result = AssetPath::parse_internal("http:/");
assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
}

#[test]
fn test_resolve_full() {
// A "full" path should ignore the base path.
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("/joe/next").unwrap(),
AssetPath::from("joe/next")
);
assert_eq!(
base.resolve_embed("/joe/next").unwrap(),
AssetPath::from("joe/next")
);
assert_eq!(
base.resolve("/joe/next#dave").unwrap(),
AssetPath::from("joe/next#dave")
);
assert_eq!(
base.resolve_embed("/joe/next#dave").unwrap(),
AssetPath::from("joe/next#dave")
);
}

#[test]
fn test_resolve_implicit_relative() {
// A path with no inital directory separator should be considered relative.
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("joe/next").unwrap(),
AssetPath::from("alice/bob/joe/next")
);
assert_eq!(
base.resolve_embed("joe/next").unwrap(),
AssetPath::from("alice/joe/next")
);
assert_eq!(
base.resolve("joe/next#dave").unwrap(),
AssetPath::from("alice/bob/joe/next#dave")
);
assert_eq!(
base.resolve_embed("joe/next#dave").unwrap(),
AssetPath::from("alice/joe/next#dave")
);
}

#[test]
fn test_resolve_explicit_relative() {
// A path which begins with "./" or "../" is treated as relative
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("./martin#dave").unwrap(),
AssetPath::from("alice/bob/martin#dave")
);
assert_eq!(
base.resolve_embed("./martin#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
assert_eq!(
base.resolve("../martin#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
assert_eq!(
base.resolve_embed("../martin#dave").unwrap(),
AssetPath::from("martin#dave")
);
}

#[test]
fn test_resolve_trailing_slash() {
// A path which begins with "./" or "../" is treated as relative
let base = AssetPath::from("alice/bob/");
assert_eq!(
base.resolve("./martin#dave").unwrap(),
AssetPath::from("alice/bob/martin#dave")
);
assert_eq!(
base.resolve_embed("./martin#dave").unwrap(),
AssetPath::from("alice/bob/martin#dave")
);
assert_eq!(
base.resolve("../martin#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
assert_eq!(
base.resolve_embed("../martin#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
}

#[test]
fn test_resolve_canonicalize() {
// Test that ".." and "." are removed after concatenation.
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("./martin/stephan/..#dave").unwrap(),
AssetPath::from("alice/bob/martin#dave")
);
assert_eq!(
base.resolve_embed("./martin/stephan/..#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
assert_eq!(
base.resolve("../martin/.#dave").unwrap(),
AssetPath::from("alice/martin#dave")
);
assert_eq!(
base.resolve_embed("../martin/.#dave").unwrap(),
AssetPath::from("martin#dave")
);
assert_eq!(
base.resolve("/martin/stephan/..#dave").unwrap(),
AssetPath::from("martin#dave")
);
assert_eq!(
base.resolve_embed("/martin/stephan/..#dave").unwrap(),
AssetPath::from("martin#dave")
);
}

#[test]
fn test_resolve_canonicalize_base() {
// Test that ".." and "." are removed after concatenation even from the base path.
let base = AssetPath::from("alice/../bob#carol");
assert_eq!(
base.resolve("./martin/stephan/..#dave").unwrap(),
AssetPath::from("bob/martin#dave")
);
assert_eq!(
base.resolve_embed("./martin/stephan/..#dave").unwrap(),
AssetPath::from("martin#dave")
);
assert_eq!(
base.resolve("../martin/.#dave").unwrap(),
AssetPath::from("martin#dave")
);
assert_eq!(
base.resolve_embed("../martin/.#dave").unwrap(),
AssetPath::from("../martin#dave")
);
assert_eq!(
base.resolve("/martin/stephan/..#dave").unwrap(),
AssetPath::from("martin#dave")
);
assert_eq!(
base.resolve_embed("/martin/stephan/..#dave").unwrap(),
AssetPath::from("martin#dave")
);
}

#[test]
fn test_resolve_canonicalize_with_source() {
// Test that ".." and "." are removed after concatenation.
let base = AssetPath::from("source://alice/bob#carol");
assert_eq!(
base.resolve("./martin/stephan/..#dave").unwrap(),
AssetPath::from("source://alice/bob/martin#dave")
);
assert_eq!(
base.resolve_embed("./martin/stephan/..#dave").unwrap(),
AssetPath::from("source://alice/martin#dave")
);
assert_eq!(
base.resolve("../martin/.#dave").unwrap(),
AssetPath::from("source://alice/martin#dave")
);
assert_eq!(
base.resolve_embed("../martin/.#dave").unwrap(),
AssetPath::from("source://martin#dave")
);
assert_eq!(
base.resolve("/martin/stephan/..#dave").unwrap(),
AssetPath::from("source://martin#dave")
);
assert_eq!(
base.resolve_embed("/martin/stephan/..#dave").unwrap(),
AssetPath::from("source://martin#dave")
);
}

#[test]
fn test_resolve_absolute() {
// Paths beginning with '/' replace the base path
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("/martin/stephan").unwrap(),
AssetPath::from("martin/stephan")
);
assert_eq!(
base.resolve_embed("/martin/stephan").unwrap(),
AssetPath::from("martin/stephan")
);
assert_eq!(
base.resolve("/martin/stephan#dave").unwrap(),
AssetPath::from("martin/stephan/#dave")
);
assert_eq!(
base.resolve_embed("/martin/stephan#dave").unwrap(),
AssetPath::from("martin/stephan/#dave")
);
}

#[test]
fn test_resolve_asset_source() {
// Paths beginning with 'source://' replace the base path
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("source://martin/stephan").unwrap(),
AssetPath::from("source://martin/stephan")
);
assert_eq!(
base.resolve_embed("source://martin/stephan").unwrap(),
AssetPath::from("source://martin/stephan")
);
assert_eq!(
base.resolve("source://martin/stephan#dave").unwrap(),
AssetPath::from("source://martin/stephan/#dave")
);
assert_eq!(
base.resolve_embed("source://martin/stephan#dave").unwrap(),
AssetPath::from("source://martin/stephan/#dave")
);
}

#[test]
fn test_resolve_label() {
// A relative path with only a label should replace the label portion
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("#dave").unwrap(),
AssetPath::from("alice/bob#dave")
);
assert_eq!(
base.resolve_embed("#dave").unwrap(),
AssetPath::from("alice/bob#dave")
);
}

#[test]
fn test_resolve_insufficient_elements() {
// Ensure that ".." segments are preserved if there are insufficient elements to remove them.
let base = AssetPath::from("alice/bob#carol");
assert_eq!(
base.resolve("../../joe/next").unwrap(),
AssetPath::from("joe/next")
);
assert_eq!(
base.resolve_embed("../../joe/next").unwrap(),
AssetPath::from("../joe/next")
);
}
}