#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlParts {
pub scheme: String,
pub host: Option<String>,
pub port: Option<u16>,
pub path: String,
pub query: Option<String>,
pub fragment: Option<String>,
}
#[must_use]
pub fn looks_like_url(input: &str) -> bool {
parse_url_basic(input).is_some()
}
#[must_use]
pub fn is_http_url(input: &str) -> bool {
matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "http")
}
#[must_use]
pub fn is_https_url(input: &str) -> bool {
matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "https")
}
#[must_use]
pub fn parse_url_basic(input: &str) -> Option<UrlParts> {
let trimmed = input.trim();
let scheme = extract_scheme(trimmed)?;
let remainder = &trimmed[scheme.len() + 1..];
let authority_input = remainder.strip_prefix("//")?;
let authority_end = authority_input
.find(['/', '?', '#'])
.unwrap_or(authority_input.len());
let authority = &authority_input[..authority_end];
if authority.is_empty() {
return None;
}
let (host, port) = parse_authority(authority);
let suffix = &authority_input[authority_end..];
let fragment_index = suffix.find('#');
let without_fragment = &suffix[..fragment_index.unwrap_or(suffix.len())];
let query_index = without_fragment.find('?');
let path = without_fragment[..query_index.unwrap_or(without_fragment.len())].to_string();
let path = if path.is_empty() {
String::from("/")
} else {
path
};
Some(UrlParts {
scheme,
host,
port,
path,
query: query_index.map(|index| without_fragment[index + 1..].to_string()),
fragment: fragment_index.map(|index| suffix[index + 1..].to_string()),
})
}
#[must_use]
pub fn extract_host(input: &str) -> Option<String> {
parse_url_basic(input).and_then(|parts| parts.host)
}
#[must_use]
pub fn extract_port(input: &str) -> Option<u16> {
parse_url_basic(input).and_then(|parts| parts.port)
}
#[must_use]
pub fn extract_path(input: &str) -> Option<String> {
parse_url_basic(input).map(|parts| parts.path)
}
#[must_use]
pub fn ensure_trailing_slash(input: &str) -> String {
let (base, suffix) = split_suffix(input.trim());
if base.is_empty() {
return format!("/{suffix}");
}
if base.ends_with('/') {
input.trim().to_string()
} else {
format!("{base}/{suffix}")
}
}
#[must_use]
pub fn strip_trailing_slash(input: &str) -> String {
let (base, suffix) = split_suffix(input.trim());
let trimmed = if base == "/" {
"/"
} else {
base.trim_end_matches('/')
};
format!("{trimmed}{suffix}")
}
#[must_use]
pub fn remove_fragment(input: &str) -> String {
match input.find('#') {
Some(index) => input[..index].to_string(),
None => input.to_string(),
}
}
#[must_use]
pub fn remove_query(input: &str) -> String {
match input.find('?') {
Some(query_index) => {
let fragment = input[query_index..]
.find('#')
.map(|offset| &input[query_index + offset..])
.unwrap_or("");
format!("{}{fragment}", &input[..query_index])
}
None => input.to_string(),
}
}
fn extract_scheme(input: &str) -> Option<String> {
let colon_index = input.find(':')?;
let candidate = &input[..colon_index];
if candidate.is_empty() {
return None;
}
let mut characters = candidate.chars();
let first = characters.next()?;
if !first.is_ascii_alphabetic() {
return None;
}
if characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))
{
Some(candidate.to_ascii_lowercase())
} else {
None
}
}
fn parse_authority(authority: &str) -> (Option<String>, Option<u16>) {
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, tail)| tail);
if let Some(after_bracket) = host_port.strip_prefix('[') {
if let Some(end) = after_bracket.find(']') {
let host = &after_bracket[..end];
let tail = &after_bracket[end + 1..];
let port = tail.strip_prefix(':').and_then(|value| value.parse().ok());
return ((!host.is_empty()).then(|| host.to_string()), port);
}
return (None, None);
}
if let Some((host, port_text)) = host_port.rsplit_once(':') {
if !host.is_empty()
&& !port_text.is_empty()
&& port_text
.chars()
.all(|character| character.is_ascii_digit())
{
return (Some(host.to_string()), port_text.parse().ok());
}
}
((!host_port.is_empty()).then(|| host_port.to_string()), None)
}
fn split_suffix(input: &str) -> (&str, &str) {
let index = input.find(['?', '#']).unwrap_or(input.len());
(&input[..index], &input[index..])
}