#![allow(clippy::inherent_to_string)]
pub mod url_class;
pub mod url_search_params;
use std::{path::PathBuf, str::FromStr};
use crate::utils::{
module::{export_default, ModuleInfo},
primordials::{BasePrimordials, Primordial},
result::ResultExt,
};
use rquickjs::{
function::{Constructor, Func},
module::{Declarations, Exports, ModuleDef},
prelude::Opt,
Class, Coerced, Ctx, Exception, Result, Value,
};
use url::{quirks, Url};
use self::url_class::{url_to_http_options, URL};
use self::url_search_params::URLSearchParams;
pub fn is_special_scheme(scheme: &str) -> bool {
matches!(scheme, "http" | "https" | "ftp" | "ws" | "wss" | "file")
}
pub fn domain_to_unicode(domain: &str) -> String {
quirks::domain_to_unicode(domain)
}
pub fn domain_to_ascii(domain: &str) -> String {
quirks::domain_to_ascii(domain)
}
pub fn path_to_file_url<'js>(ctx: Ctx<'js>, path: String, _: Opt<Value>) -> Result<URL<'js>> {
let url = Url::from_file_path(&path)
.map_err(|_| Exception::throw_type(&ctx, &["Path is not absolute: ", &path].concat()))?;
URL::from_url(ctx, url)
}
pub fn file_url_to_path<'js>(ctx: Ctx<'js>, url: Value<'js>) -> Result<String> {
let url_string = if let Ok(url) = Class::<URL>::from_value(&url) {
url.borrow().to_string()
} else {
url.get::<Coerced<String>>()?.to_string()
};
let rest = url_string
.strip_prefix("file://")
.ok_or_else(|| Exception::throw_type(&ctx, "The URL must be of scheme file"))?;
let path = match rest.find('/') {
Some(0) => rest,
_ => return Err(Exception::throw_type(&ctx, "File URL host is not supported")),
};
let path = path.split(['?', '#']).next().unwrap_or(path);
let decoded = decode_file_url_path(&ctx, path)?;
Ok(PathBuf::from_str(&decoded)
.or_throw(&ctx)?
.to_string_lossy()
.to_string())
}
fn decode_file_url_path(ctx: &Ctx<'_>, path: &str) -> Result<String> {
let bytes = path.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(byte) => {
if byte == b'/' {
return Err(Exception::throw_type(
ctx,
"File URL path must not include encoded / characters",
));
}
out.push(byte);
i += 3;
continue;
},
Err(_) => {
return Err(Exception::throw_type(ctx, "Invalid percent-encoding in file URL"))
},
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).map_err(|_| Exception::throw_type(ctx, "File URL path is not valid UTF-8"))
}
pub fn url_format<'js>(url: Class<'js, URL<'js>>, options: Opt<Value<'js>>) -> Result<String> {
let url = url.borrow();
let mut string = url.protocol();
string.push_str("//");
let mut include_fragment = true;
let mut unicode_encode = false;
let mut include_auth = true;
let mut include_search = true;
if let Some(options) = options.into_inner() {
if let Some(options) = options.as_object() {
if let Ok(value) = options.get("unicode") {
unicode_encode = value;
}
if let Ok(value) = options.get("auth") {
include_auth = value;
}
if let Ok(value) = options.get("fragment") {
include_fragment = value;
}
if let Ok(value) = options.get("search") {
include_search = value
}
}
}
if include_auth {
let username = url.username();
let password = url.password();
if !username.is_empty() {
string.push_str(&username);
if !password.is_empty() {
string.push(':');
string.push_str(&password);
}
string.push('@');
}
}
if unicode_encode {
string.push_str(&domain_to_unicode(&url.host()));
} else {
string.push_str(&url.host());
}
string.push_str(&url.pathname());
if include_search {
string.push_str(&url.search());
}
if include_fragment {
string.push_str(&url.hash());
}
Ok(string)
}
pub fn convert_trailing_space(url: &mut Url) {
if is_special_scheme(url.scheme()) {
return;
}
let path = url.path();
let has_remaining = url.fragment().is_some() || url.query().is_some();
#[allow(clippy::manual_strip)]
if path.ends_with(' ') && has_remaining {
let new_path = [&path[..path.len() - 1], "%20"].concat();
url.set_path(&new_path);
}
}
pub fn preserve_file_url_host(source: &str, mut url: Url) -> Url {
if url.scheme() != "file" {
return url;
}
if url.host_str().is_some_and(|h| !h.is_empty()) {
return url;
}
let Some(rest) = source.strip_prefix("file://") else {
return url;
};
let Some((host, _)) = rest.split_once('/') else {
return url;
};
if host.is_empty() {
return url;
}
let _ = url.set_host(Some(host));
url
}
pub fn restore_file_url_host(base: &Url, joined: &mut Url) {
if base.scheme() != "file" || joined.scheme() != "file" {
return;
}
let Some(base_host) = base.host_str() else {
return;
};
if base_host.is_empty() {
return;
}
if joined.host_str().is_some_and(|h| !h.is_empty()) {
return;
}
let base_path = base.path();
let is_drive_letter_first_seg = base_path
.as_bytes()
.get(1)
.is_some_and(|b| b.is_ascii_alphabetic())
&& base_path.as_bytes().get(2) == Some(&b':')
&& matches!(base_path.as_bytes().get(3), Some(&b'/') | None);
if !is_drive_letter_first_seg {
return;
}
let _ = joined.set_host(Some(base_host));
}
pub fn normalize_windows_drive_letter(url: &mut Url) {
if url.scheme() != "file" {
return;
}
let path = url.path();
let bytes = path.as_bytes();
if bytes.len() < 4
|| bytes[0] != b'/'
|| !bytes[1].is_ascii_alphabetic()
|| bytes[2] != b'|'
|| bytes[3] != b'/'
{
return;
}
let new_path = ["/", &path[1..2], ":", &path[3..]].concat();
url.set_path(&new_path);
}
pub fn erase_empty_host_path(url: &mut Url) {
if is_special_scheme(url.scheme()) {
return;
}
if url.path() != "/" {
return;
}
let serialized = url.as_str();
let Some(scheme_end) = serialized.find("://") else {
return;
};
let authority_and_path = &serialized[scheme_end + 3..];
if authority_and_path != "/" {
return;
}
let stripped = &serialized[..serialized.len() - 1];
if let Ok(reparsed) = Url::parse(stripped) {
*url = reparsed;
}
}
pub fn strip_path_sentinel(url: &mut Url) {
if is_special_scheme(url.scheme()) {
return;
}
if !url.path().starts_with("//") {
return;
}
let serialized = url.as_str();
let Some(auth_start) = serialized.find("://") else {
return;
};
let after_auth = auth_start + 3;
let Some(path_start_rel) = serialized[after_auth..].find('/') else {
return;
};
let path_idx = after_auth + path_start_rel;
if serialized[path_idx..].starts_with("/./") {
let stripped = [&serialized[..path_idx], &serialized[path_idx + 2..]].concat();
if let Ok(reparsed) = Url::parse(&stripped) {
*url = reparsed;
}
}
}
pub fn init(ctx: &Ctx<'_>) -> Result<()> {
let globals = ctx.globals();
Class::<URLSearchParams>::define(&globals)?;
Class::<URL>::define(&globals)?;
Ok(())
}
pub struct UrlModule;
impl ModuleDef for UrlModule {
fn declare(declare: &Declarations) -> Result<()> {
declare.declare(stringify!(URL))?;
declare.declare(stringify!(URLSearchParams))?;
declare.declare("urlToHttpOptions")?;
declare.declare("domainToUnicode")?;
declare.declare("domainToASCII")?;
declare.declare("fileURLToPath")?;
declare.declare("pathToFileURL")?;
declare.declare("format")?;
declare.declare("default")?;
Ok(())
}
fn evaluate<'js>(ctx: &Ctx<'js>, exports: &Exports<'js>) -> Result<()> {
let globals = ctx.globals();
BasePrimordials::init(ctx)?;
let url: Constructor = globals.get(stringify!(URL))?;
let url_search_params: Constructor = globals.get(stringify!(URLSearchParams))?;
export_default(ctx, exports, |default| {
default.set(stringify!(URL), url)?;
default.set(stringify!(URLSearchParams), url_search_params)?;
default.set("urlToHttpOptions", Func::from(url_to_http_options))?;
default.set(
"domainToUnicode",
Func::from(|domain: String| domain_to_unicode(&domain)),
)?;
default.set(
"domainToASCII",
Func::from(|domain: String| domain_to_ascii(&domain)),
)?;
default.set("fileURLToPath", Func::from(file_url_to_path))?;
default.set("pathToFileURL", Func::from(path_to_file_url))?;
default.set("format", Func::from(url_format))?;
Ok(())
})?;
Ok(())
}
}
impl From<UrlModule> for ModuleInfo<UrlModule> {
fn from(val: UrlModule) -> Self {
ModuleInfo {
name: "url",
module: val,
}
}
}