use crate::error::UrlError;
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlParts<'a> {
scheme: Cow<'a, str>,
host: Cow<'a, str>,
port: Option<&'a str>,
path: &'a str,
query: &'a str,
fragment: &'a str,
}
fn ascii_lower(input: &str) -> Cow<'_, str> {
if input.bytes().any(|byte| byte.is_ascii_uppercase()) {
Cow::Owned(input.to_ascii_lowercase())
} else {
Cow::Borrowed(input)
}
}
impl<'a> UrlParts<'a> {
pub fn parse(input: &'a str) -> Result<Self, UrlError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(UrlError::new("URL is empty"));
}
let scheme_end = trimmed.find(':').ok_or_else(|| {
UrlError::new("URL has no scheme; expected something like https://host/path")
})?;
let scheme = &trimmed[..scheme_end];
if scheme.is_empty() || !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
return Err(UrlError::new(format!(
"`{scheme}` is not a valid URL scheme"
)));
}
if !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return Err(UrlError::new(format!(
"`{scheme}` is not a valid URL scheme"
)));
}
let rest = &trimmed[scheme_end + 1..];
let rest = rest
.strip_prefix("//")
.ok_or_else(|| UrlError::new("URL has no `//` authority component"))?;
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
let mut remainder = &rest[authority_end..];
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_userinfo, host_port)| host_port);
let (host, port) = split_host_port(host_port)?;
if host.is_empty() {
return Err(UrlError::new("URL has an empty host"));
}
let mut fragment = "";
if let Some(index) = remainder.find('#') {
fragment = &remainder[index + 1..];
remainder = &remainder[..index];
}
let mut query = "";
if let Some(index) = remainder.find('?') {
query = &remainder[index + 1..];
remainder = &remainder[..index];
}
let path = if remainder.is_empty() { "/" } else { remainder };
Ok(Self {
scheme: ascii_lower(scheme),
host: ascii_lower(host),
port,
path,
query,
fragment,
})
}
#[must_use]
pub fn scheme(&self) -> &str {
&self.scheme
}
#[must_use]
pub fn host(&self) -> &str {
&self.host
}
#[must_use]
pub fn port(&self) -> Option<&'a str> {
self.port
}
#[must_use]
pub fn path(&self) -> &'a str {
self.path
}
#[must_use]
pub fn query(&self) -> &'a str {
self.query
}
#[must_use]
pub fn fragment(&self) -> &'a str {
self.fragment
}
#[must_use]
pub fn query_items(&self) -> Vec<(&'a str, &'a str)> {
if self.query.is_empty() {
return Vec::new();
}
self.query
.split('&')
.filter(|item| !item.is_empty())
.map(|item| item.split_once('=').unwrap_or((item, "")))
.collect()
}
}
fn split_host_port(host_port: &str) -> Result<(&str, Option<&str>), UrlError> {
if let Some(end) = host_port.strip_prefix('[').and_then(|rest| rest.find(']')) {
let host = &host_port[..=end + 1];
let tail = &host_port[end + 2..];
return match tail.strip_prefix(':') {
Some(port) => Ok((host, Some(port))),
None if tail.is_empty() => Ok((host, None)),
None => Err(UrlError::new("malformed IPv6 authority")),
};
}
match host_port.rsplit_once(':') {
Some((host, port)) => Ok((host, Some(port))),
None => Ok((host_port, None)),
}
}
#[must_use]
pub fn trim_path(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
"/"
} else {
trimmed
}
}
#[must_use]
pub fn strip_leading_slash(path: &str) -> Option<&str> {
match path.strip_prefix('/') {
Some(rest) if !rest.is_empty() => Some(rest),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PathPatternShape {
pub(crate) canonical_leading_slash: bool,
pub(crate) matches_parent: bool,
}
#[must_use]
pub(crate) fn normalize_path_pattern(pattern: &str) -> (String, PathPatternShape) {
let leading = pattern.starts_with('/');
let core = pattern.trim_start_matches('/');
let base = if leading {
format!("/{core}")
} else {
core.to_owned()
};
let trimmed = base.trim_end_matches('/');
let base = if trimmed.is_empty() && leading {
"/".to_owned()
} else {
trimmed.to_owned()
};
let shape = PathPatternShape {
canonical_leading_slash: base.starts_with('/'),
matches_parent: base.strip_suffix("/*").is_some_and(|rest| !rest.is_empty()),
};
(base, shape)
}
#[must_use]
pub fn percent_decode(input: &str) -> String {
if !input.contains('%') {
return input.to_owned();
}
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let (Some(high), Some(low)) = (
(bytes[index + 1] as char).to_digit(16),
(bytes[index + 2] as char).to_digit(16),
) {
#[allow(clippy::cast_possible_truncation)]
out.push((high * 16 + low) as u8);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}