use std::borrow::Cow;
use std::ops::Range;
use std::path::{Component, Path};
pub fn path_to_url(relative: &Path) -> String {
let mut url = String::new();
for component in relative.components() {
let piece: Cow<'_, str> = match component {
Component::Normal(name) => name.to_string_lossy(),
Component::ParentDir => Cow::Borrowed(".."),
Component::Prefix(_) | Component::RootDir | Component::CurDir => continue,
};
if !url.is_empty() {
url.push('/');
}
url.push_str(&piece);
}
url
}
pub fn is_external_url(url: &str) -> bool {
if url.starts_with("//") {
return true;
}
if url.starts_with(['/', '#', '?']) {
return false;
}
url_scheme(url).is_some()
}
pub fn is_mbr_query_route(url: &str) -> bool {
let before_fragment = url.split('#').next().unwrap_or_default();
before_fragment
.split_once('?')
.is_some_and(|(path, query)| {
path.split('/').any(|segment| segment == ".mbr")
&& query
.split('&')
.any(|param| param.split_once('=').is_some_and(|(key, _)| key == "path"))
})
}
pub fn url_scheme(url: &str) -> Option<&str> {
let colon = url.find(':')?;
let scheme = &url[..colon];
if scheme.len() < 2 {
return None;
}
let mut chars = scheme.chars();
let starts_with_alpha = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
let rest_is_scheme_chars =
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
(starts_with_alpha && rest_is_scheme_chars).then_some(scheme)
}
fn is_html_whitespace(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\x0C' | b'\r')
}
pub fn srcset_url_spans(srcset: &str) -> Vec<Range<usize>> {
let bytes = srcset.as_bytes();
let mut spans = Vec::new();
let mut pos = 0;
while pos < bytes.len() {
while pos < bytes.len() && (is_html_whitespace(bytes[pos]) || bytes[pos] == b',') {
pos += 1;
}
if pos == bytes.len() {
break;
}
let start = pos;
while pos < bytes.len() && !is_html_whitespace(bytes[pos]) {
pos += 1;
}
let end = start + srcset[start..pos].trim_end_matches(',').len();
spans.push(start..end);
if end < pos {
continue;
}
let mut depth = 0usize;
while pos < bytes.len() {
match bytes[pos] {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b',' if depth == 0 => break,
_ => {}
}
pos += 1;
}
}
spans
}
pub fn rewrite_srcset<F>(srcset: &str, mut rewrite_url: F) -> Cow<'_, str>
where
F: FnMut(&str) -> Option<String>,
{
let mut rewritten: Option<String> = None;
let mut copied_to = 0;
for span in srcset_url_spans(srcset) {
let Some(replacement) = rewrite_url(&srcset[span.clone()]) else {
continue;
};
let out = rewritten.get_or_insert_with(|| String::with_capacity(srcset.len() + 16));
out.push_str(&srcset[copied_to..span.start]);
out.push_str(&replacement);
copied_to = span.end;
}
match rewritten {
Some(mut out) => {
out.push_str(&srcset[copied_to..]);
Cow::Owned(out)
}
None => Cow::Borrowed(srcset),
}
}
pub fn serialize_as_url<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&path_to_url(path))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn native(parts: &[&str]) -> PathBuf {
parts.iter().collect()
}
#[test]
fn test_empty_path_is_empty_url() {
assert_eq!(path_to_url(Path::new("")), "");
}
#[test]
fn test_single_component() {
assert_eq!(path_to_url(Path::new("README.md")), "README.md");
}
#[test]
fn test_nested_path_uses_forward_slashes() {
let path = native(&["docs", "guide", "index.md"]);
let url = path_to_url(&path);
assert_eq!(url, "docs/guide/index.md");
assert!(
!url.contains('\\'),
"URL must never contain a backslash, got {url}"
);
}
#[test]
fn test_deeply_nested_path() {
let path = native(&["a", "b", "c", "d", "e", "f.png"]);
assert_eq!(path_to_url(&path), "a/b/c/d/e/f.png");
}
#[test]
fn test_parent_dir_is_preserved() {
let path = native(&["..", "..", "images", "photo.png"]);
assert_eq!(path_to_url(&path), "../../images/photo.png");
}
#[test]
fn test_current_dir_is_dropped() {
assert_eq!(path_to_url(Path::new(".")), "");
let path = native(&[".", "docs", "guide.md"]);
assert_eq!(path_to_url(&path), "docs/guide.md");
}
#[test]
fn test_root_component_is_dropped() {
assert_eq!(path_to_url(Path::new("/docs/guide.md")), "docs/guide.md");
}
#[test]
fn test_spaces_and_unicode_preserved() {
let path = native(&["my images", "café \u{1f600}.png"]);
assert_eq!(path_to_url(&path), "my images/café \u{1f600}.png");
}
#[test]
fn test_no_trailing_or_leading_slash() {
let url = path_to_url(&native(&["docs", "guide.md"]));
assert!(!url.starts_with('/'));
assert!(!url.ends_with('/'));
}
#[cfg(windows)]
#[test]
fn test_windows_backslash_literal_is_converted() {
assert_eq!(path_to_url(Path::new(r"docs\guide.md")), "docs/guide.md");
assert_eq!(
path_to_url(Path::new(r"C:\notes\docs\guide.md")),
"notes/docs/guide.md"
);
}
#[cfg(unix)]
#[test]
fn test_unix_backslash_is_a_filename_character() {
assert_eq!(path_to_url(Path::new(r"weird\name.md")), r"weird\name.md");
}
#[test]
fn test_is_external_url_table() {
let cases: &[(&str, bool)] = &[
("ftp://ftp.example.com/pub/file.zip", true),
("ftps://ftp.example.com/pub/file.zip", true),
("magnet:?xt=urn:btih:c12fe1c06bba254a9dc9", true),
("sms:+15555550123", true),
("callto:+15555550123", true),
("blob:http://localhost:5220/550e8400-e29b", true),
(
"message://%3CCAEn-OzgEreVRuNkdnb9gdFNLXByerYCLraYJGRvjSvXw9chVMQ@mail.gmail.com%3E",
true,
),
("message:%3Cabc123@mail.example.com%3E", true),
("zoommtg://zoom.us/join?confno=1234567890", true),
("x-devonthink-item://8A3B0C1D-2E4F", true),
("https://example.com/a%20b?c=%26d#e%2Ff", true),
("docs/my%20file.md", false),
("/docs/caf%C3%A9/", false),
("./%3Cbrackets%3E.md", false),
("Notes%3A2024.md", false),
("mailto:test@example.com", true),
("tel:+15555550123", true),
("http://example.com/path", true),
("https://example.com/path", true),
("data:image/png;base64,iVBORw0KGgo", true),
("javascript:void(0)", true),
("file:///etc/hosts", true),
("//cdn.example.com/script.js", true),
("/abs/path", false),
("relative/path.md", false),
("./relative/path.md", false),
("../sibling/path.md", false),
("#anchor", false),
("?q=1", false),
("", false),
("C:/win/path", false),
("c:/win/path", false),
("docs/a:b.md", false),
("docs/a?x:y", false),
("12:30 notes.md", false),
("./Notes:2024.md", false),
];
for (url, expected) in cases {
assert_eq!(
is_external_url(url),
*expected,
"is_external_url({url:?}) should be {expected}"
);
}
}
#[test]
fn test_is_mbr_query_route_table() {
let cases: &[(&str, bool)] = &[
("/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
(".mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
("../.mbr/videos/?path=%2Fvideos%2Fdemo.mp4", true),
("../../.mbr/audio/?path=/music/track.mp3", true),
("/.mbr/pdfs/?path=/docs/report.pdf", true),
("/.mbr/images/?path=/img/photo.png", true),
("/.mbr/videos?path=/videos/demo.mp4", true),
("/.mbr/videos/?t=30&path=/videos/demo.mp4", true),
("/.mbr/videos/?path=/videos/demo.mp4#t=30", true),
("/.mbr/videos/", false),
("/.mbr/whatever/", false),
("/.mbr/videos/?v=1", false),
("/.mbr/videos/?pathological=1", false),
("/docs/page/?path=x", false),
("/notes/.mbrconfig/?path=x", false),
("/my.mbr/videos/?path=x", false),
("/.mbr/videos/#frag?path=/videos/demo.mp4", false),
("#section", false),
("", false),
];
for (url, expected) in cases {
assert_eq!(
is_mbr_query_route(url),
*expected,
"is_mbr_query_route({url:?}) should be {expected}"
);
}
}
fn urls(srcset: &str) -> Vec<&str> {
srcset_url_spans(srcset)
.into_iter()
.map(|span| &srcset[span])
.collect()
}
#[test]
fn srcset_single_url() {
assert_eq!(urls("docs/hero-dark.png"), ["docs/hero-dark.png"]);
}
#[test]
fn srcset_url_with_density_descriptor() {
assert_eq!(urls("a.png 2x"), ["a.png"]);
}
#[test]
fn srcset_multiple_candidates_with_width_descriptors() {
assert_eq!(
urls("small.jpg 480w, medium.jpg 800w, large.jpg 1200w"),
["small.jpg", "medium.jpg", "large.jpg"]
);
}
#[test]
fn srcset_extra_whitespace_and_newlines() {
let value = "\n a.png 1x ,\n\t b.png\t2x\n ";
assert_eq!(urls(value), ["a.png", "b.png"]);
assert_eq!(
rewrite_srcset(value, |url| Some(format!("../{url}"))),
"\n ../a.png 1x ,\n\t ../b.png\t2x\n "
);
}
#[test]
fn srcset_trailing_comma_is_a_separator() {
assert_eq!(urls("a.png,"), ["a.png"]);
assert_eq!(urls("a.png, b.png"), ["a.png", "b.png"]);
assert_eq!(urls("a.png,, b.png 2x,"), ["a.png", "b.png"]);
assert_eq!(
rewrite_srcset("a.png,b.png 2x", |url| Some(format!("/{url}"))),
"/a.png,b.png 2x",
"a comma inside the run is part of the URL"
);
assert_eq!(
rewrite_srcset("a.png, b.png 2x", |url| Some(format!("/{url}"))),
"/a.png, /b.png 2x"
);
}
#[test]
fn srcset_leading_commas_are_skipped() {
assert_eq!(urls(" ,, a.png 1x"), ["a.png"]);
}
#[test]
fn srcset_descriptor_parentheses_hide_commas() {
assert_eq!(urls("a.png (x, y), b.png"), ["a.png", "b.png"]);
}
#[test]
fn srcset_empty_and_blank() {
for value in ["", " ", ",", " , \n"] {
assert!(urls(value).is_empty(), "{value:?}");
assert!(
matches!(rewrite_srcset(value, |_| Some("x".into())), Cow::Borrowed(v) if v == value)
);
}
}
#[test]
fn srcset_unchanged_is_borrowed() {
let value = "a.png 1x, b.png 2x";
assert!(matches!(rewrite_srcset(value, |_| None), Cow::Borrowed(v) if v == value));
}
#[test]
fn srcset_rewrites_only_chosen_urls() {
assert_eq!(
rewrite_srcset("https://x/a.png 1x, b.png 2x", |url| {
(!url.starts_with("https:")).then(|| format!("cid:{url}"))
}),
"https://x/a.png 1x, cid:b.png 2x"
);
}
proptest::proptest! {
#[test]
fn srcset_identity_rebuild_is_byte_identical(value in "[a-z0-9./ ,\t\n()xw]{0,48}") {
let rebuilt = rewrite_srcset(&value, |url| Some(url.to_string()));
proptest::prop_assert_eq!(rebuilt.as_ref(), value.as_str());
}
#[test]
fn srcset_spans_are_well_formed(value in "[a-z0-9./ ,\t\n()xw]{0,48}") {
let spans = srcset_url_spans(&value);
let mut previous_end = 0;
for span in spans {
proptest::prop_assert!(span.start >= previous_end && span.start < span.end);
let url = &value[span.clone()];
proptest::prop_assert!(!url.bytes().any(is_html_whitespace), "{:?}", url);
proptest::prop_assert!(!url.starts_with(',') && !url.ends_with(','), "{:?}", url);
previous_end = span.end;
}
}
}
}