Skip to main content

use_url/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// Lightweight URL parts extracted with simple string splitting.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct UrlParts {
7    pub scheme: String,
8    pub host: Option<String>,
9    pub port: Option<u16>,
10    pub path: String,
11    pub query: Option<String>,
12    pub fragment: Option<String>,
13}
14
15/// Returns `true` when the input looks like a parsable absolute URL.
16#[must_use]
17pub fn looks_like_url(input: &str) -> bool {
18    parse_url_basic(input).is_some()
19}
20
21/// Returns `true` when the input is an HTTP URL.
22#[must_use]
23pub fn is_http_url(input: &str) -> bool {
24    matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "http")
25}
26
27/// Returns `true` when the input is an HTTPS URL.
28#[must_use]
29pub fn is_https_url(input: &str) -> bool {
30    matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "https")
31}
32
33/// Parses a URL with lightweight string splitting.
34#[must_use]
35pub fn parse_url_basic(input: &str) -> Option<UrlParts> {
36    let trimmed = input.trim();
37    let scheme = extract_scheme(trimmed)?;
38    let remainder = &trimmed[scheme.len() + 1..];
39    let authority_input = remainder.strip_prefix("//")?;
40    let authority_end = authority_input
41        .find(['/', '?', '#'])
42        .unwrap_or(authority_input.len());
43    let authority = &authority_input[..authority_end];
44    if authority.is_empty() {
45        return None;
46    }
47
48    let (host, port) = parse_authority(authority);
49    let suffix = &authority_input[authority_end..];
50    let fragment_index = suffix.find('#');
51    let without_fragment = &suffix[..fragment_index.unwrap_or(suffix.len())];
52    let query_index = without_fragment.find('?');
53    let path = without_fragment[..query_index.unwrap_or(without_fragment.len())].to_string();
54    let path = if path.is_empty() {
55        String::from("/")
56    } else {
57        path
58    };
59
60    Some(UrlParts {
61        scheme,
62        host,
63        port,
64        path,
65        query: query_index.map(|index| without_fragment[index + 1..].to_string()),
66        fragment: fragment_index.map(|index| suffix[index + 1..].to_string()),
67    })
68}
69
70/// Extracts the URL host when available.
71#[must_use]
72pub fn extract_host(input: &str) -> Option<String> {
73    parse_url_basic(input).and_then(|parts| parts.host)
74}
75
76/// Extracts the URL port when available.
77#[must_use]
78pub fn extract_port(input: &str) -> Option<u16> {
79    parse_url_basic(input).and_then(|parts| parts.port)
80}
81
82/// Extracts the URL path when available.
83#[must_use]
84pub fn extract_path(input: &str) -> Option<String> {
85    parse_url_basic(input).map(|parts| parts.path)
86}
87
88/// Ensures that the URL or path ends with a trailing slash before any query or fragment.
89#[must_use]
90pub fn ensure_trailing_slash(input: &str) -> String {
91    let (base, suffix) = split_suffix(input.trim());
92    if base.is_empty() {
93        return format!("/{suffix}");
94    }
95    if base.ends_with('/') {
96        input.trim().to_string()
97    } else {
98        format!("{base}/{suffix}")
99    }
100}
101
102/// Removes trailing slashes before any query or fragment.
103#[must_use]
104pub fn strip_trailing_slash(input: &str) -> String {
105    let (base, suffix) = split_suffix(input.trim());
106    let trimmed = if base == "/" {
107        "/"
108    } else {
109        base.trim_end_matches('/')
110    };
111
112    format!("{trimmed}{suffix}")
113}
114
115/// Removes the fragment portion from a URL.
116#[must_use]
117pub fn remove_fragment(input: &str) -> String {
118    match input.find('#') {
119        Some(index) => input[..index].to_string(),
120        None => input.to_string(),
121    }
122}
123
124/// Removes the query portion and preserves any fragment.
125#[must_use]
126pub fn remove_query(input: &str) -> String {
127    match input.find('?') {
128        Some(query_index) => {
129            let fragment = input[query_index..]
130                .find('#')
131                .map(|offset| &input[query_index + offset..])
132                .unwrap_or("");
133            format!("{}{fragment}", &input[..query_index])
134        }
135        None => input.to_string(),
136    }
137}
138
139fn extract_scheme(input: &str) -> Option<String> {
140    let colon_index = input.find(':')?;
141    let candidate = &input[..colon_index];
142    if candidate.is_empty() {
143        return None;
144    }
145    let mut characters = candidate.chars();
146    let first = characters.next()?;
147    if !first.is_ascii_alphabetic() {
148        return None;
149    }
150    if characters
151        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))
152    {
153        Some(candidate.to_ascii_lowercase())
154    } else {
155        None
156    }
157}
158
159fn parse_authority(authority: &str) -> (Option<String>, Option<u16>) {
160    let host_port = authority
161        .rsplit_once('@')
162        .map_or(authority, |(_, tail)| tail);
163
164    if let Some(after_bracket) = host_port.strip_prefix('[') {
165        if let Some(end) = after_bracket.find(']') {
166            let host = &after_bracket[..end];
167            let tail = &after_bracket[end + 1..];
168            let port = tail.strip_prefix(':').and_then(|value| value.parse().ok());
169            return ((!host.is_empty()).then(|| host.to_string()), port);
170        }
171        return (None, None);
172    }
173
174    if let Some((host, port_text)) = host_port.rsplit_once(':') {
175        if !host.is_empty()
176            && !port_text.is_empty()
177            && port_text
178                .chars()
179                .all(|character| character.is_ascii_digit())
180        {
181            return (Some(host.to_string()), port_text.parse().ok());
182        }
183    }
184
185    ((!host_port.is_empty()).then(|| host_port.to_string()), None)
186}
187
188fn split_suffix(input: &str) -> (&str, &str) {
189    let index = input.find(['?', '#']).unwrap_or(input.len());
190    (&input[..index], &input[index..])
191}