#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
#![allow(
clippy::cast_possible_truncation,
clippy::if_not_else,
clippy::missing_fields_in_debug,
clippy::range_plus_one
)]
mod parser;
use core::fmt;
use core::ops::Range;
use std::borrow::Cow;
use parser::ParsedUrl;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum State {
SchemeStart,
Scheme,
NoScheme,
SpecialRelativeOrAuthority,
PathOrAuthority,
Relative,
RelativeSlash,
SpecialAuthoritySlashes,
SpecialAuthorityIgnoreSlashes,
Authority,
Host,
Hostname,
Port,
File,
FileSlash,
FileHost,
PathStart,
Path,
OpaquePath,
Query,
Fragment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ParseError {
Failure,
InputTooLong,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Failure => f.write_str("URL parse failure"),
Self::InputTooLong => f.write_str("URL input exceeds u32 index range"),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(transparent)]
pub struct UrlFlags(u8);
impl UrlFlags {
pub const SPECIAL: Self = Self(1 << 0);
pub const HAS_CREDENTIALS: Self = Self(1 << 1);
pub const HAS_EMPTY_HOST: Self = Self(1 << 2);
pub const OPAQUE_PATH: Self = Self(1 << 3);
pub const HAS_PASSWORD: Self = Self(1 << 4);
pub const HOST_IPV4: Self = Self(1 << 5);
pub const HOST_IPV6: Self = Self(1 << 6);
pub const HOST_IDNA: Self = Self(1 << 7);
#[inline]
#[must_use]
pub const fn empty() -> Self {
Self(0)
}
#[inline]
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[inline]
pub const fn insert(&mut self, other: Self) {
self.0 |= other.0;
}
#[inline]
pub const fn remove(&mut self, other: Self) {
self.0 &= !other.0;
}
#[inline]
#[must_use]
pub const fn is_special(self) -> bool {
self.contains(Self::SPECIAL)
}
#[inline]
#[must_use]
pub const fn has_opaque_path(self) -> bool {
self.contains(Self::OPAQUE_PATH)
}
}
#[derive(Clone, Debug, Eq)]
pub enum Backing<'a> {
Borrowed(&'a str),
Owned(String),
}
impl PartialEq for Backing<'_> {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Backing<'_> {
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Borrowed(s) => s,
Self::Owned(s) => s.as_str(),
}
}
#[inline]
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.as_str().as_bytes()
}
#[inline]
#[must_use]
pub fn len(&self) -> usize {
self.as_str().len()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.as_str().is_empty()
}
#[inline]
#[must_use]
pub fn is_borrowed(&self) -> bool {
matches!(self, Self::Borrowed(_))
}
#[must_use]
pub fn into_owned(self) -> Backing<'static> {
match self {
Self::Borrowed(s) => Backing::Owned(s.to_owned()),
Self::Owned(s) => Backing::Owned(s),
}
}
}
#[derive(Clone)]
pub struct Url<'a> {
serialization: Backing<'a>,
scheme_end: u32,
username_end: u32,
host_start: u32,
host_end: u32,
port: u32,
path_start: u32,
query_start: u32,
fragment_start: u32,
flags: UrlFlags,
}
impl<'a> Url<'a> {
pub const NONE: u32 = u32::MAX;
pub const NO_PORT: u32 = u32::MAX;
fn from_parsed(p: ParsedUrl<'a>) -> Self {
Self {
serialization: p.serialization,
scheme_end: p.scheme_end,
username_end: p.username_end,
host_start: p.host_start,
host_end: p.host_end,
port: match p.port {
Some(port) => u32::from(port),
None => Self::NO_PORT,
},
path_start: p.path_start,
query_start: p.query_start.unwrap_or(Self::NONE),
fragment_start: p.fragment_start.unwrap_or(Self::NONE),
flags: UrlFlags(p.flags),
}
}
fn to_parsed(&self) -> ParsedUrl<'_> {
ParsedUrl {
serialization: match &self.serialization {
Backing::Borrowed(s) => Backing::Borrowed(s),
Backing::Owned(s) => Backing::Borrowed(s.as_str()),
},
scheme_end: self.scheme_end,
username_end: self.username_end,
host_start: self.host_start,
host_end: self.host_end,
port: self.port_u16(),
path_start: self.path_start,
query_start: (self.query_start != Self::NONE).then_some(self.query_start),
fragment_start: (self.fragment_start != Self::NONE).then_some(self.fragment_start),
flags: self.flags.0,
}
}
#[must_use]
pub fn blank() -> Url<'static> {
Url {
serialization: Backing::Owned(String::new()),
scheme_end: 0,
username_end: 0,
host_start: 0,
host_end: 0,
port: Self::NO_PORT,
path_start: 0,
query_start: Self::NONE,
fragment_start: Self::NONE,
flags: UrlFlags::empty(),
}
}
#[must_use]
pub fn into_owned(self) -> Url<'static> {
Url {
serialization: self.serialization.into_owned(),
scheme_end: self.scheme_end,
username_end: self.username_end,
host_start: self.host_start,
host_end: self.host_end,
port: self.port,
path_start: self.path_start,
query_start: self.query_start,
fragment_start: self.fragment_start,
flags: self.flags,
}
}
pub fn parse(input: &'a str) -> Result<Self, ParseError> {
Self::parse_with_base(input, None)
}
pub fn parse_with_base(input: &'a str, base: Option<&Url<'_>>) -> Result<Self, ParseError> {
if input.len() > u32::MAX as usize {
return Err(ParseError::InputTooLong);
}
let base_parsed = base.map(Url::to_parsed);
let parsed = parser::parse(input, base_parsed.as_ref())?;
Ok(Self::from_parsed(parsed))
}
#[inline]
#[must_use]
pub const fn backing(&self) -> &Backing<'a> {
&self.serialization
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
self.serialization.as_str()
}
#[inline]
#[must_use]
pub const fn flags(&self) -> UrlFlags {
self.flags
}
#[inline]
fn byte_at(&self, i: u32) -> u8 {
self.serialization.as_bytes()[i as usize]
}
#[inline]
fn slice(&self, range: Range<u32>) -> &str {
&self.as_str()[range.start as usize..range.end as usize]
}
#[inline]
fn has_authority(&self) -> bool {
self.serialization.len() >= self.scheme_end as usize + 3
&& self.byte_at(self.scheme_end + 1) == b'/'
&& self.byte_at(self.scheme_end + 2) == b'/'
}
#[inline]
#[must_use]
pub const fn scheme_range(&self) -> Range<usize> {
0..self.scheme_end as usize
}
#[inline]
#[must_use]
pub fn scheme(&self) -> &str {
&self.as_str()[self.scheme_range()]
}
#[inline]
#[must_use]
pub fn protocol(&self) -> &str {
&self.as_str()[..self.scheme_end as usize + 1]
}
#[inline]
#[must_use]
pub fn has_host(&self) -> bool {
self.host_start != self.host_end || self.flags.contains(UrlFlags::HAS_EMPTY_HOST)
}
#[inline]
#[must_use]
pub fn host(&self) -> Option<&str> {
if !self.has_host() {
return None;
}
Some(self.slice(self.host_start..self.host_end))
}
#[inline]
#[must_use]
pub fn username(&self) -> &str {
let scheme_separator_len = 3; if self.has_authority() && self.username_end > self.scheme_end + scheme_separator_len {
self.slice(self.scheme_end + scheme_separator_len..self.username_end)
} else {
""
}
}
#[inline]
#[must_use]
pub fn password(&self) -> &str {
if self.has_authority()
&& (self.username_end as usize) < self.serialization.len()
&& self.byte_at(self.username_end) == b':'
{
self.slice(self.username_end + 1..self.host_start - 1)
} else {
""
}
}
#[inline]
#[must_use]
pub const fn port_u16(&self) -> Option<u16> {
if self.port == Self::NO_PORT || self.port > u16::MAX as u32 {
None
} else {
#[allow(clippy::cast_possible_truncation)]
{
Some(self.port as u16)
}
}
}
#[inline]
#[must_use]
pub fn port_str(&self) -> String {
match self.port_u16() {
Some(p) => p.to_string(),
None => String::new(),
}
}
#[inline]
#[must_use]
pub fn hostname(&self) -> &str {
self.host().unwrap_or("")
}
#[inline]
#[must_use]
pub fn host_with_port(&self) -> &str {
if !self.has_host() {
return "";
}
let end = if self.port != Self::NO_PORT {
self.path_start
} else {
self.host_end
};
self.slice(self.host_start..end)
}
#[inline]
#[must_use]
pub fn path(&self) -> &str {
let end = if self.query_start != Self::NONE {
self.query_start
} else if self.fragment_start != Self::NONE {
self.fragment_start
} else {
self.serialization.len() as u32
};
self.slice(self.path_start..end)
}
#[inline]
#[must_use]
pub fn pathname(&self) -> &str {
self.path()
}
#[inline]
#[must_use]
pub fn query(&self) -> Option<&str> {
if self.query_start == Self::NONE {
return None;
}
let start = self.query_start + 1;
let end = if self.fragment_start != Self::NONE {
self.fragment_start
} else {
self.serialization.len() as u32
};
Some(self.slice(start..end))
}
#[inline]
#[must_use]
pub fn search(&self) -> &str {
if self.query_start == Self::NONE {
return "";
}
let end = if self.fragment_start != Self::NONE {
self.fragment_start
} else {
self.serialization.len() as u32
};
if end == self.query_start + 1 {
return "";
}
self.slice(self.query_start..end)
}
#[inline]
#[must_use]
pub fn fragment(&self) -> Option<&str> {
if self.fragment_start == Self::NONE {
return None;
}
Some(&self.as_str()[self.fragment_start as usize + 1..])
}
#[inline]
#[must_use]
pub fn hash(&self) -> &str {
if self.fragment_start == Self::NONE {
return "";
}
if self.fragment_start as usize + 1 == self.serialization.len() {
return "";
}
&self.as_str()[self.fragment_start as usize..]
}
#[must_use]
pub fn href(&self) -> &str {
self.as_str()
}
#[must_use]
pub fn percent_decode(raw: &str) -> Cow<'_, str> {
let decoded = parser::percent::percent_decode(raw.as_bytes());
match decoded {
Cow::Borrowed(b) => match std::str::from_utf8(b) {
Ok(s) => Cow::Borrowed(s),
Err(_) => Cow::Owned(String::from_utf8_lossy(b).into_owned()),
},
Cow::Owned(v) => match String::from_utf8(v) {
Ok(s) => Cow::Owned(s),
Err(e) => Cow::Owned(String::from_utf8_lossy(&e.into_bytes()).into_owned()),
},
}
}
}
impl fmt::Debug for Url<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Url")
.field("href", &self.href())
.field("borrowed", &self.serialization.is_borrowed())
.field("scheme", &self.scheme())
.field("username", &self.username())
.field("password", &self.password())
.field("host", &self.host())
.field("port", &self.port_u16())
.field("path", &self.path())
.field("query", &self.query())
.field("fragment", &self.fragment())
.field("flags", &self.flags)
.finish_non_exhaustive()
}
}
impl PartialEq for Url<'_> {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for Url<'_> {}
impl fmt::Display for Url<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_url_has_no_host_or_query() {
let url = Url::blank();
assert_eq!(url.scheme(), "");
assert!(url.host().is_none());
assert!(url.query().is_none());
assert!(url.fragment().is_none());
assert_eq!(url.port_u16(), None);
}
#[test]
fn parse_https_example() {
let url = Url::parse("https://example.com/foo").unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.hostname(), "example.com");
assert_eq!(url.pathname(), "/foo");
assert_eq!(url.href(), "https://example.com/foo");
assert!(url.backing().is_borrowed());
}
#[test]
fn uppercase_scheme_owns() {
let url = Url::parse("HTTPS://example.com/foo").unwrap();
assert_eq!(url.href(), "https://example.com/foo");
assert!(!url.backing().is_borrowed());
}
#[test]
fn state_variants_cover_whatwg_set() {
let states = [
State::SchemeStart,
State::Scheme,
State::NoScheme,
State::SpecialRelativeOrAuthority,
State::PathOrAuthority,
State::Relative,
State::RelativeSlash,
State::SpecialAuthoritySlashes,
State::SpecialAuthorityIgnoreSlashes,
State::Authority,
State::Host,
State::Hostname,
State::Port,
State::File,
State::FileSlash,
State::FileHost,
State::PathStart,
State::Path,
State::OpaquePath,
State::Query,
State::Fragment,
];
assert_eq!(states.len(), 21);
}
#[test]
fn percent_decode_stub_borrows() {
let cow = Url::percent_decode("abc");
assert!(matches!(cow, Cow::Borrowed(_)));
}
}