#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/encodeurl/0.1.0")]
extern crate alloc;
use alloc::borrow::Cow;
use alloc::string::String;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[must_use]
pub fn encode_url(url: &str) -> Cow<'_, str> {
let bytes = url.as_bytes();
let mut out: Option<String> = None;
let mut idx = 0;
while idx < bytes.len() {
let end = run_end(url, idx);
if end > idx {
let acc = out.get_or_insert_with(|| prefix_string(url, idx));
encode_uri_into(&url[idx..end], acc);
idx = end;
} else {
let len = char_len(url, idx);
if let Some(acc) = out.as_mut() {
acc.push_str(&url[idx..idx + len]);
}
idx += len;
}
}
match out {
Some(s) => Cow::Owned(s),
None => Cow::Borrowed(url),
}
}
fn prefix_string(url: &str, idx: usize) -> String {
let mut s = String::with_capacity(url.len() + 8);
s.push_str(&url[..idx]);
s
}
fn char_len(url: &str, pos: usize) -> usize {
url[pos..].chars().next().map_or(0, char::len_utf8)
}
fn run_end(url: &str, start: usize) -> usize {
let bytes = url.as_bytes();
let mut pos = start;
while pos < bytes.len() {
let b = bytes[pos];
if b == b'%' {
let b1 = bytes.get(pos + 1).copied();
let b2 = bytes.get(pos + 2).copied();
let b1_hex = b1.is_some_and(|x| x.is_ascii_hexdigit());
let b2_hex = b2.is_some_and(|x| x.is_ascii_hexdigit());
if b1_hex && (b2.is_none() || b2_hex) {
break;
}
match b1 {
None => pos += 1, Some(x) if x.is_ascii_hexdigit() => {
pos += 2; pos += char_len(url, pos); }
Some(_) => {
pos += 1; pos += char_len(url, pos); }
}
continue;
}
if b < 0x80 && is_url_safe(b) {
break; }
pos += char_len(url, pos);
}
pos
}
fn encode_uri_into(run: &str, out: &mut String) {
for ch in run.chars() {
if ch.is_ascii() && is_encode_uri_safe(ch as u8) {
out.push(ch);
} else {
let mut buf = [0u8; 4];
for &byte in ch.encode_utf8(&mut buf).as_bytes() {
push_percent(out, byte);
}
}
}
}
fn push_percent(out: &mut String, byte: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
fn is_url_safe(b: u8) -> bool {
matches!(b,
0x21 | 0x23..=0x3B | 0x3D | 0x3F..=0x5F | 0x61..=0x7A | 0x7C | 0x7E)
}
fn is_encode_uri_safe(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b'-'
| b'.'
| b'/'
| b':'
| b';'
| b'='
| b'?'
| b'@'
| b'_'
| b'~'
)
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::borrow::Cow;
fn enc(s: &str) -> String {
encode_url(s).into_owned()
}
#[test]
fn encodes_spaces_and_non_ascii() {
assert_eq!(
enc("http://example.com/foo bar"),
"http://example.com/foo%20bar"
);
assert_eq!(enc("/path?q=café"), "/path?q=caf%C3%A9");
assert_eq!(enc("π"), "%CF%80");
assert_eq!(enc("😀"), "%F0%9F%98%80");
}
#[test]
fn preserves_valid_escapes() {
assert_eq!(enc("%20"), "%20");
assert_eq!(enc("%3c%3C"), "%3c%3C"); assert_eq!(enc("a%2Fb"), "a%2Fb");
}
#[test]
fn escapes_invalid_percent() {
assert_eq!(enc("%G"), "%25G");
assert_eq!(enc("%2G"), "%252G");
assert_eq!(enc("%"), "%25");
assert_eq!(enc("100%done"), "100%25done");
assert_eq!(enc("%zz%41%4"), "%25zz%41%4");
}
#[test]
fn incomplete_escape_at_end_is_kept() {
assert_eq!(enc("%1"), "%1");
assert_eq!(enc("a%4"), "a%4");
}
#[test]
fn run_consumption_edge_cases() {
assert_eq!(enc("%%20"), "%25%2520");
assert_eq!(enc("%["), "%25%5B");
assert_eq!(enc("%é"), "%25%C3%A9");
assert_eq!(enc("%😀"), "%25%F0%9F%98%80");
}
#[test]
fn keeps_url_significant_chars() {
let s = "/path/[id]/?a=1&b=2#frag|x^y\\z";
assert_eq!(enc(s), s);
}
#[test]
fn encodes_unsafe_ascii() {
assert_eq!(enc("a<b>c\"d`e{f}g"), "a%3Cb%3Ec%22d%60e%7Bf%7Dg");
}
#[test]
fn borrows_when_unchanged() {
assert!(matches!(encode_url("/already/clean?x=1"), Cow::Borrowed(_)));
assert!(matches!(encode_url("foo bar"), Cow::Owned(_)));
assert!(matches!(encode_url(""), Cow::Borrowed(_)));
}
}