struct Parts<'a> {
scheme: Option<&'a str>,
authority: Option<&'a str>,
path: &'a str,
query: Option<&'a str>,
fragment: Option<&'a str>,
}
fn split(s: &str) -> Parts<'_> {
let mut rest = s;
let fragment = rest.find('#').map(|i| {
let f = &rest[i + 1..];
rest = &rest[..i];
f
});
let query = rest.find('?').map(|i| {
let q = &rest[i + 1..];
rest = &rest[..i];
q
});
let scheme = rest.find(':').and_then(|i| {
let candidate = &rest[..i];
if is_scheme(candidate) {
rest = &rest[i + 1..];
Some(candidate)
} else {
None
}
});
let authority = rest.strip_prefix("//").map(|after| {
let end = after.find('/').unwrap_or(after.len());
let a = &after[..end];
rest = &after[end..];
a
});
Parts {
scheme,
authority,
path: rest,
query,
fragment,
}
}
fn is_scheme(s: &str) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn remove_dot_segments(path: &str) -> String {
let mut input = path.to_string();
let mut output = String::new();
while !input.is_empty() {
if let Some(rest) = input.strip_prefix("../") {
input = rest.to_string();
} else if let Some(rest) = input.strip_prefix("./") {
input = rest.to_string();
} else if let Some(rest) = input.strip_prefix("/./") {
input = format!("/{rest}");
} else if input == "/." {
input = "/".to_string();
} else if let Some(rest) = input.strip_prefix("/../") {
input = format!("/{rest}");
remove_last_segment(&mut output);
} else if input == "/.." {
input = "/".to_string();
remove_last_segment(&mut output);
} else if input == "." || input == ".." {
input.clear();
} else {
let start = usize::from(input.starts_with('/'));
let end = input[start..].find('/').map_or(input.len(), |i| start + i);
output.push_str(&input[..end]);
input = input[end..].to_string();
}
}
output
}
fn remove_last_segment(output: &mut String) {
match output.rfind('/') {
Some(i) => output.truncate(i),
None => output.clear(),
}
}
fn recompose(
scheme: Option<&str>,
authority: Option<&str>,
path: &str,
query: Option<&str>,
fragment: Option<&str>,
) -> String {
let mut out = String::new();
if let Some(scheme) = scheme {
out.push_str(scheme);
out.push(':');
}
if let Some(authority) = authority {
out.push_str("//");
out.push_str(authority);
}
out.push_str(path);
if let Some(query) = query {
out.push('?');
out.push_str(query);
}
if let Some(fragment) = fragment {
out.push('#');
out.push_str(fragment);
}
out
}
fn merge(base: &Parts<'_>, reference_path: &str) -> String {
if base.authority.is_some() && base.path.is_empty() {
format!("/{reference_path}")
} else {
match base.path.rfind('/') {
Some(i) => format!("{}{reference_path}", &base.path[..=i]),
None => reference_path.to_string(),
}
}
}
pub(crate) fn resolve(reference: &str, base: &str) -> String {
let r = split(reference);
let b = split(base);
let (scheme, authority, path, query) = if let Some(scheme) = r.scheme {
(scheme, r.authority, remove_dot_segments(r.path), r.query)
} else if r.authority.is_some() {
(
b.scheme.unwrap_or_default(),
r.authority,
remove_dot_segments(r.path),
r.query,
)
} else if r.path.is_empty() {
(
b.scheme.unwrap_or_default(),
b.authority,
b.path.to_string(),
r.query.or(b.query),
)
} else if r.path.starts_with('/') {
(
b.scheme.unwrap_or_default(),
b.authority,
remove_dot_segments(r.path),
r.query,
)
} else {
(
b.scheme.unwrap_or_default(),
b.authority,
remove_dot_segments(&merge(&b, r.path)),
r.query,
)
};
let scheme = (!scheme.is_empty()).then_some(scheme);
recompose(scheme, authority, &path, query, r.fragment)
}
#[cfg(test)]
mod tests {
use super::resolve;
#[test]
fn rfc_3986_normal_examples() {
let base = "http://a/b/c/d;p?q";
for (reference, expected) in [
("g:h", "g:h"),
("g", "http://a/b/c/g"),
("./g", "http://a/b/c/g"),
("g/", "http://a/b/c/g/"),
("/g", "http://a/g"),
("//g", "http://g"),
("?y", "http://a/b/c/d;p?y"),
("g?y", "http://a/b/c/g?y"),
("#s", "http://a/b/c/d;p?q#s"),
("g#s", "http://a/b/c/g#s"),
("g?y#s", "http://a/b/c/g?y#s"),
(";x", "http://a/b/c/;x"),
("g;x", "http://a/b/c/g;x"),
("g;x?y#s", "http://a/b/c/g;x?y#s"),
("", "http://a/b/c/d;p?q"),
(".", "http://a/b/c/"),
("./", "http://a/b/c/"),
("..", "http://a/b/"),
("../", "http://a/b/"),
("../g", "http://a/b/g"),
("../..", "http://a/"),
("../../", "http://a/"),
("../../g", "http://a/g"),
] {
assert_eq!(resolve(reference, base), expected, "resolving {reference:?}");
}
}
#[test]
fn rfc_3986_abnormal_examples() {
let base = "http://a/b/c/d;p?q";
for (reference, expected) in [
("../../../g", "http://a/g"),
("../../../../g", "http://a/g"),
("/./g", "http://a/g"),
("/../g", "http://a/g"),
("g.", "http://a/b/c/g."),
(".g", "http://a/b/c/.g"),
("g..", "http://a/b/c/g.."),
("..g", "http://a/b/c/..g"),
("./../g", "http://a/b/g"),
("./g/.", "http://a/b/c/g/"),
("g/./h", "http://a/b/c/g/h"),
("g/../h", "http://a/b/c/h"),
("g;x=1/./y", "http://a/b/c/g;x=1/y"),
("g;x=1/../y", "http://a/b/c/y"),
("g?y/./x", "http://a/b/c/g?y/./x"),
("g?y/../x", "http://a/b/c/g?y/../x"),
("g#s/./x", "http://a/b/c/g#s/./x"),
("g#s/../x", "http://a/b/c/g#s/../x"),
] {
assert_eq!(resolve(reference, base), expected, "resolving {reference:?}");
}
}
#[test]
fn a_reference_with_its_own_scheme_ignores_the_base_entirely() {
assert_eq!(resolve("mailto:x@example.com", "http://a/b/"), "mailto:x@example.com");
}
}