diff --git a/Cargo.lock b/Cargo.lock index 727077f93d..d603594cca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,6 +741,7 @@ name = "liburl_rust" version = "0.1.0" dependencies = [ "cbindgen", + "libregex_rust", "libunicode_rust", ] diff --git a/Libraries/LibRegex/Rust/Cargo.toml b/Libraries/LibRegex/Rust/Cargo.toml index bb79360247..59a131848b 100644 --- a/Libraries/LibRegex/Rust/Cargo.toml +++ b/Libraries/LibRegex/Rust/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib", "rlib"] # After changing dependencies, regenerate the Flatpak sources: # python3 Meta/CMake/flatpak/generate-cargo-sources.py diff --git a/Libraries/LibURL/CMakeLists.txt b/Libraries/LibURL/CMakeLists.txt index 4e84618304..659a69f85a 100644 --- a/Libraries/LibURL/CMakeLists.txt +++ b/Libraries/LibURL/CMakeLists.txt @@ -24,4 +24,5 @@ ladybird_lib(LibURL url) target_link_libraries(LibURL PRIVATE liburl_rust LibUnicode LibTextCodec LibRegex) import_rust_crate(MANIFEST_PATH Rust/Cargo.toml CRATE_NAME liburl_rust FEATURES allocator FFI_HEADER RustFFI.h) +target_link_libraries(liburl_rust INTERFACE LibUnicode LibTextCodec LibRegex) target_compile_definitions(LibURL PRIVATE ENABLE_RUST) diff --git a/Libraries/LibURL/Rust/Cargo.toml b/Libraries/LibURL/Rust/Cargo.toml index a61a99cdc6..7934af24a8 100644 --- a/Libraries/LibURL/Rust/Cargo.toml +++ b/Libraries/LibURL/Rust/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" crate-type = ["staticlib"] [dependencies] +libregex_rust = { path = "../../LibRegex/Rust" } libunicode_rust = { path = "../../LibUnicode/Rust" } [features] diff --git a/Libraries/LibURL/Rust/src/ffi.rs b/Libraries/LibURL/Rust/src/ffi.rs index af95a9a3ae..d527b88c26 100644 --- a/Libraries/LibURL/Rust/src/ffi.rs +++ b/Libraries/LibURL/Rust/src/ffi.rs @@ -4,4 +4,5 @@ * SPDX-License-Identifier: BSD-2-Clause */ +pub mod pattern; pub mod url; diff --git a/Libraries/LibURL/Rust/src/ffi/pattern.rs b/Libraries/LibURL/Rust/src/ffi/pattern.rs new file mode 100644 index 0000000000..53fd60c48a --- /dev/null +++ b/Libraries/LibURL/Rust/src/ffi/pattern.rs @@ -0,0 +1,640 @@ +/* + * Copyright (c) 2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use std::ffi::c_void; +use std::panic::AssertUnwindSafe; +use std::panic::catch_unwind; + +use crate::ffi::url::RustFfiUrl; +use crate::ffi::url::RustUrlByteSlice; +use crate::pattern::ComponentResult; +use crate::pattern::GroupMatch; +use crate::pattern::IgnoreCase; +use crate::pattern::Init; +use crate::pattern::Input; +use crate::pattern::MatchInput; +use crate::pattern::Pattern; +use crate::pattern::PatternErrorOr; +use crate::pattern::Result as MatchResult; +use crate::url::Url; + +/// Opaque URLPattern handle. +pub struct RustUrlPattern(Pattern); + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct RustUrlPatternInit { + pub has_protocol: bool, + pub protocol: RustUrlByteSlice, + pub has_username: bool, + pub username: RustUrlByteSlice, + pub has_password: bool, + pub password: RustUrlByteSlice, + pub has_hostname: bool, + pub hostname: RustUrlByteSlice, + pub has_port: bool, + pub port: RustUrlByteSlice, + pub has_pathname: bool, + pub pathname: RustUrlByteSlice, + pub has_search: bool, + pub search: RustUrlByteSlice, + pub has_hash: bool, + pub hash: RustUrlByteSlice, + pub has_base_url: bool, + pub base_url: RustUrlByteSlice, +} + +#[repr(u8)] +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RustUrlPatternComponent { + Protocol, + Username, + Password, + Hostname, + Port, + Pathname, + Search, + Hash, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct RustUrlPatternGroup { + pub name: RustUrlByteSlice, + pub has_value: bool, + pub value: RustUrlByteSlice, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct RustUrlPatternComponentResult { + pub input: RustUrlByteSlice, + pub groups: *const RustUrlPatternGroup, + pub group_count: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct RustUrlPatternResultInput { + pub is_string: bool, + pub string: RustUrlByteSlice, + pub init: RustUrlPatternInit, +} + +#[repr(C)] +pub struct RustUrlPatternExecResult { + pub inputs: *const RustUrlPatternResultInput, + pub input_count: usize, + pub protocol: RustUrlPatternComponentResult, + pub username: RustUrlPatternComponentResult, + pub password: RustUrlPatternComponentResult, + pub hostname: RustUrlPatternComponentResult, + pub port: RustUrlPatternComponentResult, + pub pathname: RustUrlPatternComponentResult, + pub search: RustUrlPatternComponentResult, + pub hash: RustUrlPatternComponentResult, +} + +pub type FfiUrlPatternResultFn = unsafe extern "C" fn(*mut c_void, *const RustUrlPatternExecResult); + +fn abort_on_panic R, R>(f: F) -> R { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(result) => result, + Err(_) => std::process::abort(), + } +} + +fn decode_utf8(slice: RustUrlByteSlice) -> String { + if slice.data.is_null() { + return String::new(); + } + // SAFETY: slice.data is valid for slice.length bytes (contract with C++ caller). + let bytes = unsafe { std::slice::from_raw_parts(slice.data, slice.length) }; + String::from_utf8_lossy(bytes).into_owned() +} + +fn byte_slice(input: &str) -> RustUrlByteSlice { + RustUrlByteSlice { + data: input.as_ptr(), + length: input.len(), + } +} + +fn empty_byte_slice() -> RustUrlByteSlice { + RustUrlByteSlice { + data: std::ptr::null(), + length: 0, + } +} + +fn optional_string_from_raw(ptr: *const u8, len: usize) -> Option { + if ptr.is_null() { + return None; + } + + Some(decode_utf8(RustUrlByteSlice { data: ptr, length: len })) +} + +fn optional_string_from_ffi(has_value: bool, slice: RustUrlByteSlice) -> Option { + if !has_value { + return None; + } + + Some(decode_utf8(slice)) +} + +fn init_from_ffi(ffi: &RustUrlPatternInit) -> Init { + Init { + protocol: optional_string_from_ffi(ffi.has_protocol, ffi.protocol), + username: optional_string_from_ffi(ffi.has_username, ffi.username), + password: optional_string_from_ffi(ffi.has_password, ffi.password), + hostname: optional_string_from_ffi(ffi.has_hostname, ffi.hostname), + port: optional_string_from_ffi(ffi.has_port, ffi.port), + pathname: optional_string_from_ffi(ffi.has_pathname, ffi.pathname), + search: optional_string_from_ffi(ffi.has_search, ffi.search), + hash: optional_string_from_ffi(ffi.has_hash, ffi.hash), + base_url: optional_string_from_ffi(ffi.has_base_url, ffi.base_url), + } +} + +fn init_to_ffi(init: &Init) -> RustUrlPatternInit { + fn optional_string_to_ffi(value: Option<&String>) -> (bool, RustUrlByteSlice) { + value + .map(|value| (true, byte_slice(value))) + .unwrap_or((false, empty_byte_slice())) + } + + let (has_protocol, protocol) = optional_string_to_ffi(init.protocol.as_ref()); + let (has_username, username) = optional_string_to_ffi(init.username.as_ref()); + let (has_password, password) = optional_string_to_ffi(init.password.as_ref()); + let (has_hostname, hostname) = optional_string_to_ffi(init.hostname.as_ref()); + let (has_port, port) = optional_string_to_ffi(init.port.as_ref()); + let (has_pathname, pathname) = optional_string_to_ffi(init.pathname.as_ref()); + let (has_search, search) = optional_string_to_ffi(init.search.as_ref()); + let (has_hash, hash) = optional_string_to_ffi(init.hash.as_ref()); + let (has_base_url, base_url) = optional_string_to_ffi(init.base_url.as_ref()); + + RustUrlPatternInit { + has_protocol, + protocol, + has_username, + username, + has_password, + password, + has_hostname, + hostname, + has_port, + port, + has_pathname, + pathname, + has_search, + search, + has_hash, + hash, + has_base_url, + base_url, + } +} + +fn url_from_ffi(ffi: &RustFfiUrl) -> Url { + super::url::url_from_ffi(ffi) +} + +fn component_pattern_string(pattern: &Pattern, component: RustUrlPatternComponent) -> &str { + match component { + RustUrlPatternComponent::Protocol => &pattern.protocol_component().pattern_string, + RustUrlPatternComponent::Username => &pattern.username_component().pattern_string, + RustUrlPatternComponent::Password => &pattern.password_component().pattern_string, + RustUrlPatternComponent::Hostname => &pattern.hostname_component().pattern_string, + RustUrlPatternComponent::Port => &pattern.port_component().pattern_string, + RustUrlPatternComponent::Pathname => &pattern.pathname_component().pattern_string, + RustUrlPatternComponent::Search => &pattern.search_component().pattern_string, + RustUrlPatternComponent::Hash => &pattern.hash_component().pattern_string, + } +} + +#[derive(Default)] +struct ExecResultStorage { + inputs: Vec, + protocol_groups: Vec, + username_groups: Vec, + password_groups: Vec, + hostname_groups: Vec, + port_groups: Vec, + pathname_groups: Vec, + search_groups: Vec, + hash_groups: Vec, + ffi_result: Option, +} + +impl ExecResultStorage { + fn input_to_ffi(input: &Input) -> RustUrlPatternResultInput { + match input { + Input::String(value) => RustUrlPatternResultInput { + is_string: true, + string: byte_slice(value), + init: RustUrlPatternInit { + has_protocol: false, + protocol: empty_byte_slice(), + has_username: false, + username: empty_byte_slice(), + has_password: false, + password: empty_byte_slice(), + has_hostname: false, + hostname: empty_byte_slice(), + has_port: false, + port: empty_byte_slice(), + has_pathname: false, + pathname: empty_byte_slice(), + has_search: false, + search: empty_byte_slice(), + has_hash: false, + hash: empty_byte_slice(), + has_base_url: false, + base_url: empty_byte_slice(), + }, + }, + Input::Init(value) => RustUrlPatternResultInput { + is_string: false, + string: empty_byte_slice(), + init: init_to_ffi(value), + }, + } + } + + fn component_result_to_ffi( + component_result: &ComponentResult, + groups: &mut Vec, + ) -> RustUrlPatternComponentResult { + groups.reserve(component_result.groups.len()); + for (name, value) in &component_result.groups { + let (has_value, value) = match value { + GroupMatch::String(value) => (true, byte_slice(value)), + GroupMatch::Empty => (false, empty_byte_slice()), + }; + + groups.push(RustUrlPatternGroup { + name: byte_slice(name), + has_value, + value, + }); + } + + RustUrlPatternComponentResult { + input: byte_slice(&component_result.input), + groups: groups.as_ptr(), + group_count: groups.len(), + } + } + + fn fill_from_result(&mut self, result: &MatchResult) { + self.inputs.reserve(result.inputs.len()); + for input in &result.inputs { + self.inputs.push(Self::input_to_ffi(input)); + } + + let protocol = Self::component_result_to_ffi(&result.protocol, &mut self.protocol_groups); + let username = Self::component_result_to_ffi(&result.username, &mut self.username_groups); + let password = Self::component_result_to_ffi(&result.password, &mut self.password_groups); + let hostname = Self::component_result_to_ffi(&result.hostname, &mut self.hostname_groups); + let port = Self::component_result_to_ffi(&result.port, &mut self.port_groups); + let pathname = Self::component_result_to_ffi(&result.pathname, &mut self.pathname_groups); + let search = Self::component_result_to_ffi(&result.search, &mut self.search_groups); + let hash = Self::component_result_to_ffi(&result.hash, &mut self.hash_groups); + + self.ffi_result = Some(RustUrlPatternExecResult { + inputs: self.inputs.as_ptr(), + input_count: self.inputs.len(), + protocol, + username, + password, + hostname, + port, + pathname, + search, + hash, + }); + } +} + +fn write_error(error_out: *mut *const u8, error_len_out: *mut usize, message: String) { + if error_out.is_null() || error_len_out.is_null() { + return; + } + + let leaked = message.into_bytes().into_boxed_slice(); + // SAFETY: caller provided valid output pointers. + unsafe { + *error_len_out = leaked.len(); + *error_out = Box::into_raw(leaked) as *const u8; + } +} + +/// Compile a URLPattern from a constructor string. +/// +/// # Safety +/// `input` must be valid for `input_len` bytes of UTF-8. If `base_url` is non-null, +/// it must be valid for `base_url_len` bytes of UTF-8. `error_out` and +/// `error_len_out` must be valid pointers when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_create_from_string( + input: *const u8, + input_len: usize, + base_url: *const u8, + base_url_len: usize, + ignore_case: IgnoreCase, + error_out: *mut *const u8, + error_len_out: *mut usize, +) -> *mut RustUrlPattern { + abort_on_panic(|| { + if input.is_null() { + return std::ptr::null_mut(); + } + + // SAFETY: caller guarantees input is valid for input_len bytes. + let input_bytes = unsafe { std::slice::from_raw_parts(input, input_len) }; + let Ok(input_str) = std::str::from_utf8(input_bytes) else { + return std::ptr::null_mut(); + }; + let base_url = optional_string_from_raw(base_url, base_url_len); + + match Pattern::create(&Input::String(input_str.to_string()), &base_url, ignore_case) { + Ok(pattern) => Box::into_raw(Box::new(RustUrlPattern(pattern))), + Err(error) => { + write_error(error_out, error_len_out, error.message); + std::ptr::null_mut() + } + } + }) +} + +/// Compile a URLPattern from an init dictionary. +/// +/// # Safety +/// `init` must be a valid pointer. `error_out` and `error_len_out` must be +/// valid pointers when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_create_from_init( + init: *const RustUrlPatternInit, + ignore_case: IgnoreCase, + error_out: *mut *const u8, + error_len_out: *mut usize, +) -> *mut RustUrlPattern { + abort_on_panic(|| { + let Some(init) = (unsafe { init.as_ref() }) else { + return std::ptr::null_mut(); + }; + let init = init_from_ffi(init); + + match Pattern::create(&Input::Init(init), &None, ignore_case) { + Ok(pattern) => Box::into_raw(Box::new(RustUrlPattern(pattern))), + Err(error) => { + write_error(error_out, error_len_out, error.message); + std::ptr::null_mut() + } + } + }) +} + +/// Free an error string returned by a URLPattern create function. +/// +/// # Safety +/// `error` must be a pointer returned via a create function's `error_out`, or null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_free_error(error: *mut u8, len: usize) { + if !error.is_null() { + // SAFETY: pointer originates from `Box::into_raw` in `write_error`. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(error, len)) }); + } +} + +/// Free a compiled URLPattern. +/// +/// # Safety +/// `pattern` must be a valid pointer returned by a create function, or null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_free(pattern: *mut RustUrlPattern) { + if !pattern.is_null() { + // SAFETY: pointer originates from `Box::into_raw` in a create function. + drop(unsafe { Box::from_raw(pattern) }); + } +} + +/// Return whether the pattern contains regexp groups. +/// +/// # Safety +/// `pattern` must be a valid pointer returned by a create function. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_has_regexp_groups(pattern: *const RustUrlPattern) -> bool { + if pattern.is_null() { + return false; + } + + // SAFETY: caller guarantees pattern is valid. + let pattern = unsafe { &*pattern }; + pattern.0.has_regexp_groups() +} + +/// Return a component's canonical pattern string as a borrowed slice. +/// +/// The returned slice is valid until `pattern` is freed. +/// +/// # Safety +/// `pattern` must be a valid pointer returned by a create function. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_component_pattern_string( + pattern: *const RustUrlPattern, + component: RustUrlPatternComponent, +) -> RustUrlByteSlice { + if pattern.is_null() { + return empty_byte_slice(); + } + + // SAFETY: caller guarantees pattern is valid. + let pattern = unsafe { &*pattern }; + let pattern_string = component_pattern_string(&pattern.0, component); + RustUrlByteSlice { + data: pattern_string.as_ptr(), + length: pattern_string.len(), + } +} + +fn exec_match_result( + result: PatternErrorOr>, + error_out: *mut *const u8, + error_len_out: *mut usize, + ctx: *mut c_void, + on_complete: FfiUrlPatternResultFn, +) -> bool { + match result { + Ok(Some(result)) => { + let mut storage = ExecResultStorage::default(); + storage.fill_from_result(&result); + // SAFETY: callback is provided by caller and `ffi_result` points into `storage`, + // which remains alive for the duration of the callback. + unsafe { on_complete(ctx, storage.ffi_result.as_ref().unwrap()) }; + true + } + Ok(None) => { + // SAFETY: callback is provided by caller. + unsafe { on_complete(ctx, std::ptr::null()) }; + true + } + Err(error) => { + write_error(error_out, error_len_out, error.message); + false + } + } +} + +/// Execute a compiled pattern against a string input and return a borrowed view +/// of the match result to a callback. +/// +/// # Safety +/// `pattern` must be valid. `input` must be valid for `input_len` bytes of UTF-8. +/// If `base_url` is non-null, it must be valid for `base_url_len` bytes of UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_exec_string( + pattern: *const RustUrlPattern, + input: *const u8, + input_len: usize, + base_url: *const u8, + base_url_len: usize, + error_out: *mut *const u8, + error_len_out: *mut usize, + ctx: *mut c_void, + on_complete: FfiUrlPatternResultFn, +) -> bool { + abort_on_panic(|| { + if pattern.is_null() || input.is_null() { + return false; + } + + // SAFETY: caller guarantees pointers are valid. + let pattern = unsafe { &*pattern }; + let input_bytes = unsafe { std::slice::from_raw_parts(input, input_len) }; + let Ok(input_str) = std::str::from_utf8(input_bytes) else { + return false; + }; + let base_url = optional_string_from_raw(base_url, base_url_len); + + exec_match_result( + pattern.0.r#match(&MatchInput::String(input_str.to_string()), &base_url), + error_out, + error_len_out, + ctx, + on_complete, + ) + }) +} + +/// Execute a compiled pattern against an init dictionary and return a borrowed +/// view of the match result to a callback. +/// +/// # Safety +/// `pattern` and `input` must be valid pointers. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_exec_init( + pattern: *const RustUrlPattern, + input: *const RustUrlPatternInit, + error_out: *mut *const u8, + error_len_out: *mut usize, + ctx: *mut c_void, + on_complete: FfiUrlPatternResultFn, +) -> bool { + abort_on_panic(|| { + if pattern.is_null() || input.is_null() { + return false; + } + + // SAFETY: caller guarantees pointers are valid. + let pattern = unsafe { &*pattern }; + let input = init_from_ffi(unsafe { &*input }); + + exec_match_result( + pattern.0.r#match(&MatchInput::Init(input), &None), + error_out, + error_len_out, + ctx, + on_complete, + ) + }) +} + +/// Test a compiled pattern against a string input. +/// +/// # Safety +/// `pattern` must be valid. `input` must be valid for `input_len` bytes of UTF-8. +/// If `base_url` is non-null, it must be valid for `base_url_len` bytes of UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_test_string( + pattern: *const RustUrlPattern, + input: *const u8, + input_len: usize, + base_url: *const u8, + base_url_len: usize, +) -> bool { + abort_on_panic(|| { + if pattern.is_null() || input.is_null() { + return false; + } + + // SAFETY: caller guarantees pointers are valid. + let pattern = unsafe { &*pattern }; + let input_bytes = unsafe { std::slice::from_raw_parts(input, input_len) }; + let Ok(input_str) = std::str::from_utf8(input_bytes) else { + return false; + }; + let base_url = optional_string_from_raw(base_url, base_url_len); + + matches!( + pattern.0.r#match(&MatchInput::String(input_str.to_string()), &base_url), + Ok(Some(_)) + ) + }) +} + +/// Test a compiled pattern against an init dictionary. +/// +/// # Safety +/// `pattern` and `input` must be valid pointers. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_test_init( + pattern: *const RustUrlPattern, + input: *const RustUrlPatternInit, +) -> bool { + abort_on_panic(|| { + if pattern.is_null() || input.is_null() { + return false; + } + + // SAFETY: caller guarantees pointers are valid. + let pattern = unsafe { &*pattern }; + let input = init_from_ffi(unsafe { &*input }); + + matches!(pattern.0.r#match(&MatchInput::Init(input), &None), Ok(Some(_))) + }) +} + +/// Test a compiled pattern against a parsed URL. +/// +/// # Safety +/// `pattern` and `input` must be valid pointers. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rust_url_pattern_test_url(pattern: *const RustUrlPattern, input: *const RustFfiUrl) -> bool { + abort_on_panic(|| { + if pattern.is_null() || input.is_null() { + return false; + } + + // SAFETY: caller guarantees pointers are valid. + let pattern = unsafe { &*pattern }; + let input = url_from_ffi(unsafe { &*input }); + + matches!(pattern.0.r#match(&MatchInput::Url(input), &None), Ok(Some(_))) + }) +} diff --git a/Libraries/LibURL/Rust/src/ffi/url.rs b/Libraries/LibURL/Rust/src/ffi/url.rs index 6f1cb497e1..ea78a8665d 100644 --- a/Libraries/LibURL/Rust/src/ffi/url.rs +++ b/Libraries/LibURL/Rust/src/ffi/url.rs @@ -158,7 +158,7 @@ fn host_to_ffi(host: Option<&Host>) -> FfiUrlHost { } } -fn url_from_ffi(ffi: &RustFfiUrl) -> Url { +pub(crate) fn url_from_ffi(ffi: &RustFfiUrl) -> Url { let scheme = decode_utf8(ffi.scheme); let path = if !ffi.path_segments.is_null() { // SAFETY: path_segments is valid for path_segment_count elements. diff --git a/Libraries/LibURL/Rust/src/lib.rs b/Libraries/LibURL/Rust/src/lib.rs index 946d999e61..2637a18a3b 100644 --- a/Libraries/LibURL/Rust/src/lib.rs +++ b/Libraries/LibURL/Rust/src/lib.rs @@ -9,6 +9,7 @@ mod rust_allocator; mod ffi; +pub mod pattern; mod textcodec; pub mod url; diff --git a/Libraries/LibURL/Rust/src/pattern/canonicalization.rs b/Libraries/LibURL/Rust/src/pattern/canonicalization.rs new file mode 100644 index 0000000000..b65bd61c98 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/canonicalization.rs @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::ErrorInfo; +use crate::pattern::PatternErrorOr; +use crate::url::BasicParseOptions; +use crate::url::State; +use crate::url::Url; +use crate::url::basic_parse; +use crate::url::basic_parse_into; + +// https://urlpattern.spec.whatwg.org/#url-pattern-create-a-dummy-url +fn create_a_dummy_url() -> Url { + // 1. Let dummyInput be "https://dummy.invalid/". + // 2. Return the result of running the basic URL parser on dummyInput. + basic_parse("https://dummy.invalid/", BasicParseOptions::new()).unwrap() +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-protocol +pub fn canonicalize_a_protocol(value: &str) -> PatternErrorOr { + // 1. If value is the empty string, return value. + if value.is_empty() { + return Ok(value.to_string()); + } + + // 2. Let parseResult be the result of running the basic URL parser given value followed by "://dummy.invalid/". + // NOTE: Note, state override is not used here because it enforces restrictions that are only appropriate for the + // protocol setter. Instead we use the protocol to parse a dummy URL using the normal parsing entry point. + let parse_result = basic_parse(&format!("{value}://dummy.invalid"), BasicParseOptions::new()); + + // 4. If parseResult is failure, then throw a TypeError. + let Some(parse_result) = parse_result else { + return Err(ErrorInfo::new("Failed to canonicalize URL protocol string")); + }; + + // 5. Return parseResult’s scheme. + Ok(parse_result.scheme) +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-username +pub fn canonicalize_a_username(value: &str) -> String { + // 1. If value is the empty string, return value. + if value.is_empty() { + return value.to_string(); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Set the username given dummyURL and value. + dummy_url.set_username(value); + + // 4. Return dummyURL’s username. + dummy_url.username +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-password +pub fn canonicalize_a_password(value: &str) -> String { + // 1. If value is the empty string, return value. + if value.is_empty() { + return value.to_string(); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Set the password given dummyURL and value. + dummy_url.set_password(value); + + // 4. Return dummyURL’s password. + dummy_url.password +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-hostname +pub fn canonicalize_a_hostname(value: &str) -> PatternErrorOr { + // 1. If value is the empty string, return value. + if value.is_empty() { + return Ok(value.to_string()); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Let parseResult be the result of running the basic URL parser given value with dummyURL + // as url and hostname state as state override. + let parse_result = basic_parse_into( + value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::Hostname), + ); + + // 4. If parseResult is failure, then throw a TypeError. + if !parse_result { + return Err(ErrorInfo::new("Failed to canonicalize URL hostname string")); + } + + // 5. Return dummyURL’s host, serialized, or empty string if it is null. + if dummy_url.host.is_none() { + return Ok(String::new()); + } + Ok(dummy_url.serialized_host()) +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-an-ipv6-hostname +pub fn canonicalize_an_ipv6_hostname(value: &str) -> PatternErrorOr { + // 1. Let result be the empty string. + let mut result = String::new(); + + // 2. For each code point in value interpreted as a list of code points: + for code_point in value.chars() { + // 1. If all of the following are true: + // * code point is not an ASCII hex digit; + // * code point is not U+005B ([); + // * code point is not U+005D (]); and + // * code point is not U+003A (:), + // then throw a TypeError. + if !code_point.is_ascii_hexdigit() && code_point != '[' && code_point != ']' && code_point != ':' { + return Err(ErrorInfo::new("Failed to canonicalize IPv6 hostname string")); + } + + // 2. Append the result of running ASCII lowercase given code point to the end of result. + result.push(code_point.to_ascii_lowercase()); + } + + // 3. Return result. + Ok(result) +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-port +pub fn canonicalize_a_port(port_value: &str, protocol_value: &Option) -> PatternErrorOr { + // 1. If portValue is the empty string, return portValue. + if port_value.is_empty() { + return Ok(port_value.to_string()); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. If protocolValue was given, then set dummyURL’s scheme to protocolValue. + // NOTE: Note, we set the URL record's scheme in order for the basic URL parser to + // recognize and normalize default port values. + if let Some(protocol_value) = protocol_value { + dummy_url.set_scheme(protocol_value.clone()); + } + + // 4. Let parseResult be the result of running basic URL parser given portValue with dummyURL + // as url and port state as state override. + let parse_result = basic_parse_into( + port_value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::Port), + ); + + // 4. If parseResult is failure, then throw a TypeError. + if !parse_result { + return Err(ErrorInfo::new("Failed to canonicalize port string")); + } + + // 5. Return dummyURL’s port, serialized, or empty string if it is null. + if dummy_url.port.is_none() { + return Ok(String::new()); + } + Ok(dummy_url.port.unwrap().to_string()) +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-pathname +pub fn canonicalize_a_pathname(value: &str) -> String { + // 1. If value is the empty string, then return value. + if value.is_empty() { + return value.to_string(); + } + + // 2. Let leading slash be true if the first code point in value is U+002F (/) and otherwise false. + let leading_slash = value.as_bytes()[0] == b'/'; + + // 3. Let modified value be "/-" if leading slash is false and otherwise the empty string. + let mut modified_value = String::new(); + if !leading_slash { + modified_value.push_str("/-"); + } + + // 4. Append value to the end of modified value. + modified_value.push_str(value); + + // 5. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 6. Empty dummyURL’s path. + dummy_url.set_paths(&[]); + + // 7. Run basic URL parser given modified value with dummyURL as url and path start state as state override. + let _ = basic_parse_into( + &modified_value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::PathStart), + ); + + // 8. Let result be the result of URL path serializing dummyURL. + let mut result = dummy_url.serialize_path(); + + // 9. If leading slash is false, then set result to the code point substring from 2 to the end of the string within result. + if !leading_slash { + result = result.chars().skip(2).collect(); + } + + // 10. Return result. + result +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-an-opaque-pathname +pub fn canonicalize_an_opaque_pathname(value: &str) -> PatternErrorOr { + // 1. If value is the empty string, return value. + if value.is_empty() { + return Ok(value.to_string()); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Set dummyURL’s path to the empty string. + dummy_url.set_paths(&[""]); + dummy_url.set_has_an_opaque_path(true); + + // 4. Let parseResult be the result of running URL parsing given value with dummyURL as url and opaque path state as state override. + let parse_result = basic_parse_into( + value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::OpaquePath), + ); + + // 5. If parseResult is failure, then throw a TypeError. + if !parse_result { + return Err(ErrorInfo::new("Failed to canonicalize opaque pathname string")); + } + + // 6. Return the result of URL path serializing dummyURL. + Ok(dummy_url.serialize_path()) +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-search +pub fn canonicalize_a_search(value: &str) -> String { + // 1. If value is the empty string, return value. + if value.is_empty() { + return value.to_string(); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Set dummyURL’s query to the empty string. + dummy_url.set_query(Some(String::new())); + + // 4. Run basic URL parser given value with dummyURL as url and query state as state override. + let _ = basic_parse_into( + value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::Query), + ); + + // 5. Return dummyURL’s query. + dummy_url.query.expect("query should be present") +} + +// https://urlpattern.spec.whatwg.org/#canonicalize-a-hash +pub fn canonicalize_a_hash(value: &str) -> String { + // 1. If value is the empty string, return value. + if value.is_empty() { + return value.to_string(); + } + + // 2. Let dummyURL be the result of creating a dummy URL. + let mut dummy_url = create_a_dummy_url(); + + // 3. Set dummyURL’s fragment to the empty string. + dummy_url.set_fragment(Some(String::new())); + + // 4. Run basic URL parser given value with dummyURL as url and fragment state as state override. + let _ = basic_parse_into( + value, + &mut dummy_url, + &BasicParseOptions::new().state_override(State::Fragment), + ); + + // 5. Return dummyURL’s fragment. + dummy_url.fragment.expect("fragment should be present") +} diff --git a/Libraries/LibURL/Rust/src/pattern/component.rs b/Libraries/LibURL/Rust/src/pattern/component.rs new file mode 100644 index 0000000000..7d344af414 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/component.rs @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use std::collections::BTreeMap; +use std::fmt; +use std::rc::Rc; + +use libregex_rust::ast::Flags; +use libregex_rust::regex::Regex; +use libregex_rust::vm::VmResult; + +use crate::pattern::EncodingCallback; +use crate::pattern::ErrorInfo; +use crate::pattern::Options; +use crate::pattern::Part; +use crate::pattern::PatternErrorOr; +use crate::pattern::PatternParser; +use crate::pattern::escape_a_regexp_string; +use crate::pattern::full_wildcard_regexp_value; +use crate::pattern::generate_a_pattern_string; +use crate::pattern::generate_a_segment_wildcard_regexp; +use crate::pattern::part::Modifier as PartModifier; +use crate::pattern::part::Type as PartType; +use crate::url::special_schemes; + +// https://urlpattern.spec.whatwg.org/#component +#[derive(Clone, Default)] +pub struct Component { + // https://urlpattern.spec.whatwg.org/#component-pattern-string + // pattern string, a well formed pattern string + pub pattern_string: String, + + // https://urlpattern.spec.whatwg.org/#component-regular-expression + // regular expression, a RegExp + pub regular_expression: Option, + + // https://urlpattern.spec.whatwg.org/#component-group-name-list + // group name list, a list of strings + pub group_name_list: Vec, + + // https://urlpattern.spec.whatwg.org/#component-has-regexp-groups + // has regexp groups, a boolean + pub has_regexp_groups: bool, +} + +#[derive(Clone)] +pub struct RegularExpression(Rc); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GroupMatch { + String(String), + Empty, +} + +// https://urlpattern.spec.whatwg.org/#dictdef-urlpatterncomponentresult +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Result { + pub input: String, + pub groups: BTreeMap, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ExecutionResult { + pub success: bool, + pub captures: Vec>, +} + +// https://urlpattern.spec.whatwg.org/#generate-a-regular-expression-and-name-list +struct RegularExpressionAndNameList { + regular_expression: String, + name_list: Vec, +} + +impl Component { + // https://urlpattern.spec.whatwg.org/#compile-a-component + pub fn compile(input: &str, encoding_callback: EncodingCallback, options: &Options) -> PatternErrorOr { + // 1. Let part list be the result of running parse a pattern string given input, options, and encoding callback. + let part_list = PatternParser::parse(input, options, encoding_callback)?; + + // 2. Let (regular expression string, name list) be the result of running generate a regular expression and name + // list given part list and options. + let RegularExpressionAndNameList { + regular_expression: regular_expression_string, + name_list, + } = generate_a_regular_expression_and_name_list(&part_list, options); + + // 3. Let flags be an empty string. + // NOTE: These flags match the flags for the empty string of the LibJS RegExp implementation. + let mut flags = Flags::default(); + + // 4. If options’s ignore case is true then set flags to "vi". + if options.ignore_case { + flags.unicode_sets = true; + flags.ignore_case = true; + } + // 5. Otherwise set flags to "v" + else { + flags.unicode_sets = true; + } + + // 6. Let regular expression be RegExpCreate(regular expression string, flags). If this throws an exception, catch + // it, and throw a TypeError. + let regex = Regex::compile(®ular_expression_string, flags) + .map_err(|error| ErrorInfo::new(format!("RegExp compile error: {error}")))?; + + // 7. Let pattern string be the result of running generate a pattern string given part list and options. + let pattern_string = generate_a_pattern_string(&part_list, options); + + // 8. Let has regexp groups be false. + let mut has_regexp_groups = false; + + // 9. For each part of part list: + for part in &part_list { + // 1. If part’s type is "regexp", then set has regexp groups to true. + if part.r#type == PartType::Regexp { + has_regexp_groups = true; + break; + } + } + + // 10. Return a new component whose pattern string is pattern string, regular expression is regular expression, + // group name list is name list, and has regexp groups is has regexp groups. + Ok(Self { + pattern_string, + regular_expression: Some(RegularExpression::new(regex)), + group_name_list: name_list, + has_regexp_groups, + }) + } + + // https://urlpattern.spec.whatwg.org/#create-a-component-match-result + pub fn create_match_result(&self, input: &str, exec_result: &ExecutionResult) -> Result { + // 1. Let result be a new URLPatternComponentResult. + // 2. Set result["input"] to input. + let mut result = Result { + input: input.to_string(), + ..Result::default() + }; + + // 3. Let groups be a record. + let mut groups = BTreeMap::new(); + + // 4. Let index be 1. + // 5. While index is less than or equal to component’s group name list’s size: + assert!(exec_result.captures.len() == self.group_name_list.len()); + for index in 1..=self.group_name_list.len() { + // 1. Let name be component’s group name list[index − 1]. + let name = self.group_name_list[index - 1].clone(); + + // 2. Let value be Get(execResult, ToString(index)). + // 3. Set groups[name] to value. + let capture = &exec_result.captures[index - 1]; + if let Some(capture) = capture { + groups.insert(name, GroupMatch::String(capture.clone())); + } else { + groups.insert(name, GroupMatch::Empty); + } + + // 4. Increment index by 1. + } + + // 6. Set result["groups"] to groups. + result.groups = groups; + + // 7. Return result. + result + } + + pub fn execute(&self, input: &str) -> ExecutionResult { + let Some(regular_expression) = self.regular_expression.as_ref() else { + return ExecutionResult::default(); + }; + + let utf16_input: Vec = input.encode_utf16().collect(); + let capture_count = regular_expression.capture_count() as usize; + let mut captures = vec![-1; (capture_count + 1) * 2]; + let match_result = regular_expression.exec_into(&utf16_input, 0, &mut captures); + if match_result != VmResult::Match { + return ExecutionResult::default(); + } + + let mut result = ExecutionResult { + success: true, + captures: Vec::with_capacity(self.group_name_list.len()), + }; + + for index in 1..=self.group_name_list.len() { + let start = captures[index * 2]; + let end = captures[index * 2 + 1]; + if start < 0 || end < 0 { + result.captures.push(None); + continue; + } + + let capture = String::from_utf16_lossy(&utf16_input[start as usize..end as usize]); + result.captures.push(Some(capture)); + } + + result + } + + pub fn matches(&self, input: &str) -> bool { + let Some(regular_expression) = self.regular_expression.as_ref() else { + return false; + }; + + let utf16_input: Vec = input.encode_utf16().collect(); + regular_expression.test(&utf16_input, 0) == VmResult::Match + } +} + +// https://urlpattern.spec.whatwg.org/#protocol-component-matches-a-special-scheme +pub fn protocol_component_matches_a_special_scheme(protocol_component: &Component) -> bool { + // 1. Let special scheme list be a list populated with all of the special schemes. + // 2. For each scheme of special scheme list: + for scheme in special_schemes() { + // 1. Let test result be RegExpBuiltinExec(protocol component’s regular expression, scheme). + let test_result = protocol_component.matches(scheme); + + // 2. If test result is not null, then return true. + if test_result { + return true; + } + } + + // 3. Return false. + false +} + +impl RegularExpression { + fn new(regex: Regex) -> Self { + Self(Rc::new(regex)) + } + + fn capture_count(&self) -> u32 { + self.0.capture_count() + } + + fn exec_into(&self, input: &[u16], start: usize, out: &mut [i32]) -> VmResult { + self.0.exec_into(input, start, out) + } + + fn test(&self, input: &[u16], start: usize) -> VmResult { + self.0.test(input, start) + } +} + +impl fmt::Debug for Component { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Component") + .field("pattern_string", &self.pattern_string) + .field( + "regular_expression", + &self.regular_expression.as_ref().map(|_| "RegularExpression"), + ) + .field("group_name_list", &self.group_name_list) + .field("has_regexp_groups", &self.has_regexp_groups) + .finish() + } +} + +impl fmt::Debug for RegularExpression { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RegularExpression(..)") + } +} + +fn generate_a_regular_expression_and_name_list(part_list: &[Part], options: &Options) -> RegularExpressionAndNameList { + // 1. Let result be "^". + let mut result = String::from("^"); + + // 2. Let name list be a new list. + let mut name_list = Vec::new(); + + // 3. For each part of part list: + for part in part_list { + // 1. If part’s type is "fixed-text": + if part.r#type == PartType::FixedText { + // 1. If part’s modifier is "none", then append the result of running escape a regexp string given part’s + // value to the end of result. + if part.modifier == PartModifier::None { + result.push_str(&escape_a_regexp_string(&part.value)); + } + // 2. Otherwise: + else { + // 1. Append "(?:" to the end of result. + result.push_str("(?:"); + + // 2. Append the result of running escape a regexp string given part’s value to the end of result. + result.push_str(&escape_a_regexp_string(&part.value)); + + // 3. Append ")" to the end of result. + result.push(')'); + + // 4. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + } + + // 3. Continue. + continue; + } + + // 2. Assert: part’s name is not the empty string. + assert!(!part.name.is_empty()); + + // 3. Append part’s name to name list. + name_list.push(part.name.clone()); + + // 4. Let regexp value be part’s value. + let mut regexp_value = part.value.clone(); + + // 5. If part’s type is "segment-wildcard", then set regexp value to the result of running generate a segment wildcard regexp given options. + if part.r#type == PartType::SegmentWildcard { + regexp_value = generate_a_segment_wildcard_regexp(options); + } + // 6. Otherwise if part’s type is "full-wildcard", then set regexp value to full wildcard regexp value. + else if part.r#type == PartType::FullWildcard { + regexp_value = full_wildcard_regexp_value.to_string(); + } + + // 7. If part’s prefix is the empty string and part’s suffix is the empty string: + if part.prefix.is_empty() && part.suffix.is_empty() { + // 1. If part’s modifier is "none" or "optional", then: + if part.modifier == PartModifier::None || part.modifier == PartModifier::Optional { + // 1. Append "(" to the end of result. + result.push('('); + + // 2. Append regexp value to the end of result. + result.push_str(®exp_value); + + // 3. Append ")" to the end of result. + result.push(')'); + + // 4. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + } + // 2. Otherwise: + else { + // 1. Append "((?:" to the end of result. + result.push_str("((?:"); + + // 2. Append regexp value to the end of result. + result.push_str(®exp_value); + + // 3. Append ")" to the end of result. + result.push(')'); + + // 4. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + + // 5. Append ")" to the end of result. + result.push(')'); + } + + // 3. Continue. + continue; + } + + // 8. If part’s modifier is "none" or "optional": + if part.modifier == PartModifier::None || part.modifier == PartModifier::Optional { + // 1. Append "(?:" to the end of result. + result.push_str("(?:"); + + // 2. Append the result of running escape a regexp string given part’s prefix to the end of result. + result.push_str(&escape_a_regexp_string(&part.prefix)); + + // 3. Append "(" to the end of result. + result.push('('); + + // 4. Append regexp value to the end of result. + result.push_str(®exp_value); + + // 5. Append ")" to the end of result. + result.push(')'); + + // 6. Append the result of running escape a regexp string given part’s suffix to the end of result. + result.push_str(&escape_a_regexp_string(&part.suffix)); + + // 7. Append ")" to the end of result. + result.push(')'); + + // 8. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + + // 9. Continue. + continue; + } + + // 9. Assert: part’s modifier is "zero-or-more" or "one-or-more". + assert!(part.modifier == PartModifier::ZeroOrMore || part.modifier == PartModifier::OneOrMore); + + // 10. Assert: part’s prefix is not the empty string or part’s suffix is not the empty string. + assert!(!part.prefix.is_empty() || !part.suffix.is_empty()); + + // 11. Append "(?:" to the end of result. + result.push_str("(?:"); + + // 12. Append the result of running escape a regexp string given part’s prefix to the end of result. + result.push_str(&escape_a_regexp_string(&part.prefix)); + + // 13. Append "((?:" to the end of result. + result.push_str("((?:"); + + // 14. Append regexp value to the end of result. + result.push_str(®exp_value); + + // 15. Append ")(?:" to the end of result. + result.push_str(")(?:"); + + // 16. Append the result of running escape a regexp string given part’s suffix to the end of result. + result.push_str(&escape_a_regexp_string(&part.suffix)); + + // 17. Append the result of running escape a regexp string given part’s prefix to the end of result. + result.push_str(&escape_a_regexp_string(&part.prefix)); + + // 18. Append "(?:" to the end of result. + result.push_str("(?:"); + + // 19. Append regexp value to the end of result. + result.push_str(®exp_value); + + // 20. Append "))*)" to the end of result. + result.push_str("))*)"); + + // 21. Append the result of running escape a regexp string given part’s suffix to the end of result. + result.push_str(&escape_a_regexp_string(&part.suffix)); + + // 22. Append ")" to the end of result. + result.push(')'); + + // 23. If part’s modifier is "zero-or-more" then append "?" to the end of result. + if part.modifier == PartModifier::ZeroOrMore { + result.push('?'); + } + } + + // 4. Append "$" to the end of result. + result.push('$'); + + // 5. Return (result, name list). + RegularExpressionAndNameList { + regular_expression: result, + name_list, + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/constructor_string_parser.rs b/Libraries/LibURL/Rust/src/pattern/constructor_string_parser.rs new file mode 100644 index 0000000000..ba4884189a --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/constructor_string_parser.rs @@ -0,0 +1,712 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::Component; +use crate::pattern::Init; +use crate::pattern::Options; +use crate::pattern::PatternErrorOr; +use crate::pattern::Token; +use crate::pattern::Tokenizer; +use crate::pattern::canonicalize_a_protocol; +use crate::pattern::protocol_component_matches_a_special_scheme; +use crate::pattern::tokenizer::Policy as TokenizerPolicy; +use crate::pattern::tokenizer::Type as TokenType; + +// https://urlpattern.spec.whatwg.org/#constructor-string-parser +pub struct ConstructorStringParser { + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-input + // A constructor string parser has an associated input, a string, which must be set upon creation. + pub input: String, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-token-list + // A constructor string parser has an associated token list, a token list, which must be set upon creation. + pub token_list: Vec, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-result + // A constructor string parser has an associated result, a URLPatternInit, initially set to a new URLPatternInit. + pub result: Init, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-component-start + // A constructor string parser has an associated component start, a number, initially set to 0. + pub component_start: u32, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-token-index + // A constructor string parser has an associated token index, a number, initially set to 0. + pub token_index: u32, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-token-increment + // A constructor string parser has an associated token increment, a number, initially set to 1. + pub token_increment: u32, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-group-depth + // A constructor string parser has an associated group depth, a number, initially set to 0. + pub group_depth: u32, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-hostname-ipv6-bracket-depth + // A constructor string parser has an associated hostname IPv6 bracket depth, a number, initially set to 0. + pub hostname_ipv6_bracket_depth: u32, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-protocol-matches-a-special-scheme-flag + // A constructor string parser has an associated protocol matches a special scheme flag, a boolean, initially set to false. + pub protocol_matches_a_special_scheme: bool, + + // https://urlpattern.spec.whatwg.org/#constructor-string-parser-state + // A constructor string parser has an associated state, a string, initially set to "init". + pub state: State, + + input_code_points: Vec, +} + +// https://urlpattern.spec.whatwg.org/#constructor-string-parser-state +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum State { + Initial, + Protocol, + Authority, + Username, + Password, + Hostname, + Port, + Pathname, + Search, + Hash, + Done, +} + +impl ConstructorStringParser { + pub(crate) fn new(input: &str, token_list: Vec) -> Self { + Self { + input: input.to_string(), + token_list, + result: Init::default(), + component_start: 0, + token_index: 0, + token_increment: 1, + group_depth: 0, + hostname_ipv6_bracket_depth: 0, + protocol_matches_a_special_scheme: false, + state: State::Initial, + input_code_points: input.chars().collect(), + } + } + + // https://urlpattern.spec.whatwg.org/#parse-a-constructor-string + pub fn parse(input: &str) -> PatternErrorOr { + // 1. Let parser be a new constructor string parser whose input is input and token list is the result of running + // tokenize given input and "lenient". + let mut parser = Self::new(input, Tokenizer::tokenize(input, TokenizerPolicy::Lenient)?); + + // 2. While parser’s token index is less than parser’s token list size: + while (parser.token_index as usize) < parser.token_list.len() { + // 1. Set parser’s token increment to 1. + parser.token_increment = 1; + + // NOTE: On every iteration of the parse loop the parser’s token index will be incremented by its token + // increment value. Typically this means incrementing by 1, but at certain times it is set to zero. + // The token increment is then always reset back to 1 at the top of the loop. + + // 2. If parser’s token list[parser’s token index]'s type is "end" then: + if parser.token_list[parser.token_index as usize].r#type == TokenType::End { + // 1. If parser’s state is "init": + if parser.state == State::Initial { + // NOTE: If we reached the end of the string in the "init" state, then we failed to find a protocol + // terminator and this has to be a relative URLPattern constructor string. + + // 1. Run rewind given parser. + parser.rewind(); + + // NOTE: We next determine at which component the relative pattern begins. Relative pathnames are + // most common, but URLs and URLPattern constructor strings can begin with the search or hash + // components as well. + + // 2. If the result of running is a hash prefix given parser is true, then run change state given parser, + // "hash" and 1. + if parser.is_a_hash_prefix() { + parser.change_state(State::Hash, 1); + } + // 3. Otherwise if the result of running is a search prefix given parser is true: + else if parser.is_a_search_prefix() { + // 1. Run change state given parser, "search" and 1. + parser.change_state(State::Search, 1); + } + // 4. Otherwise: + else { + // 1. Run change state given parser, "pathname" and 0. + parser.change_state(State::Pathname, 0); + } + + // 5. Increment parser’s token index by parser’s token increment. + parser.token_index += parser.token_increment; + + // 6. Continue. + continue; + } + + // 2. If parser’s state is "authority": + if parser.state == State::Authority { + // NOTE: If we reached the end of the string in the "authority" state, then we failed to find an + // "@". Therefore there is no username or password. + + // 1. Run rewind and set state given parser, and "hostname". + parser.rewind_and_set_state(State::Hostname); + + // 2. Increment parser’s token index by parser’s token increment. + parser.token_index += parser.token_increment; + + // 3. Continue. + continue; + } + + // 3. Run change state given parser, "done" and 0. + parser.change_state(State::Done, 0); + + // 4. Break. + break; + } + + // 3. If the result of running is a group open given parser is true: + if parser.is_a_group_open() { + // NOTE: We ignore all code points within "{ ... }" pattern groupings. It would not make sense to allow + // a URL component boundary to lie within a grouping; e.g. "https://example.c{om/fo}o". While not + // supported within well formed pattern strings, we handle nested groupings here to avoid parser + // confusion. + // + // It is not necessary to perform this logic for regexp or named groups since those values are collapsed into + // individual tokens by the tokenize algorithm. + + // 1. Increment parser’s group depth by 1. + parser.group_depth += 1; + + // 2. Increment parser’s token index by parser’s token increment. + parser.token_index += parser.token_increment; + + // 3. Continue. + continue; + } + + // 4. If parser’s group depth is greater than 0: + if parser.group_depth > 0 { + // 1. If the result of running is a group close given parser is true, then decrement parser’s group depth by 1. + if parser.is_a_group_close() { + assert!(parser.group_depth != 0); + parser.group_depth -= 1; + } + // 2. Otherwise: + else { + // 1. Increment parser’s token index by parser’s token increment. + parser.token_index += parser.token_increment; + + // 2. Continue. + continue; + } + } + + // 5. Switch on parser’s state and run the associated steps: + match parser.state { + // -> "init", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-init%E2%91%A2 + State::Initial => { + // 1. If the result of running is a protocol suffix given parser is true: + if parser.is_a_protocol_suffix() { + // 1. Run rewind and set state given parser and "protocol". + parser.rewind_and_set_state(State::Protocol); + } + } + // -> "protocol", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-protocol%E2%91%A0 + State::Protocol => { + // 1. If the result of running is a protocol suffix given parser is true: + if parser.is_a_protocol_suffix() { + // 1. Run compute protocol matches a special scheme flag given parser. + parser.compute_protocol_matches_a_special_scheme_flag()?; + + // NOTE: We need to eagerly compile the protocol component to determine if it matches any special + // schemes. If it does then certain special rules apply. It determines if the pathname + // defaults to a "/" and also whether we will look for the username, password, hostname, and + // port components. Authority slashes can also cause us to look for these components as well. + // Otherwise we treat this as an "opaque path URL" and go straight to the pathname component. + + // 2. Let next state be "pathname". + let mut next_state = State::Pathname; + + // 3. Let skip be 1. + let mut skip = 1; + + // 4. If the result of running next is authority slashes given parser is true: + if parser.next_is_authority_slashes() { + // 1. Set next state to "authority". + next_state = State::Authority; + + // 2. Set skip to 3. + skip = 3; + } + // 5. Otherwise if parser’s protocol matches a special scheme flag is true, then set next state to "authority". + else if parser.protocol_matches_a_special_scheme { + next_state = State::Authority; + } + + // 6. Run change state given parser, next state, and skip. + parser.change_state(next_state, skip); + } + } + // -> "authority", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-authority%E2%91%A3 + State::Authority => { + // 1. If the result of running is an identity terminator given parser is true, then run rewind and set state + // given parser and "username". + if parser.is_an_identity_terminator() { + parser.rewind_and_set_state(State::Username); + } + // 2. Otherwise if any of the following are true: + // * the result of running is a pathname start given parser; + // * the result of running is a search prefix given parser; or + // * the result of running is a hash prefix given parser, + // then run rewind and set state given parser and "hostname". + else if parser.is_a_pathname_start() || parser.is_a_search_prefix() || parser.is_a_hash_prefix() { + parser.rewind_and_set_state(State::Hostname); + } + } + // -> "username", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-username%E2%91%A0 + State::Username => { + // 1. If the result of running is a password prefix given parser is true, then run change state given + // parser, "password", and 1. + if parser.is_a_password_prefix() { + parser.change_state(State::Password, 1); + } + // 2. Otherwise if the result of running is an identity terminator given parser is true, then run change + // state given parser, "hostname", and 1. + else if parser.is_an_identity_terminator() { + parser.change_state(State::Hostname, 1); + } + } + // -> "password", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-password%E2%91%A0 + State::Password => { + // 1. If the result of running is an identity terminator given parser is true, then run change state + // given parser, "hostname", and 1. + if parser.is_an_identity_terminator() { + parser.change_state(State::Hostname, 1); + } + } + // -> "hostname", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-hostname%E2%91%A3 + State::Hostname => { + // 1. If the result of running is an IPv6 open given parser is true, then increment parser’s hostname + // IPv6 bracket depth by 1. + if parser.is_an_ipv6_open() { + parser.hostname_ipv6_bracket_depth += 1; + } + // 2. Otherwise if the result of running is an IPv6 close given parser is true, then decrement parser’s + // hostname IPv6 bracket depth by 1. + else if parser.is_an_ipv6_close() { + assert!(parser.hostname_ipv6_bracket_depth != 0); + parser.hostname_ipv6_bracket_depth -= 1; + } + // 3. Otherwise if the result of running is a port prefix given parser is true and parser’s hostname IPv6 + // bracket depth is zero, then run change state given parser, "port", and 1. + else if parser.is_a_port_prefix() && parser.hostname_ipv6_bracket_depth == 0 { + parser.change_state(State::Port, 1); + } + // 4. Otherwise if the result of running is a pathname start given parser is true, then run change state + // given parser, "pathname", and 0. + else if parser.is_a_pathname_start() { + parser.change_state(State::Pathname, 0); + } + // 5. Otherwise if the result of running is a search prefix given parser is true, then run change state + // given parser, "search", and 1. + else if parser.is_a_search_prefix() { + parser.change_state(State::Search, 1); + } + // 6. Otherwise if the result of running is a hash prefix given parser is true, then run change state + // given parser, "hash", and 1. + else if parser.is_a_hash_prefix() { + parser.change_state(State::Hash, 1); + } + } + // -> "port", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-port%E2%91%A0 + State::Port => { + // 1. If the result of running is a pathname start given parser is true, then run change state given + // parser, "pathname", and 0. + if parser.is_a_pathname_start() { + parser.change_state(State::Pathname, 0); + } + // 2. Otherwise if the result of running is a search prefix given parser is true, then run change state + // given parser, "search", and 1. + else if parser.is_a_search_prefix() { + parser.change_state(State::Search, 1); + } + // 3. Otherwise if the result of running is a hash prefix given parser is true, then run change state given + // parser, "hash", and 1. + else if parser.is_a_hash_prefix() { + parser.change_state(State::Hash, 1); + } + } + // -> "pathname", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-pathname%E2%91%A3 + State::Pathname => { + // 1. If the result of running is a search prefix given parser is true, then run change state given parser, + // "search", and 1. + if parser.is_a_search_prefix() { + parser.change_state(State::Search, 1); + } + // 2. Otherwise if the result of running is a hash prefix given parser is true, then run change state given + // parser, "hash", and 1. + else if parser.is_a_hash_prefix() { + parser.change_state(State::Hash, 1); + } + } + // -> "search", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-search%E2%91%A3 + State::Search => { + // 1. If the result of running is a hash prefix given parser is true, then run change state given parser, + // "hash", and 1. + if parser.is_a_hash_prefix() { + parser.change_state(State::Hash, 1); + } + } + // -> "hash", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-hash%E2%91%A4 + State::Hash => { + // 1. Do nothing. + } + // -> "done", https://urlpattern.spec.whatwg.org/#ref-for-constructor-string-parser-state-done%E2%91%A0 + State::Done => { + // 1. Assert: This step is never reached. + unreachable!(); + } + } + + // 6. Increment parser’s token index by parser’s token increment. + parser.token_index += parser.token_increment; + } + + // 3. If parser’s result contains "hostname" and not "port", then set parser’s result["port"] to the empty string. + if parser.result.hostname.is_some() && parser.result.port.is_none() { + parser.result.port = Some(String::new()); + } + + // NOTE: This is special-cased because when an author does not specify a port, they usually intend the default + // port. If any port is acceptable, the author can specify it as a wildcard explicitly. For example, + // "https://example.com/*" does not match URLs beginning with "https://example.com:8443/", which is a + // different origin. + + // 4. Return parser’s result. + Ok(parser.result) + } + + // https://urlpattern.spec.whatwg.org/#make-a-component-string + pub(crate) fn make_a_component_string(&self) -> String { + // 1. Assert: parser’s token index is less than parser’s token list's size. + assert!((self.token_index as usize) < self.token_list.len()); + + // 2. Let token be parser’s token list[parser’s token index]. + let token = &self.token_list[self.token_index as usize]; + + // 3. Let component start token be the result of running get a safe token given parser and parser’s component start. + let component_start_token = self.get_a_safe_token(self.component_start); + + // 4. Let component start input index be component start token’s index. + let component_start_input_index = component_start_token.index; + + // 5. Let end index be token’s index. + let end_index = token.index; + + // 6. Return the code point substring from component start input index to end index within parser’s input. + self.input_code_points[component_start_input_index as usize..end_index as usize] + .iter() + .collect() + } + + // https://urlpattern.spec.whatwg.org/#compute-protocol-matches-a-special-scheme-flag + pub(crate) fn compute_protocol_matches_a_special_scheme_flag(&mut self) -> PatternErrorOr<()> { + // 1. Let protocol string be the result of running make a component string given parser. + let protocol_string = self.make_a_component_string(); + + // 2. Let protocol component be the result of compiling a component given protocol string, canonicalize a protocol, and default options. + let protocol_component = Component::compile( + &protocol_string, + Box::new(canonicalize_a_protocol), + &Options::default_(), + )?; + + // 3. If the result of running protocol component matches a special scheme given protocol component is true, then set parser’s protocol matches a special scheme flag to true. + if protocol_component_matches_a_special_scheme(&protocol_component) { + self.protocol_matches_a_special_scheme = true; + } + + Ok(()) + } + + pub(crate) fn set_result_for_active_state(&mut self, value: Option) { + match self.state { + State::Protocol => self.result.protocol = value, + State::Username => self.result.username = value, + State::Password => self.result.password = value, + State::Hostname => self.result.hostname = value, + State::Port => self.result.port = value, + State::Pathname => self.result.pathname = value, + State::Search => self.result.search = value, + State::Hash => self.result.hash = value, + State::Initial | State::Authority | State::Done => unreachable!(), + } + } + + // https://urlpattern.spec.whatwg.org/#change-state + pub(crate) fn change_state(&mut self, new_state: State, skip: u32) { + // 1. If parser’s state is not "init", not "authority", and not "done", then set parser’s result[parser’s state] to + // the result of running make a component string given parser. + if self.state != State::Initial && self.state != State::Authority && self.state != State::Done { + self.set_result_for_active_state(Some(self.make_a_component_string())); + } + + // 2. If parser’s state is not "init" and new state is not "done", then: + if self.state != State::Initial && new_state != State::Done { + // 1. If parser’s state is "protocol", "authority", "username", or "password"; new state is "port", "pathname", + // "search", or "hash"; and parser’s result["hostname"] does not exist, then set parser’s result["hostname"] + // to the empty string. + if matches!( + self.state, + State::Protocol | State::Authority | State::Username | State::Password + ) && matches!(new_state, State::Port | State::Pathname | State::Search | State::Hash) + && self.result.hostname.is_none() + { + self.result.hostname = Some(String::new()); + } + + // 2. If parser’s state is "protocol", "authority", "username", "password", "hostname", or "port"; new state is + // "search" or "hash"; and parser’s result["pathname"] does not exist, then: + if matches!( + self.state, + State::Protocol | State::Authority | State::Username | State::Password | State::Hostname | State::Port + ) && matches!(new_state, State::Search | State::Hash) + && self.result.pathname.is_none() + { + // 1. If parser’s protocol matches a special scheme flag is true, then set parser’s result["pathname"] to "/". + if self.protocol_matches_a_special_scheme { + self.result.pathname = Some("/".to_string()); + } + // 2. Otherwise, set parser’s result["pathname"] to the empty string. + else { + self.result.pathname = Some(String::new()); + } + } + + // 3. If parser’s state is "protocol", "authority", "username", "password", "hostname", "port", or "pathname"; + // new state is "hash"; and parser’s result["search"] does not exist, then set parser’s result["search"] + // to the empty string. + if matches!( + self.state, + State::Protocol + | State::Authority + | State::Username + | State::Password + | State::Hostname + | State::Port + | State::Pathname + ) && new_state == State::Hash + && self.result.search.is_none() + { + self.result.search = Some(String::new()); + } + } + + // 3. Set parser’s state to new state. + self.state = new_state; + + // 4. Increment parser’s token index by skip. + self.token_index += skip; + + // 5. Set parser’s component start to parser’s token index. + self.component_start = self.token_index; + + // 6. Set parser’s token increment to 0. + self.token_increment = 0; + } + + // https://urlpattern.spec.whatwg.org/#next-is-authority-slashes + pub(crate) fn next_is_authority_slashes(&self) -> bool { + // 1. If the result of running is a non-special pattern char given parser, parser’s token index + 1, and "/" is false, + // then return false. + if !self.is_a_non_special_pattern_char(self.token_index + 1, '/') { + return false; + } + + // 2. If the result of running is a non-special pattern char given parser, parser’s token index + 2, and "/" is false, + // then return false. + if !self.is_a_non_special_pattern_char(self.token_index + 2, '/') { + return false; + } + + // 3. Return true. + true + } + + // https://urlpattern.spec.whatwg.org/#is-an-identity-terminator + pub(crate) fn is_an_identity_terminator(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and "@". + self.is_a_non_special_pattern_char(self.token_index, '@') + } + + // https://urlpattern.spec.whatwg.org/#is-a-password-prefix + pub(crate) fn is_a_password_prefix(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and ":". + self.is_a_non_special_pattern_char(self.token_index, ':') + } + + // https://urlpattern.spec.whatwg.org/#is-a-port-prefix + pub(crate) fn is_a_port_prefix(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and ":". + self.is_a_non_special_pattern_char(self.token_index, ':') + } + + // https://urlpattern.spec.whatwg.org/#is-a-pathname-start + pub(crate) fn is_a_pathname_start(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and "/". + self.is_a_non_special_pattern_char(self.token_index, '/') + } + + // https://urlpattern.spec.whatwg.org/#is-a-search-prefix + pub(crate) fn is_a_search_prefix(&self) -> bool { + // 1. If result of running is a non-special pattern char given parser, parser’s token index and "?" is true, + // then return true. + if self.is_a_non_special_pattern_char(self.token_index, '?') { + return true; + } + + // 2. If parser’s token list[parser’s token index]'s value is not "?", then return false. + if self.token_list[self.token_index as usize].value != "?" { + return false; + } + + // 3. Let previous index be parser’s token index − 1. + // 4. If previous index is less than 0, then return true. + if self.token_index == 0 { + return true; + } + let previous_index = self.token_index - 1; + + // 5. Let previous token be the result of running get a safe token given parser and previous index. + let previous_token = self.get_a_safe_token(previous_index); + + // 6. If any of the following are true, then return false: + // * previous token’s type is "name". + // * previous token’s type is "regexp". + // * previous token’s type is "close". + // * previous token’s type is "asterisk". + if matches!( + previous_token.r#type, + TokenType::Name | TokenType::Regexp | TokenType::Close | TokenType::Asterisk + ) { + return false; + } + + // 7. Return true. + true + } + + // https://urlpattern.spec.whatwg.org/#is-a-protocol-suffix + pub(crate) fn is_a_protocol_suffix(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and ":". + self.is_a_non_special_pattern_char(self.token_index, ':') + } + + // https://urlpattern.spec.whatwg.org/#is-a-hash-prefix + pub(crate) fn is_a_hash_prefix(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index and "#". + self.is_a_non_special_pattern_char(self.token_index, '#') + } + + // https://urlpattern.spec.whatwg.org/#is-a-group-open + pub(crate) fn is_a_group_open(&self) -> bool { + // 1. If parser’s token list[parser’s token index]'s type is "open", then return true. + if self.token_list[self.token_index as usize].r#type == TokenType::Open { + return true; + } + + // 2. Otherwise return false. + false + } + + // https://urlpattern.spec.whatwg.org/#is-a-group-close + pub(crate) fn is_a_group_close(&self) -> bool { + // 1. If parser’s token list[parser’s token index]'s type is "close", then return true. + if self.token_list[self.token_index as usize].r#type == TokenType::Close { + return true; + } + + // 2. Otherwise return false. + false + } + + // https://urlpattern.spec.whatwg.org/#is-an-ipv6-open + pub(crate) fn is_an_ipv6_open(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and "[". + self.is_a_non_special_pattern_char(self.token_index, '[') + } + + // https://urlpattern.spec.whatwg.org/#is-an-ipv6-close + pub(crate) fn is_an_ipv6_close(&self) -> bool { + // 1. Return the result of running is a non-special pattern char given parser, parser’s token index, and "]". + self.is_a_non_special_pattern_char(self.token_index, ']') + } + + // https://urlpattern.spec.whatwg.org/#get-a-safe-token + pub(crate) fn get_a_safe_token(&self, index: u32) -> &Token { + // 1. If index is less than parser’s token list's size, then return parser’s token list[index]. + if (index as usize) < self.token_list.len() { + return &self.token_list[index as usize]; + } + + // 2. Assert: parser’s token list's size is greater than or equal to 1. + assert!(!self.token_list.is_empty()); + + // 3. Let last index be parser’s token list's size − 1. + // 4. Let token be parser’s token list[last index]. + let token = self.token_list.last().unwrap(); + + // 5. Assert: token’s type is "end". + assert!(token.r#type == TokenType::End); + + // 6. Return token. + token + } + + // https://urlpattern.spec.whatwg.org/#is-a-non-special-pattern-char + pub(crate) fn is_a_non_special_pattern_char(&self, index: u32, value: char) -> bool { + // 1. Let token be the result of running get a safe token given parser and index. + let token = self.get_a_safe_token(index); + + // 2. If token’s value is not value, then return false. + if token.value.is_empty() || token.value.chars().next().unwrap() != value { + return false; + } + + // 3. If any of the following are true: + // * token’s type is "char"; + // * token’s type is "escaped-char"; or + // * token’s type is "invalid-char", + // then return true. + if matches!( + token.r#type, + TokenType::Char | TokenType::EscapedChar | TokenType::InvalidChar + ) { + return true; + } + + // 4. Return false. + false + } + + // https://urlpattern.spec.whatwg.org/#rewind + pub(crate) fn rewind(&mut self) { + // 1. Set parser’s token index to parser’s component start. + self.token_index = self.component_start; + + // 2. Set parser’s token increment to 0. + self.token_increment = 0; + } + + // https://urlpattern.spec.whatwg.org/#rewind-and-set-state + pub(crate) fn rewind_and_set_state(&mut self, state: State) { + // 1. Run rewind given parser. + self.rewind(); + + // 2. Set parser’s state to state. + self.state = state; + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/init.rs b/Libraries/LibURL/Rust/src/pattern/init.rs new file mode 100644 index 0000000000..c0ead940ae --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/init.rs @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::ErrorInfo; +use crate::pattern::PatternErrorOr; +use crate::pattern::canonicalize_a_hash; +use crate::pattern::canonicalize_a_hostname; +use crate::pattern::canonicalize_a_password; +use crate::pattern::canonicalize_a_pathname; +use crate::pattern::canonicalize_a_port; +use crate::pattern::canonicalize_a_protocol; +use crate::pattern::canonicalize_a_search; +use crate::pattern::canonicalize_a_username; +use crate::pattern::canonicalize_an_opaque_pathname; +use crate::pattern::escape_a_pattern_string; +use crate::url::BasicParseOptions; +use crate::url::basic_parse; +use crate::url::is_special_scheme; + +// https://urlpattern.spec.whatwg.org/#dictdef-urlpatterninit +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Init { + pub protocol: Option, + pub username: Option, + pub password: Option, + pub hostname: Option, + pub port: Option, + pub pathname: Option, + pub search: Option, + pub hash: Option, + pub base_url: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PatternProcessType { + Pattern, + Url, +} + +// https://urlpattern.spec.whatwg.org/#process-a-base-url-string +fn process_a_base_url_string(input: &str, r#type: PatternProcessType) -> String { + // 1. Assert: input is not null. + // 2. If type is not "pattern" return input. + if r#type != PatternProcessType::Pattern { + return input.to_string(); + } + + // 3. Return the result of escaping a pattern string given input. + escape_a_pattern_string(input) +} + +// https://urlpattern.spec.whatwg.org/#is-an-absolute-pathname +fn is_an_absolute_pathname(input: &str, r#type: PatternProcessType) -> bool { + // 1. If input is the empty string, then return false. + if input.is_empty() { + return false; + } + + // 2. If input[0] is U+002F (/), then return true. + if input.as_bytes()[0] == b'/' { + return true; + } + + // 3. If type is "url", then return false. + if r#type == PatternProcessType::Url { + return false; + } + + // 4. If input’s code point length is less than 2, then return false. + if input.len() < 2 { + return false; + } + + // 5. If input[0] is U+005C (\) and input[1] is U+002F (/), then return true. + if input.as_bytes()[0] == b'\\' && input.as_bytes()[1] == b'/' { + return true; + } + + // 6. If input[0] is U+007B ({) and input[1] is U+002F (/), then return true. + if input.as_bytes()[0] == b'{' && input.as_bytes()[1] == b'/' { + return true; + } + + // 7. Return false. + false +} + +// https://urlpattern.spec.whatwg.org/#process-protocol-for-init +fn process_protocol_for_init(value: &str, r#type: PatternProcessType) -> PatternErrorOr { + // 1. Let strippedValue be the given value with a single trailing U+003A (:) removed, if any. + let mut stripped_value = value.to_string(); + if stripped_value.ends_with(':') { + stripped_value.pop(); + } + + // 2. If type is "pattern" then return strippedValue. + if r#type == PatternProcessType::Pattern { + return Ok(stripped_value); + } + + // 3. Return the result of running canonicalize a protocol given strippedValue. + canonicalize_a_protocol(&stripped_value) +} + +// https://urlpattern.spec.whatwg.org/#process-username-for-init +fn process_username_for_init(value: &str, r#type: PatternProcessType) -> String { + // 1. If type is "pattern" then return value. + if r#type == PatternProcessType::Pattern { + return value.to_string(); + } + + // 2. Return the result of running canonicalize a username given value. + canonicalize_a_username(value) +} + +// https://urlpattern.spec.whatwg.org/#process-password-for-init +fn process_password_for_init(value: &str, r#type: PatternProcessType) -> String { + // 1. If type is "pattern" then return value. + if r#type == PatternProcessType::Pattern { + return value.to_string(); + } + + // 2. Return the result of running canonicalize a password given value. + canonicalize_a_password(value) +} + +// https://urlpattern.spec.whatwg.org/#process-hostname-for-init +fn process_hostname_for_init(value: &str, r#type: PatternProcessType) -> PatternErrorOr { + // 1. If type is "pattern" then return value. + if r#type == PatternProcessType::Pattern { + return Ok(value.to_string()); + } + + // 2. Return the result of running canonicalize a hostname given value. + canonicalize_a_hostname(value) +} + +// https://urlpattern.spec.whatwg.org/#process-port-for-init +fn process_port_for_init(port_value: &str, protocol_value: &str, r#type: PatternProcessType) -> PatternErrorOr { + // 1. If type is "pattern" then return portValue. + if r#type == PatternProcessType::Pattern { + return Ok(port_value.to_string()); + } + + // 2. Return the result of running canonicalize a port given portValue and protocolValue. + canonicalize_a_port(port_value, &Some(protocol_value.to_string())) +} + +// https://urlpattern.spec.whatwg.org/#process-pathname-for-init +fn process_pathname_for_init( + pathname_value: &str, + protocol_value: &str, + r#type: PatternProcessType, +) -> PatternErrorOr { + // 1. If type is "pattern" then return pathnameValue. + if r#type == PatternProcessType::Pattern { + return Ok(pathname_value.to_string()); + } + + // 2. If protocolValue is a special scheme or the empty string, then return the result of running canonicalize a + // pathname given pathnameValue. + // NOTE: If the protocolValue is the empty string then no value was provided for protocol in the constructor + // dictionary. Normally we do not special case empty string dictionary values, but in this case we treat + // it as a special scheme in order to default to the most common pathname canonicalization. + if protocol_value.is_empty() || is_special_scheme(protocol_value.as_bytes()) { + return Ok(canonicalize_a_pathname(pathname_value)); + } + + // 3. Return the result of running canonicalize an opaque pathname given pathnameValue. + canonicalize_an_opaque_pathname(pathname_value) +} + +// https://urlpattern.spec.whatwg.org/#process-search-for-init +fn process_search_for_init(value: &str, r#type: PatternProcessType) -> String { + // 1. Let strippedValue be the given value with a single leading U+003F (?) removed, if any. + let stripped_value = value.strip_prefix('?').unwrap_or(value); + + // 2. If type is "pattern" then return strippedValue. + if r#type == PatternProcessType::Pattern { + return stripped_value.to_string(); + } + + // 3. Return the result of running canonicalize a search given strippedValue. + canonicalize_a_search(stripped_value) +} + +// https://urlpattern.spec.whatwg.org/#process-hash-for-init +fn process_hash_for_init(value: &str, r#type: PatternProcessType) -> String { + // 1. Let strippedValue be the given value with a single leading U+0023 (#) removed, if any. + let stripped_value = value.strip_prefix('#').unwrap_or(value); + + // 2. If type is "pattern" then return strippedValue. + if r#type == PatternProcessType::Pattern { + return stripped_value.to_string(); + } + + // 3. Return the result of running canonicalize a hash given strippedValue. + canonicalize_a_hash(stripped_value) +} + +// https://urlpattern.spec.whatwg.org/#process-a-urlpatterninit +#[allow(clippy::too_many_arguments)] +pub fn process_a_url_pattern_init( + init: &Init, + r#type: PatternProcessType, + protocol: &Option, + username: &Option, + password: &Option, + hostname: &Option, + port: &Option, + pathname: &Option, + search: &Option, + hash: &Option, +) -> PatternErrorOr { + // 1. Let result be the result of creating a new URLPatternInit. + let mut result = Init::default(); + + // 2. If protocol is not null, set result["protocol"] to protocol. + if let Some(protocol) = protocol { + result.protocol = Some(protocol.clone()); + } + + // 3. If username is not null, set result["username"] to username. + if let Some(username) = username { + result.username = Some(username.clone()); + } + + // 4. If password is not null, set result["password"] to password. + if let Some(password) = password { + result.password = Some(password.clone()); + } + + // 5. If hostname is not null, set result["hostname"] to hostname. + if let Some(hostname) = hostname { + result.hostname = Some(hostname.clone()); + } + + // 6. If port is not null, set result["port"] to port. + if let Some(port) = port { + result.port = Some(port.clone()); + } + + // 7. If pathname is not null, set result["pathname"] to pathname. + if let Some(pathname) = pathname { + result.pathname = Some(pathname.clone()); + } + + // 8. If search is not null, set result["search"] to search. + if let Some(search) = search { + result.search = Some(search.clone()); + } + + // 9. If hash is not null, set result["hash"] to hash. + if let Some(hash) = hash { + result.hash = Some(hash.clone()); + } + + // 10. Let baseURL be null. + let mut base_url = None; + + // 11. If init["baseURL"] exists: + if let Some(init_base_url) = &init.base_url { + // 1. Set baseURL to the result of running the basic URL parser on init["baseURL"]. + base_url = basic_parse(init_base_url, BasicParseOptions::new()); + + // 2. If baseURL is failure, then throw a TypeError. + let Some(base_url_ref) = base_url.as_ref() else { + return Err(ErrorInfo::new(format!( + "Invalid base URL '{init_base_url}' provided for URLPattern" + ))); + }; + + // 3. If init["protocol"] does not exist, then set result["protocol"] to the result of processing a base URL + // string given baseURL’s scheme and type. + if init.protocol.is_none() { + result.protocol = Some(process_a_base_url_string(&base_url_ref.scheme, r#type)); + } + + // 4. If type is not "pattern" and init contains none of "protocol", "hostname", "port" and "username", then + // set result["username"] to the result of processing a base URL string given baseURL’s username and type. + if r#type != PatternProcessType::Pattern + && init.protocol.is_none() + && init.hostname.is_none() + && init.port.is_none() + && init.username.is_none() + { + result.username = Some(process_a_base_url_string(&base_url_ref.username, r#type)); + } + + // 5. If type is not "pattern" and init contains none of "protocol", "hostname", "port", "username" and + // "password", then set result["password"] to the result of processing a base URL string given baseURL’s + // password and type. + if r#type != PatternProcessType::Pattern + && init.protocol.is_none() + && init.hostname.is_none() + && init.port.is_none() + && init.username.is_none() + && init.password.is_none() + { + result.password = Some(process_a_base_url_string(&base_url_ref.password, r#type)); + } + + // 6. If init contains neither "protocol" nor "hostname", then: + if init.protocol.is_none() && init.hostname.is_none() { + // 1. Let baseHost be the serialization of baseURL's host, if it is not null, and the empty string otherwise. + let base_host = if base_url_ref.host.is_some() { + base_url_ref.serialized_host() + } else { + String::new() + }; + + // 2. Set result["hostname"] to the result of processing a base URL string given baseHost and type. + result.hostname = Some(process_a_base_url_string(&base_host, r#type)); + } + + // 7. If init contains none of "protocol", "hostname", and "port", then: + if init.protocol.is_none() && init.hostname.is_none() && init.port.is_none() { + // 1. If baseURL’s port is null, then set result["port"] to the empty string. + if base_url_ref.port.is_none() { + result.port = Some(String::new()); + } + // 2. Otherwise, set result["port"] to baseURL’s port, serialized. + else if let Some(base_url_port) = base_url_ref.port { + result.port = Some(base_url_port.to_string()); + } + } + + // 8. If init contains none of "protocol", "hostname", "port", and "pathname", then set result["pathname"] to + // the result of processing a base URL string given the result of URL path serializing baseURL and type. + if init.protocol.is_none() && init.hostname.is_none() && init.port.is_none() && init.pathname.is_none() { + result.pathname = Some(process_a_base_url_string(&base_url_ref.serialize_path(), r#type)); + } + + // 9. If init contains none of "protocol", "hostname", "port", "pathname", and "search", then: + if init.protocol.is_none() + && init.hostname.is_none() + && init.port.is_none() + && init.pathname.is_none() + && init.search.is_none() + { + // 1. Let baseQuery be baseURL’s query. + let base_query = &base_url_ref.query; + + // 2. If baseQuery is null, then set baseQuery to the empty string. + // 3. Set result["search"] to the result of processing a base URL string given baseQuery and type. + result.search = Some(process_a_base_url_string(base_query.as_deref().unwrap_or(""), r#type)); + } + + // 10. If init contains none of "protocol", "hostname", "port", "pathname", "search", and "hash", then: + if init.protocol.is_none() + && init.hostname.is_none() + && init.port.is_none() + && init.pathname.is_none() + && init.search.is_none() + && init.hash.is_none() + { + // 1. Let baseFragment be baseURL’s fragment. + let base_fragment = &base_url_ref.fragment; + + // 2. If baseFragment is null, then set baseFragment to the empty string. + // 3. Set result["hash"] to the result of processing a base URL string given baseFragment and type. + result.hash = Some(process_a_base_url_string( + base_fragment.as_deref().unwrap_or(""), + r#type, + )); + } + } + + // 12. If init["protocol"] exists, then set result["protocol"] to the result of process protocol for init given init["protocol"] and type. + if let Some(protocol) = &init.protocol { + result.protocol = Some(process_protocol_for_init(protocol, r#type)?); + } + + // 13. If init["username"] exists, then set result["username"] to the result of process username for init given init["username"] and type. + if let Some(username) = &init.username { + result.username = Some(process_username_for_init(username, r#type)); + } + + // 14. If init["password"] exists, then set result["password"] to the result of process password for init given init["password"] and type. + if let Some(password) = &init.password { + result.password = Some(process_password_for_init(password, r#type)); + } + + // 15. If init["hostname"] exists, then set result["hostname"] to the result of process hostname for init given init["hostname"] and type. + if let Some(hostname) = &init.hostname { + result.hostname = Some(process_hostname_for_init(hostname, r#type)?); + } + + // 16. Let resultProtocolString be result["protocol"] if it exists; otherwise the empty string. + let result_protocol_string = result.protocol.clone().unwrap_or_default(); + + // 17. If init["port"] exists, then set result["port"] to the result of process port for init given init["port"], resultProtocolString, and type. + if let Some(port) = &init.port { + result.port = Some(process_port_for_init(port, &result_protocol_string, r#type)?); + } + + // 18. If init["pathname"] exists: + if let Some(init_pathname) = &init.pathname { + // 1. Set result["pathname"] to init["pathname"]. + result.pathname = Some(init_pathname.clone()); + + // 2. If the following are all true: + // * baseURL is not null; + // * baseURL does not have an opaque path; and + // * the result of running is an absolute pathname given result["pathname"] and type is false, + // then: + if let Some(base_url) = base_url.as_ref() + && !base_url.has_opaque_path + && !is_an_absolute_pathname(result.pathname.as_deref().unwrap(), r#type) + { + // 1. Let baseURLPath be the result of running process a base URL string given the result of URL path + // serializing baseURL and type. + let base_url_path = process_a_base_url_string(&base_url.serialize_path(), r#type); + + // 2. Let slash index be the index of the last U+002F (/) code point found in baseURLPath, interpreted as a + // sequence of code points, or null if there are no instances of the code point. + let slash_index = base_url_path.rfind('/'); + + // 3. If slash index is not null: + if let Some(slash_index) = slash_index { + // 1. Let new pathname be the code point substring from 0 to slash index + 1 within baseURLPath. + let mut new_pathname = base_url_path[..slash_index + 1].to_string(); + + // 2. Append result["pathname"] to the end of new pathname. + // 3. Set result["pathname"] to new pathname. + new_pathname.push_str(result.pathname.as_deref().unwrap()); + result.pathname = Some(new_pathname); + } + } + + // 3. Set result["pathname"] to the result of process pathname for init given result["pathname"], resultProtocolString, and type. + result.pathname = Some(process_pathname_for_init( + result.pathname.as_deref().unwrap(), + &result_protocol_string, + r#type, + )?); + } + + // 19. If init["search"] exists then set result["search"] to the result of process search for init given init["search"] and type. + if let Some(search) = &init.search { + result.search = Some(process_search_for_init(search, r#type)); + } + + // 20. If init["hash"] exists then set result["hash"] to the result of process hash for init given init["hash"] and type. + if let Some(hash) = &init.hash { + result.hash = Some(process_hash_for_init(hash, r#type)); + } + + // 21. Return result. + Ok(result) +} diff --git a/Libraries/LibURL/Rust/src/pattern/mod.rs b/Libraries/LibURL/Rust/src/pattern/mod.rs new file mode 100644 index 0000000000..1999042171 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/mod.rs @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#![allow(clippy::module_inception)] + +mod canonicalization; +mod component; +mod constructor_string_parser; +mod init; +mod options; +mod part; +mod pattern; +mod pattern_error; +mod pattern_parser; +mod string; +mod tokenizer; + +pub use canonicalization::canonicalize_a_hash; +pub use canonicalization::canonicalize_a_hostname; +pub use canonicalization::canonicalize_a_password; +pub use canonicalization::canonicalize_a_pathname; +pub use canonicalization::canonicalize_a_port; +pub use canonicalization::canonicalize_a_protocol; +pub use canonicalization::canonicalize_a_search; +pub use canonicalization::canonicalize_a_username; +pub use canonicalization::canonicalize_an_ipv6_hostname; +pub use canonicalization::canonicalize_an_opaque_pathname; +pub use component::Component; +pub use component::GroupMatch; +pub use component::RegularExpression; +pub use component::Result as ComponentResult; +pub use component::protocol_component_matches_a_special_scheme; +pub use constructor_string_parser::ConstructorStringParser; +pub use init::Init; +pub use init::PatternProcessType; +pub use init::process_a_url_pattern_init; +pub use options::Options; +pub use part::Part; +pub use pattern::IgnoreCase; +pub use pattern::Input; +pub use pattern::MatchInput; +pub use pattern::Pattern; +pub use pattern::Result; +pub use pattern_error::ErrorInfo; +pub use pattern_error::PatternErrorOr; +pub use pattern_parser::EncodingCallback; +pub use pattern_parser::PatternParser; +pub use string::escape_a_pattern_string; +pub use string::escape_a_regexp_string; +pub use string::full_wildcard_regexp_value; +pub use string::generate_a_pattern_string; +pub use string::generate_a_segment_wildcard_regexp; +pub use tokenizer::Token; +pub use tokenizer::Tokenizer; diff --git a/Libraries/LibURL/Rust/src/pattern/options.rs b/Libraries/LibURL/Rust/src/pattern/options.rs new file mode 100644 index 0000000000..44c822fa9a --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/options.rs @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +// https://urlpattern.spec.whatwg.org/#options +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Options { + // https://urlpattern.spec.whatwg.org/#options-delimiter-code-point + pub delimiter_code_point: Option, + + // https://urlpattern.spec.whatwg.org/#options-prefix-code-point + pub prefix_code_point: Option, + + // https://urlpattern.spec.whatwg.org/#options-ignore-case + pub ignore_case: bool, +} + +impl Options { + // https://urlpattern.spec.whatwg.org/#default-options + pub fn default_() -> Self { + // The default options is an options struct with delimiter code point set to the empty string and prefix code point set to the empty string. + Self { + delimiter_code_point: None, + prefix_code_point: None, + ignore_case: false, + } + } + + // https://urlpattern.spec.whatwg.org/#hostname-options + pub fn hostname() -> Self { + // The hostname options is an options struct with delimiter code point set "." and prefix code point set to the empty string. + Self { + delimiter_code_point: Some('.'), + prefix_code_point: None, + ignore_case: false, + } + } + + // https://urlpattern.spec.whatwg.org/#pathname-options + pub fn pathname() -> Self { + // The pathname options is an options struct with delimiter code point set "/" and prefix code point set to "/". + Self { + delimiter_code_point: Some('/'), + prefix_code_point: Some('/'), + ignore_case: false, + } + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/part.rs b/Libraries/LibURL/Rust/src/pattern/part.rs new file mode 100644 index 0000000000..647b0e9c97 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/part.rs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +// https://urlpattern.spec.whatwg.org/#part +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Part { + // https://urlpattern.spec.whatwg.org/#part-type + // A part has an associated type, a string, which must be set upon creation. + pub r#type: Type, + + // https://urlpattern.spec.whatwg.org/#part-value + // A part has an associated value, a string, which must be set upon creation. + pub value: String, + + // https://urlpattern.spec.whatwg.org/#part-modifier + // A part has an associated modifier a string, which must be set upon creation. + pub modifier: Modifier, + + // https://urlpattern.spec.whatwg.org/#part-name + // A part has an associated name, a string, initially the empty string. + pub name: String, + + // https://urlpattern.spec.whatwg.org/#part-prefix + // A part has an associated prefix, a string, initially the empty string. + pub prefix: String, + + // https://urlpattern.spec.whatwg.org/#part-suffix + // A part has an associated suffix, a string, initially the empty string. + pub suffix: String, +} + +// https://urlpattern.spec.whatwg.org/#part-type +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Type { + // The part represents a simple fixed text string. + FixedText, + + // The part represents a matching group with a custom regular expression. + Regexp, + + // The part represents a matching group that matches code points up to the next separator code point. This is + // typically used for a named group like ":foo" that does not have a custom regular expression. + SegmentWildcard, + + // The part represents a matching group that greedily matches all code points. This is typically used for + // the "*" wildcard matching group. + FullWildcard, +} + +// https://urlpattern.spec.whatwg.org/#part-modifier +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Modifier { + // The part does not have a modifier. + None, + + // The part has an optional modifier indicated by the U+003F (?) code point. + Optional, + + // The part has a "zero or more" modifier indicated by the U+002A (*) code point. + ZeroOrMore, + + // The part has a "one or more" modifier indicated by the U+002B (+) code point. + OneOrMore, +} + +impl Part { + pub fn new(r#type: Type, value: String, modifier: Modifier) -> Self { + Self { + r#type, + value, + modifier, + name: String::new(), + prefix: String::new(), + suffix: String::new(), + } + } + + pub fn new_with_name( + r#type: Type, + value: String, + modifier: Modifier, + name: String, + prefix: String, + suffix: String, + ) -> Self { + Self { + r#type, + value, + modifier, + name, + prefix, + suffix, + } + } + + pub fn type_to_string(r#type: Type) -> &'static str { + match r#type { + Type::FixedText => "FixedText", + Type::Regexp => "Regexp", + Type::SegmentWildcard => "SegmentWildcard", + Type::FullWildcard => "FullWildcard", + } + } + + // https://urlpattern.spec.whatwg.org/#convert-a-modifier-to-a-string + pub fn convert_modifier_to_string(modifier: Modifier) -> &'static str { + // 1. If modifier is "zero-or-more", then return "*". + if modifier == Modifier::ZeroOrMore { + return "*"; + } + + // 2. If modifier is "optional", then return "?". + if modifier == Modifier::Optional { + return "?"; + } + + // 3. If modifier is "one-or-more", then return "+". + if modifier == Modifier::OneOrMore { + return "+"; + } + + // 4. Return the empty string. + "" + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/pattern.rs b/Libraries/LibURL/Rust/src/pattern/pattern.rs new file mode 100644 index 0000000000..6afd79017a --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/pattern.rs @@ -0,0 +1,687 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::Component; +use crate::pattern::ConstructorStringParser; +use crate::pattern::Init; +use crate::pattern::PatternErrorOr; +use crate::pattern::PatternProcessType; +use crate::pattern::canonicalize_a_hash; +use crate::pattern::canonicalize_a_hostname; +use crate::pattern::canonicalize_a_password; +use crate::pattern::canonicalize_a_pathname; +use crate::pattern::canonicalize_a_port; +use crate::pattern::canonicalize_a_protocol; +use crate::pattern::canonicalize_a_search; +use crate::pattern::canonicalize_a_username; +use crate::pattern::canonicalize_an_ipv6_hostname; +use crate::pattern::canonicalize_an_opaque_pathname; +use crate::pattern::process_a_url_pattern_init; +use crate::pattern::protocol_component_matches_a_special_scheme; +use crate::url::BasicParseOptions; +use crate::url::ExcludeFragment; +use crate::url::Url; +use crate::url::basic_parse; +use crate::url::default_port_for_scheme; +use crate::url::is_special_scheme; + +// https://urlpattern.spec.whatwg.org/#typedefdef-urlpatterninput +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Input { + String(String), + Init(Init), +} + +#[derive(Clone, Debug)] +pub enum MatchInput { + String(String), + Init(Init), + Url(Url), +} + +// https://urlpattern.spec.whatwg.org/#dictdef-urlpatternresult +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Result { + pub inputs: Vec, + + pub protocol: crate::pattern::component::Result, + pub username: crate::pattern::component::Result, + pub password: crate::pattern::component::Result, + pub hostname: crate::pattern::component::Result, + pub port: crate::pattern::component::Result, + pub pathname: crate::pattern::component::Result, + pub search: crate::pattern::component::Result, + pub hash: crate::pattern::component::Result, +} + +// https://urlpattern.spec.whatwg.org/#dictdef-urlpatternoptions +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IgnoreCase { + Yes, + No, +} + +// https://urlpattern.spec.whatwg.org/#url-pattern +#[derive(Clone, Debug, Default)] +pub struct Pattern { + // https://urlpattern.spec.whatwg.org/#url-pattern-protocol-component + // protocol component, a component + protocol_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-username-component + // username component, a component + username_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-password-component + // password component, a component + password_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-hostname-component + // hostname component, a component + hostname_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-port-component + // port component, a component + port_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-pathname-component + // pathname component, a component + pathname_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-search-component + // search component, a component + search_component: Component, + + // https://urlpattern.spec.whatwg.org/#url-pattern-hash-component + // hash component, a component + hash_component: Component, +} + +// https://urlpattern.spec.whatwg.org/#hostname-pattern-is-an-ipv6-address +fn hostname_pattern_is_an_ipv6_address(input: &str) -> bool { + // 1. If input’s code point length is less than 2, then return false. + if input.len() < 2 { + return false; + } + + // 2. Let input code points be input interpreted as a list of code points. + let input_code_points = input.as_bytes(); + + // 3. If input code points[0] is U+005B ([), then return true. + if input_code_points[0] == b'[' { + return true; + } + + // 4. If input code points[0] is U+007B ({) and input code points[1] is U+005B ([), then return true. + if input_code_points[0] == b'{' && input_code_points[1] == b'[' { + return true; + } + + // 5. If input code points[0] is U+005C (\) and input code points[1] is U+005B ([), then return true. + if input_code_points[0] == b'\\' && input_code_points[1] == b'[' { + return true; + } + + // 6. Return false. + false +} + +impl Pattern { + // https://urlpattern.spec.whatwg.org/#url-pattern-create + #[allow(clippy::field_reassign_with_default)] + pub fn create(input: &Input, base_url: &Option, ignore_case: IgnoreCase) -> PatternErrorOr { + // 1. Let init be null. + let mut init; + + // 2. If input is a scalar value string then: + if let Input::String(input_string) = input { + // 1. Set init to the result of running parse a constructor string given input. + init = ConstructorStringParser::parse(input_string)?; + + // 2. If baseURL is null and init["protocol"] does not exist, then throw a TypeError. + if base_url.is_none() && init.protocol.is_none() { + return Err(crate::pattern::ErrorInfo::new( + "Relative URLPattern constructor must provide one of baseURL or protocol", + )); + } + + // 3. If baseURL is not null, set init["baseURL"] to baseURL. + if let Some(base_url) = base_url { + init.base_url = Some(base_url.clone()); + } + } + // 3. Otherwise: + else { + // 1. Assert: input is a URLPatternInit. + let Input::Init(input_init) = input else { unreachable!() }; + + // 2. If baseURL is not null, then throw a TypeError. + if base_url.is_some() { + return Err(crate::pattern::ErrorInfo::new( + "Constructor with URLPatternInit should provide no baseURL", + )); + } + + // 3. Set init to input. + init = input_init.clone(); + } + + // 4. Let processedInit be the result of process a URLPatternInit given init, "pattern", null, null, null, null, null, null, null, and null. + let none = None; + let mut processed_init = process_a_url_pattern_init( + &init, + PatternProcessType::Pattern, + &none, + &none, + &none, + &none, + &none, + &none, + &none, + &none, + )?; + + // 5. For each componentName of « "protocol", "username", "password", "hostname", "port", "pathname", "search", "hash" »: + // 1. If processedInit[componentName] does not exist, then set processedInit[componentName] to "*". + if processed_init.protocol.is_none() { + processed_init.protocol = Some("*".to_string()); + } + if processed_init.username.is_none() { + processed_init.username = Some("*".to_string()); + } + if processed_init.password.is_none() { + processed_init.password = Some("*".to_string()); + } + if processed_init.hostname.is_none() { + processed_init.hostname = Some("*".to_string()); + } + if processed_init.port.is_none() { + processed_init.port = Some("*".to_string()); + } + if processed_init.pathname.is_none() { + processed_init.pathname = Some("*".to_string()); + } + if processed_init.search.is_none() { + processed_init.search = Some("*".to_string()); + } + if processed_init.hash.is_none() { + processed_init.hash = Some("*".to_string()); + } + + // 6. If processedInit["protocol"] is a special scheme and processedInit["port"] is a string which represents its + // corresponding default port in radix-10 using ASCII digits then set processedInit["port"] to the empty string. + if is_special_scheme(processed_init.protocol.as_ref().unwrap().as_bytes()) + && let Ok(maybe_port) = processed_init.port.as_ref().unwrap().parse::() + && Some(maybe_port) == default_port_for_scheme(processed_init.protocol.as_ref().unwrap()) + { + processed_init.port = Some(String::new()); + } + + // 7. Let urlPattern be a new URL pattern. + let mut url_pattern = Self::default(); + + // 8. Set urlPattern’s protocol component to the result of compiling a component given processedInit["protocol"], + // canonicalize a protocol, and default options. + url_pattern.protocol_component = Component::compile( + processed_init.protocol.as_deref().unwrap(), + Box::new(canonicalize_a_protocol), + &crate::pattern::Options::default_(), + )?; + + // 9. Set urlPattern’s username component to the result of compiling a component given processedInit["username"], + // canonicalize a username, and default options. + url_pattern.username_component = Component::compile( + processed_init.username.as_deref().unwrap(), + Box::new(|value| Ok(canonicalize_a_username(value))), + &crate::pattern::Options::default_(), + )?; + + // 10. Set urlPattern’s password component to the result of compiling a component given processedInit["password"], + // canonicalize a password, and default options. + url_pattern.password_component = Component::compile( + processed_init.password.as_deref().unwrap(), + Box::new(|value| Ok(canonicalize_a_password(value))), + &crate::pattern::Options::default_(), + )?; + + // 11. If the result running hostname pattern is an IPv6 address given processedInit["hostname"] is true, then set + // urlPattern’s hostname component to the result of compiling a component given processedInit["hostname"], + // canonicalize an IPv6 hostname, and hostname options. + if hostname_pattern_is_an_ipv6_address(processed_init.hostname.as_deref().unwrap()) { + url_pattern.hostname_component = Component::compile( + processed_init.hostname.as_deref().unwrap(), + Box::new(canonicalize_an_ipv6_hostname), + &crate::pattern::Options::hostname(), + )?; + } + // 12. Otherwise, set urlPattern’s hostname component to the result of compiling a component given + // processedInit["hostname"], canonicalize a hostname, and hostname options. + else { + url_pattern.hostname_component = Component::compile( + processed_init.hostname.as_deref().unwrap(), + Box::new(canonicalize_a_hostname), + &crate::pattern::Options::hostname(), + )?; + } + + // 13. Set urlPattern’s port component to the result of compiling a component given processedInit["port"], + // canonicalize a port, and default options. + url_pattern.port_component = Component::compile( + processed_init.port.as_deref().unwrap(), + Box::new(|value| canonicalize_a_port(value, &None)), + &crate::pattern::Options::default_(), + )?; + + // 14. Let compileOptions be a copy of the default options with the ignore case property set to options["ignoreCase"]. + let mut compile_options = crate::pattern::Options::default_(); + compile_options.ignore_case = ignore_case == IgnoreCase::Yes; + + // 15. If the result of running protocol component matches a special scheme given urlPattern’s protocol component is true, then: + if protocol_component_matches_a_special_scheme(&url_pattern.protocol_component) { + // 1. Let pathCompileOptions be copy of the pathname options with the ignore case property set to options["ignoreCase"]. + let mut path_compile_options = crate::pattern::Options::pathname(); + path_compile_options.ignore_case = ignore_case == IgnoreCase::Yes; + + // 2. Set urlPattern’s pathname component to the result of compiling a component given processedInit["pathname"], + // canonicalize a pathname, and pathCompileOptions. + url_pattern.pathname_component = Component::compile( + processed_init.pathname.as_deref().unwrap(), + Box::new(|value| Ok(canonicalize_a_pathname(value))), + &path_compile_options, + )?; + } + // 16. Otherwise set urlPattern’s pathname component to the result of compiling a component given + // processedInit["pathname"], canonicalize an opaque pathname, and compileOptions. + else { + url_pattern.pathname_component = Component::compile( + processed_init.pathname.as_deref().unwrap(), + Box::new(canonicalize_an_opaque_pathname), + &compile_options, + )?; + } + + // 17. Set urlPattern’s search component to the result of compiling a component given processedInit["search"], + // canonicalize a search, and compileOptions. + url_pattern.search_component = Component::compile( + processed_init.search.as_deref().unwrap(), + Box::new(|value| Ok(canonicalize_a_search(value))), + &compile_options, + )?; + + // 18. Set urlPattern’s hash component to the result of compiling a component given processedInit["hash"], + // canonicalize a hash, and compileOptions. + url_pattern.hash_component = Component::compile( + processed_init.hash.as_deref().unwrap(), + Box::new(|value| Ok(canonicalize_a_hash(value))), + &compile_options, + )?; + + // 19. Return urlPattern. + Ok(url_pattern) + } + + // https://urlpattern.spec.whatwg.org/#url-pattern-match + #[allow(clippy::field_reassign_with_default)] + pub fn r#match(&self, input: &MatchInput, base_url_string: &Option) -> PatternErrorOr> { + // 1. Let protocol be the empty string. + let mut protocol = String::new(); + + // 2. Let username be the empty string. + let mut username = String::new(); + + // 3. Let password be the empty string. + let mut password = String::new(); + + // 4. Let hostname be the empty string. + let mut hostname = String::new(); + + // 5. Let port be the empty string. + let mut port = String::new(); + + // 6. Let pathname be the empty string. + let mut pathname = String::new(); + + // 7. Let search be the empty string. + let mut search = String::new(); + + // 8. Let hash be the empty string. + let mut hash = String::new(); + + // 9. Let inputs be an empty list. + let mut inputs = Vec::new(); + + // 10. If input is a URL, then append the serialization of input to inputs. + if let MatchInput::Url(input_url) = input { + inputs.push(Input::String(input_url.serialize(ExcludeFragment::No))); + } + // 11. Otherwise, append input to inputs. + else { + match input { + MatchInput::String(input_string) => { + inputs.push(Input::String(input_string.clone())); + } + MatchInput::Init(input_init) => inputs.push(Input::Init(input_init.clone())), + MatchInput::Url(_) => unreachable!(), + } + } + + // 12. If input is a URLPatternInit then: + if let MatchInput::Init(input_init) = input { + // 1. If baseURLString was given, throw a TypeError. + if base_url_string.is_some() { + return Err(crate::pattern::ErrorInfo::new( + "Base URL cannot be provided when URLPatternInput is provided", + )); + } + + // 2. Let applyResult be the result of process a URLPatternInit given input, "url", protocol, username, password, + // hostname, port, pathname, search, and hash. If this throws an exception, catch it, and return null. + let protocol_option = Some(protocol.clone()); + let username_option = Some(username.clone()); + let password_option = Some(password.clone()); + let hostname_option = Some(hostname.clone()); + let port_option = Some(port.clone()); + let pathname_option = Some(pathname.clone()); + let search_option = Some(search.clone()); + let hash_option = Some(hash.clone()); + let apply_result = process_a_url_pattern_init( + input_init, + PatternProcessType::Url, + &protocol_option, + &username_option, + &password_option, + &hostname_option, + &port_option, + &pathname_option, + &search_option, + &hash_option, + ); + let Ok(apply_result) = apply_result else { + return Ok(None); + }; + + // 3. Set protocol to applyResult["protocol"]. + protocol = apply_result.protocol.unwrap(); + + // 4. Set username to applyResult["username"]. + username = apply_result.username.unwrap(); + + // 5. Set password to applyResult["password"]. + password = apply_result.password.unwrap(); + + // 6. Set hostname to applyResult["hostname"]. + hostname = apply_result.hostname.unwrap(); + + // 7. Set port to applyResult["port"]. + port = apply_result.port.unwrap(); + + // 8. Set pathname to applyResult["pathname"]. + pathname = apply_result.pathname.unwrap(); + + // 9. Set search to applyResult["search"]. + search = apply_result.search.unwrap(); + + // 10. Set hash to applyResult["hash"]. + hash = apply_result.hash.unwrap(); + } + // 13. Otherwise: + else { + // 1. Let url be input. + let url; + + // 2. If input is a USVString: + if let MatchInput::String(input_string) = input { + // 1. Let baseURL be null. + let mut base_url = None; + + // 2. If baseURLString was given, then: + if let Some(base_url_string) = base_url_string { + // 1. Set baseURL to the result of running the basic URL parser on baseURLString. + base_url = basic_parse(base_url_string, BasicParseOptions::new()); + + // 2. If baseURL is failure, return null. + if base_url.is_none() { + return Ok(None); + } + + // 3. Append baseURLString to inputs. + inputs.push(Input::String(base_url_string.clone())); + } + + // 3. Set url to the result of running the basic URL parser on input with baseURL. + // 4. If url is failure, return null. + let maybe_url = if let Some(base_url) = base_url.as_ref() { + basic_parse(input_string, BasicParseOptions::new().base_url(base_url)) + } else { + basic_parse(input_string, BasicParseOptions::new()) + }; + let Some(parsed_url) = maybe_url else { + return Ok(None); + }; + url = parsed_url; + } else { + // 3. Assert: url is a URL. + let MatchInput::Url(input_url) = input else { + unreachable!() + }; + url = input_url.clone(); + } + + // 4. Set protocol to url’s scheme. + protocol = url.scheme.clone(); + + // 5. Set username to url’s username. + username = url.username.clone(); + + // 6. Set password to url’s password. + password = url.password.clone(); + + // 7. Set hostname to url’s host, serialized, or the empty string if the value is null. + if let Some(host) = &url.host { + hostname = host.serialize(); + } else { + hostname = String::new(); + } + + // 8. Set port to url’s port, serialized, or the empty string if the value is null. + if let Some(url_port) = url.port { + port = url_port.to_string(); + } else { + port = String::new(); + } + + // 9. Set pathname to the result of URL path serializing url. + pathname = url.serialize_path(); + + // 10. Set search to url’s query or the empty string if the value is null. + search = url.query.unwrap_or_default(); + + // 11. Set hash to url’s fragment or the empty string if the value is null. + hash = url.fragment.unwrap_or_default(); + } + + // 14. Let protocolExecResult be RegExpBuiltinExec(urlPattern’s protocol component's regular expression, protocol). + let protocol_exec_result = self.protocol_component.execute(&protocol); + if !protocol_exec_result.success { + return Ok(None); + } + + // 15. Let usernameExecResult be RegExpBuiltinExec(urlPattern’s username component's regular expression, username). + let username_exec_result = self.username_component.execute(&username); + if !username_exec_result.success { + return Ok(None); + } + + // 16. Let passwordExecResult be RegExpBuiltinExec(urlPattern’s password component's regular expression, password). + let password_exec_result = self.password_component.execute(&password); + if !password_exec_result.success { + return Ok(None); + } + + // 17. Let hostnameExecResult be RegExpBuiltinExec(urlPattern’s hostname component's regular expression, hostname). + let hostname_exec_result = self.hostname_component.execute(&hostname); + if !hostname_exec_result.success { + return Ok(None); + } + + // 18. Let portExecResult be RegExpBuiltinExec(urlPattern’s port component's regular expression, port). + let port_exec_result = self.port_component.execute(&port); + if !port_exec_result.success { + return Ok(None); + } + + // 19. Let pathnameExecResult be RegExpBuiltinExec(urlPattern’s pathname component's regular expression, pathname). + let pathname_exec_result = self.pathname_component.execute(&pathname); + if !pathname_exec_result.success { + return Ok(None); + } + + // 20. Let searchExecResult be RegExpBuiltinExec(urlPattern’s search component's regular expression, search). + let search_exec_result = self.search_component.execute(&search); + if !search_exec_result.success { + return Ok(None); + } + + // 21. Let hashExecResult be RegExpBuiltinExec(urlPattern’s hash component's regular expression, hash). + let hash_exec_result = self.hash_component.execute(&hash); + if !hash_exec_result.success { + return Ok(None); + } + + // 22. If protocolExecResult, usernameExecResult, passwordExecResult, hostnameExecResult, portExecResult, + // pathnameExecResult, searchExecResult, or hashExecResult are null then return null. + // NOTE: Done in steps above at point of exec. + + // 23. Let result be a new URLPatternResult. + let mut result = Result::default(); + + // 24. Set result["inputs"] to inputs. + result.inputs = inputs; + + // 25. Set result["protocol"] to the result of creating a component match result given urlPattern’s protocol + // component, protocol, and protocolExecResult. + result.protocol = self + .protocol_component + .create_match_result(&protocol, &protocol_exec_result); + + // 26. Set result["username"] to the result of creating a component match result given urlPattern’s username + // component, username, and usernameExecResult. + result.username = self + .username_component + .create_match_result(&username, &username_exec_result); + + // 27. Set result["password"] to the result of creating a component match result given urlPattern’s password + // component, password, and passwordExecResult. + result.password = self + .password_component + .create_match_result(&password, &password_exec_result); + + // 28. Set result["hostname"] to the result of creating a component match result given urlPattern’s hostname + // component, hostname, and hostnameExecResult. + result.hostname = self + .hostname_component + .create_match_result(&hostname, &hostname_exec_result); + + // 29. Set result["port"] to the result of creating a component match result given urlPattern’s port component, + // port, and portExecResult. + result.port = self.port_component.create_match_result(&port, &port_exec_result); + + // 30. Set result["pathname"] to the result of creating a component match result given urlPattern’s pathname + // component, pathname, and pathnameExecResult. + result.pathname = self + .pathname_component + .create_match_result(&pathname, &pathname_exec_result); + + // 31. Set result["search"] to the result of creating a component match result given urlPattern’s search component, + // search, and searchExecResult. + result.search = self.search_component.create_match_result(&search, &search_exec_result); + + // 32. Set result["hash"] to the result of creating a component match result given urlPattern’s hash component, + // hash, and hashExecResult. + result.hash = self.hash_component.create_match_result(&hash, &hash_exec_result); + + // 33. Return result. + Ok(Some(result)) + } + + pub fn has_regexp_groups(&self) -> bool { + // 1. If urlPattern’s protocol component has regexp groups is true, then return true. + if self.protocol_component.has_regexp_groups { + return true; + } + + // 2. If urlPattern’s username component has regexp groups is true, then return true. + if self.username_component.has_regexp_groups { + return true; + } + + // 3. If urlPattern’s password component has regexp groups is true, then return true. + if self.password_component.has_regexp_groups { + return true; + } + + // 4. If urlPattern’s hostname component has regexp groups is true, then return true. + if self.hostname_component.has_regexp_groups { + return true; + } + + // 5. If urlPattern’s port component has regexp groups is true, then return true. + if self.port_component.has_regexp_groups { + return true; + } + + // 6. If urlPattern’s pathname component has regexp groups is true, then return true. + if self.pathname_component.has_regexp_groups { + return true; + } + + // 7. If urlPattern’s search component has regexp groups is true, then return true. + if self.search_component.has_regexp_groups { + return true; + } + + // 8. If urlPattern’s hash component has regexp groups is true, then return true. + if self.hash_component.has_regexp_groups { + return true; + } + + // 9. Return false. + false + } + + pub fn protocol_component(&self) -> &Component { + &self.protocol_component + } + + pub fn username_component(&self) -> &Component { + &self.username_component + } + + pub fn password_component(&self) -> &Component { + &self.password_component + } + + pub fn hostname_component(&self) -> &Component { + &self.hostname_component + } + + pub fn port_component(&self) -> &Component { + &self.port_component + } + + pub fn pathname_component(&self) -> &Component { + &self.pathname_component + } + + pub fn search_component(&self) -> &Component { + &self.search_component + } + + pub fn hash_component(&self) -> &Component { + &self.hash_component + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/pattern_error.rs b/Libraries/LibURL/Rust/src/pattern/pattern_error.rs new file mode 100644 index 0000000000..00679b97cb --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/pattern_error.rs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +// NOTE: All exceptions which are thrown by the URLPattern spec are TypeErrors which web-based callers are expected to assume. +// If this ever does not become the case, this should change to also include the error type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ErrorInfo { + pub message: String, +} + +pub type PatternErrorOr = Result; + +impl ErrorInfo { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/pattern_parser.rs b/Libraries/LibURL/Rust/src/pattern/pattern_parser.rs new file mode 100644 index 0000000000..88a6fed697 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/pattern_parser.rs @@ -0,0 +1,472 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::ErrorInfo; +use crate::pattern::Options; +use crate::pattern::Part; +use crate::pattern::PatternErrorOr; +use crate::pattern::Token; +use crate::pattern::Tokenizer; +use crate::pattern::full_wildcard_regexp_value; +use crate::pattern::generate_a_segment_wildcard_regexp; +use crate::pattern::part::Modifier as PartModifier; +use crate::pattern::part::Type as PartType; +use crate::pattern::tokenizer::Policy as TokenizerPolicy; +use crate::pattern::tokenizer::Type as TokenType; + +// https://urlpattern.spec.whatwg.org/#pattern-parser +pub struct PatternParser { + // https://urlpattern.spec.whatwg.org/#pattern-parser-token-list + // A pattern parser has an associated token list, a token list, initially an empty list. + pub token_list: Vec, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-encoding-callback + // A pattern parser has an associated encoding callback, a encoding callback, that must be set upon creation. + pub encoding_callback: EncodingCallback, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-segment-wildcard-regexp + // A pattern parser has an associated segment wildcard regexp, a string, that must be set upon creation. + pub segment_wildcard_regexp: String, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-part-list + // A pattern parser has an associated part list, a part list, initially an empty list. + pub part_list: Vec, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-pending-fixed-value + // A pattern parser has an associated pending fixed value, a string, initially the empty string. + pub pending_fixed_value: String, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-index + // A pattern parser has an associated index, a number, initially 0. + pub index: usize, + + // https://urlpattern.spec.whatwg.org/#pattern-parser-next-numeric-name + // A pattern parser has an associated next numeric name, a number, initially 0. + pub next_numeric_name: usize, +} + +// https://urlpattern.spec.whatwg.org/#encoding-callback +// An encoding callback is an abstract algorithm that takes a given string input. The input will be a simple text +// piece of a pattern string. An implementing algorithm will validate and encode the input. It must return the +// encoded string or throw an exception. +pub type EncodingCallback = Box PatternErrorOr>; + +impl PatternParser { + pub(crate) fn new(encoding_callback: EncodingCallback, segment_wildcard_regexp: String) -> Self { + Self { + token_list: Vec::new(), + encoding_callback, + segment_wildcard_regexp, + part_list: Vec::new(), + pending_fixed_value: String::new(), + index: 0, + next_numeric_name: 0, + } + } + + // https://urlpattern.spec.whatwg.org/#consume-a-required-token + pub(crate) fn consume_a_required_token(&mut self, r#type: TokenType) -> PatternErrorOr<()> { + // 1. Let result be the result of running try to consume a token given parser and type. + let result = self.try_to_consume_a_token(r#type); + + // 2. If result is null, then throw a TypeError. + if result.is_none() { + return Err(ErrorInfo::new(format!( + "Missing required token '{}' in URL pattern", + Token::type_to_string(r#type) + ))); + } + + // 3. Return result. + // NOTE: No caller actually needs the result, so we just ignore it. + Ok(()) + } + + // https://urlpattern.spec.whatwg.org/#consume-text + pub(crate) fn consume_text(&mut self) -> String { + // 1. Let result be the empty string. + let mut result = String::new(); + + // 1. While true: + loop { + // 1. Let token be the result of running try to consume a token given parser and "char". + let mut token = self.try_to_consume_a_token(TokenType::Char); + + // 2. If token is null, then set token to the result of running try to consume a token given parser and "escaped-char". + if token.is_none() { + token = self.try_to_consume_a_token(TokenType::EscapedChar); + } + + // 3. If token is null, then break. + let Some(token) = token else { + break; + }; + + // 4. Append token’s value to the end of result. + result.push_str(&token.value); + } + + // 2. Return result. + result + } + + // https://urlpattern.spec.whatwg.org/#maybe-add-a-part-from-the-pending-fixed-value + pub(crate) fn maybe_add_a_part_from_the_pending_fixed_value(&mut self) -> PatternErrorOr<()> { + // 1. If parser’s pending fixed value is the empty string, then return. + if self.pending_fixed_value.is_empty() { + return Ok(()); + } + + // 2. Let encoded value be the result of running parser’s encoding callback given parser’s pending fixed value. + let encoded_value = (self.encoding_callback)(&self.pending_fixed_value)?; + + // 3. Set parser’s pending fixed value to the empty string. + self.pending_fixed_value.clear(); + + // 4. Let part be a new part whose type is "fixed-text", value is encoded value, and modifier is "none". + // 5. Append part to parser’s part list. + self.part_list + .push(Part::new(PartType::FixedText, encoded_value, PartModifier::None)); + + Ok(()) + } + + // https://urlpattern.spec.whatwg.org/#is-a-duplicate-name + pub(crate) fn is_a_duplicate_name(&self, name: &str) -> bool { + // 1. For each part of parser’s part list: + for part in &self.part_list { + // 1. If part’s name is name, then return true. + if part.name == name { + return true; + } + } + + // 2. Return false. + false + } + + // https://urlpattern.spec.whatwg.org/#add-a-part + pub(crate) fn add_a_part( + &mut self, + prefix: &str, + name_token: Option, + regexp_or_wildcard_token: Option, + suffix: &str, + modifier_token: Option, + ) -> PatternErrorOr<()> { + // 1. Let modifier be "none". + let mut modifier = PartModifier::None; + + // 2. If modifier token is not null: + if let Some(modifier_token) = modifier_token { + // 1. If modifier token’s value is "?" then set modifier to "optional". + if modifier_token.value == "?" { + modifier = PartModifier::Optional; + } + // 2. Otherwise if modifier token’s value is "*" then set modifier to "zero-or-more". + else if modifier_token.value == "*" { + modifier = PartModifier::ZeroOrMore; + } + // 3. Otherwise if modifier token’s value is "+" then set modifier to "one-or-more". + else if modifier_token.value == "+" { + modifier = PartModifier::OneOrMore; + } + } + + // 3. If name token is null and regexp or wildcard token is null and modifier is "none": + // NOTE: This was a "{foo}" grouping. We add this to the pending fixed value so that it will be combined with + // any previous or subsequent text. + if name_token.is_none() && regexp_or_wildcard_token.is_none() && modifier == PartModifier::None { + // 1. Append prefix to the end of parser’s pending fixed value. + self.pending_fixed_value.push_str(prefix); + + // 2. Return. + return Ok(()); + } + + // 4. Run maybe add a part from the pending fixed value given parser. + self.maybe_add_a_part_from_the_pending_fixed_value()?; + + // 5. If name token is null and regexp or wildcard token is null: + // NOTE: This was a "{foo}?" grouping. The modifier means we cannot combine it with other text. Therefore we + // add it as a part immediately. + if name_token.is_none() && regexp_or_wildcard_token.is_none() { + // 1. Assert: suffix is the empty string. + assert!(suffix.is_empty()); + + // 2. If prefix is the empty string, then return. + if prefix.is_empty() { + return Ok(()); + } + + // 3. Let encoded value be the result of running parser’s encoding callback given prefix. + let encoded_value = (self.encoding_callback)(prefix)?; + + // 4. Let part be a new part whose type is "fixed-text", value is encoded value, and modifier is modifier. + // 5. Append part to parser’s part list. + self.part_list + .push(Part::new(PartType::FixedText, encoded_value, modifier)); + + // 6. Return. + return Ok(()); + } + + // 6. Let regexp value be the empty string. + // NOTE: Next, we convert the regexp or wildcard token into a regular expression. + let mut regexp_value = + // 7. If regexp or wildcard token is null, then set regexp value to parser’s segment wildcard regexp. + if let Some(regexp_or_wildcard_token) = regexp_or_wildcard_token.as_ref() { + // 8. Otherwise if regexp or wildcard token’s type is "asterisk", then set regexp value to the full wildcard regexp value. + if regexp_or_wildcard_token.r#type == TokenType::Asterisk { + full_wildcard_regexp_value.to_string() + } + // 9. Otherwise set regexp value to regexp or wildcard token’s value. + else { + regexp_or_wildcard_token.value.clone() + } + } else { + self.segment_wildcard_regexp.clone() + }; + + // 10. Let type be "regexp". + // NOTE: Next, we convert regexp value into a part type. We make sure to go to a regular expression first so + // that an equivalent "regexp" token will be treated the same as a "name" or "asterisk" token. + let mut r#type = PartType::Regexp; + + // 11. If regexp value is parser’s segment wildcard regexp: + if regexp_value == self.segment_wildcard_regexp { + // 1. Set type to "segment-wildcard". + r#type = PartType::SegmentWildcard; + + // 2. Set regexp value to the empty string. + regexp_value = String::new(); + } + // 12. Otherwise if regexp value is the full wildcard regexp value: + else if regexp_value == full_wildcard_regexp_value { + // 1. Set type to "full-wildcard". + r#type = PartType::FullWildcard; + + // 2. Set regexp value to the empty string. + regexp_value = String::new(); + } + + // 13. Let name be the empty string. + // NOTE: Next, we determine the part name. This can be explicitly provided by a "name" token or be automatically assigned. + let mut name = String::new(); + + // 14. If name token is not null, then set name to name token’s value. + if let Some(name_token) = name_token { + name = name_token.value; + } + // 15. Otherwise if regexp or wildcard token is not null: + else if regexp_or_wildcard_token.is_some() { + // 1. Set name to parser’s next numeric name, serialized. + name = self.next_numeric_name.to_string(); + + // 2. Increment parser’s next numeric name by 1. + self.next_numeric_name += 1; + } + + // 16. If the result of running is a duplicate name given parser and name is true, then throw a TypeError. + if self.is_a_duplicate_name(&name) { + return Err(ErrorInfo::new(format!( + "Duplicate name '{name}' provided in URL pattern" + ))); + } + + // 17. Let encoded prefix be the result of running parser’s encoding callback given prefix. + // NOTE: Finally, we encode the fixed text values and create the part. + let encoded_prefix = (self.encoding_callback)(prefix)?; + + // 18. Let encoded suffix be the result of running parser’s encoding callback given suffix. + let encoded_suffix = (self.encoding_callback)(suffix)?; + + // 19. Let part be a new part whose type is type, value is regexp value, modifier is modifier, name is name, prefix + // is encoded prefix, and suffix is encoded suffix. + // 20. Append part to parser’s part list. + self.part_list.push(Part::new_with_name( + r#type, + regexp_value, + modifier, + name, + encoded_prefix, + encoded_suffix, + )); + + Ok(()) + } + + // https://urlpattern.spec.whatwg.org/#try-to-consume-a-modifier-token + pub(crate) fn try_to_consume_a_modifier_token(&mut self) -> Option { + // 1. Let token be the result of running try to consume a token given parser and "other-modifier". + let mut token = self.try_to_consume_a_token(TokenType::OtherModifier); + + // 2. If token is not null, then return token. + if token.is_some() { + return token; + } + + // 3. Set token to the result of running try to consume a token given parser and "asterisk". + token = self.try_to_consume_a_token(TokenType::Asterisk); + + // 4. Return token. + token + } + + // https://urlpattern.spec.whatwg.org/#try-to-consume-a-regexp-or-wildcard-token + pub(crate) fn try_to_consume_a_regexp_or_wildcard_token(&mut self, name_token: Option) -> Option { + // 1. Let token be the result of running try to consume a token given parser and "regexp". + let mut token = self.try_to_consume_a_token(TokenType::Regexp); + + // 2. If name token is null and token is null, then set token to the result of running try to consume a token given + // parser and "asterisk". + if name_token.is_none() && token.is_none() { + token = self.try_to_consume_a_token(TokenType::Asterisk); + } + + // 3. Return token. + token + } + + // https://urlpattern.spec.whatwg.org/#try-to-consume-a-token + pub(crate) fn try_to_consume_a_token(&mut self, r#type: TokenType) -> Option { + // 1. Assert: parser’s index is less than parser’s token list size. + assert!(self.index < self.token_list.len()); + + // 2. Let next token be parser’s token list[parser’s index]. + let next_token = self.token_list[self.index].clone(); + + // 3. If next token’s type is not type return null. + if next_token.r#type != r#type { + return None; + } + + // 4. Increment parser’s index by 1. + self.index += 1; + + // 5. Return next token. + Some(next_token) + } + + // https://urlpattern.spec.whatwg.org/#parse-a-pattern-string + pub fn parse(input: &str, options: &Options, encoding_callback: EncodingCallback) -> PatternErrorOr> { + // 1. Let parser be a new pattern parser whose encoding callback is encoding callback and segment wildcard regexp + // is the result of running generate a segment wildcard regexp given options. + let mut parser = Self::new(encoding_callback, generate_a_segment_wildcard_regexp(options)); + + // 2. Set parser’s token list to the result of running tokenize given input and "strict". + parser.token_list = Tokenizer::tokenize(input, TokenizerPolicy::Strict)?; + + // 3. While parser’s index is less than parser’s token list's size: + while parser.index < parser.token_list.len() { + // 1. Let char token be the result of running try to consume a token given parser and "char". + let char_token = parser.try_to_consume_a_token(TokenType::Char); + + // 2. Let name token be the result of running try to consume a token given parser and "name". + let mut name_token = parser.try_to_consume_a_token(TokenType::Name); + + // 3. Let regexp or wildcard token be the result of running try to consume a regexp or wildcard token given + // parser and name token. + let mut regexp_or_wildcard_token = parser.try_to_consume_a_regexp_or_wildcard_token(name_token.clone()); + + // 4. If name token is not null or regexp or wildcard token is not null: + // NOTE: If there is a matching group, we need to add the part immediately. + if name_token.is_some() || regexp_or_wildcard_token.is_some() { + // 1. Let prefix be the empty string. + let mut prefix = String::new(); + + // 2. If char token is not null then set prefix to char token’s value. + if let Some(char_token) = char_token { + prefix = char_token.value; + } + + // 3. If prefix is not the empty string and not options’s prefix code point: + if !prefix.is_empty() + && (options.prefix_code_point.is_none() || prefix != options.prefix_code_point.unwrap().to_string()) + { + // 1. Append prefix to the end of parser’s pending fixed value. + parser.pending_fixed_value.push_str(&prefix); + + // 2. Set prefix to the empty string. + prefix.clear(); + } + + // 4. Run maybe add a part from the pending fixed value given parser. + parser.maybe_add_a_part_from_the_pending_fixed_value()?; + + // 5. Let modifier token be the result of running try to consume a modifier token given parser. + let modifier_token = parser.try_to_consume_a_modifier_token(); + + // 6. Run add a part given parser, prefix, name token, regexp or wildcard token, the empty string, + // and modifier token. + parser.add_a_part(&prefix, name_token, regexp_or_wildcard_token, "", modifier_token)?; + + // 7. Continue. + continue; + } + + // 5. Let fixed token be char token. + // NOTE: If there was no matching group, then we need to buffer any fixed text. We want to collect as + // much text as possible before adding it as a "fixed-text" part. + let mut fixed_token = char_token; + + // 6. If fixed token is null, then set fixed token to the result of running try to consume a token given + // parser and "escaped-char". + if fixed_token.is_none() { + fixed_token = parser.try_to_consume_a_token(TokenType::EscapedChar); + } + + // 7. If fixed token is not null: + if let Some(fixed_token) = fixed_token { + // 1. Append fixed token’s value to parser’s pending fixed value. + parser.pending_fixed_value.push_str(&fixed_token.value); + + // 2. Continue. + continue; + } + + // 8. Let open token be the result of running try to consume a token given parser and "open". + let open_token = parser.try_to_consume_a_token(TokenType::Open); + + // 9. If open token is not null: + if open_token.is_some() { + // 1. Let prefix be the result of running consume text given parser. + let prefix = parser.consume_text(); + + // 2. Set name token to the result of running try to consume a token given parser and "name". + name_token = parser.try_to_consume_a_token(TokenType::Name); + + // 3. Set regexp or wildcard token to the result of running try to consume a regexp or wildcard token + // given parser and name token. + regexp_or_wildcard_token = parser.try_to_consume_a_regexp_or_wildcard_token(name_token.clone()); + + // 4. Let suffix be the result of running consume text given parser. + let suffix = parser.consume_text(); + + // 5. Run consume a required token given parser and "close". + parser.consume_a_required_token(TokenType::Close)?; + + // 6. Let modifier token to the result of running try to consume a modifier token given parser. + let modifier_token = parser.try_to_consume_a_modifier_token(); + + // 7. Run add a part given parser, prefix, name token, regexp or wildcard token, suffix, and modifier token. + parser.add_a_part(&prefix, name_token, regexp_or_wildcard_token, &suffix, modifier_token)?; + + // 8. Continue. + continue; + } + + // 10. Run maybe add a part from the pending fixed value given parser. + parser.maybe_add_a_part_from_the_pending_fixed_value()?; + + // 11. Run consume a required token given parser and "end". + parser.consume_a_required_token(TokenType::End)?; + } + + // 4. Return parser’s part list. + Ok(parser.part_list) + } +} diff --git a/Libraries/LibURL/Rust/src/pattern/string.rs b/Libraries/LibURL/Rust/src/pattern/string.rs new file mode 100644 index 0000000000..c49e49e471 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/string.rs @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use crate::pattern::Options; +use crate::pattern::Part; +use crate::pattern::part::Type as PartType; +use crate::pattern::tokenizer::Tokenizer; + +#[allow(non_upper_case_globals)] +// https://urlpattern.spec.whatwg.org/#full-wildcard-regexp-value +pub const full_wildcard_regexp_value: &str = ".*"; + +// https://urlpattern.spec.whatwg.org/#escape-a-pattern-string +pub fn escape_a_pattern_string(input: &str) -> String { + // 1. Assert: input is an ASCII string. + assert!(input.is_ascii()); + + // 2. Let result be the empty string. + let mut result = String::new(); + + // 3. Let index be 0. + // 4. While index is less than input’s length: + for c in input.chars() { + // 1. Let c be input[index]. + // 2. Increment index by 1. + + // 3. If c is one of: + // * U+002B (+); + // * U+002A (*); + // * U+003F (?); + // * U+003A (:); + // * U+007B ({); + // * U+007D (}); + // * U+0028 ((); + // * U+0029 ()); or + // * U+005C (\), + // then append U+005C (\) to the end of result. + if "+*?:{}()\\".contains(c) { + result.push('\\'); + } + + // 4. Append c to the end of result. + result.push(c); + } + + // 5. Return result. + result +} + +// https://urlpattern.spec.whatwg.org/#escape-a-regexp-string +pub fn escape_a_regexp_string(input: &str) -> String { + // 1. Assert: input is an ASCII string. + assert!(input.is_ascii()); + + // 2. Let result be the empty string. + let mut result = String::new(); + + // 3. Let index be 0. + // 4. While index is less than input’s length: + for c in input.chars() { + // 1. Let c be input[index]. + // 2. Increment index by 1. + + // 3. If c is one of: + // * U+002E (.); + // * U+002B (+); + // * U+002A (*); + // * U+003F (?); + // * U+005E (^); + // * U+0024 ($); + // * U+007B ({); + // * U+007D (}); + // * U+0028 ((); + // * U+0029 ()); + // * U+005B ([); + // * U+005D (]); + // * U+007C (|); + // * U+002F (/); or + // * U+005C (\), + // then append "\" to the end of result. + if ".+*?^${}()[]|/\\".contains(c) { + result.push('\\'); + } + + // 4. Append c to the end of result. + result.push(c); + } + + // 5. Return result. + result +} + +// https://urlpattern.spec.whatwg.org/#generate-a-segment-wildcard-regexp +pub fn generate_a_segment_wildcard_regexp(options: &Options) -> String { + // 1. Let result be "[^". + let mut result = String::from("[^"); + + // 2. Append the result of running escape a regexp string given options’s delimiter code point to the end of result. + if let Some(delimiter_code_point) = options.delimiter_code_point { + result.push_str(&escape_a_regexp_string(&delimiter_code_point.to_string())); + } + + // 3. Append "]+?" to the end of result. + result.push_str("]+?"); + + // 4. Return result. + result +} + +// https://urlpattern.spec.whatwg.org/#generate-a-pattern-string +pub fn generate_a_pattern_string(part_list: &[Part], options: &Options) -> String { + // 1. Let result be the empty string. + let mut result = String::new(); + + // 2. Let index list be the result of getting the indices for part list. + // 3. For each index of index list: + for index in 0..part_list.len() { + // 1. Let part be part list[index]. + let part = &part_list[index]; + + // 2. Let previous part be part list[index - 1] if index is greater than 0, otherwise let it be null. + let previous_part = if index > 0 { Some(&part_list[index - 1]) } else { None }; + + // 3. Let next part be part list[index + 1] if index is less than index list’s size - 1, otherwise let it be null. + let next_part = if index + 1 < part_list.len() { + Some(&part_list[index + 1]) + } else { + None + }; + + // 4. If part’s type is "fixed-text" then: + if part.r#type == PartType::FixedText { + // 1. If part’s modifier is "none" then: + if part.modifier == crate::pattern::part::Modifier::None { + // 1. Append the result of running escape a pattern string given part’s value to the end of result. + result.push_str(&escape_a_pattern_string(&part.value)); + + // 2. Continue. + continue; + } + + // 2. Append "{" to the end of result. + result.push('{'); + + // 3. Append the result of running escape a pattern string given part’s value to the end of result. + result.push_str(&escape_a_pattern_string(&part.value)); + + // 4. Append "}" to the end of result. + result.push('}'); + + // 5. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + + // 6. Continue. + continue; + } + + // 5. Let custom name be true if part’s name[0] is not an ASCII digit; otherwise false. + let custom_name = !part.name.as_bytes()[0].is_ascii_digit(); + + // 6. Let needs grouping be true if at least one of the following are true, otherwise let it be false: + // * part’s suffix is not the empty string. + // * part’s prefix is not the empty string and is not options’s prefix code point. + let mut needs_grouping = !part.suffix.is_empty() + || (!part.prefix.is_empty() + && (options.prefix_code_point.is_some() + && part.prefix != options.prefix_code_point.unwrap().to_string())); + + // 7. If all of the following are true: + // * needs grouping is false; and + // * custom name is true; and + // * part’s type is "segment-wildcard"; and + // * part’s modifier is "none"; and + // * next part is not null; and + // * next part’s prefix is the empty string; and + // * next part’s suffix is the empty string + // then: + if !needs_grouping + && custom_name + && part.r#type == PartType::SegmentWildcard + && part.modifier == crate::pattern::part::Modifier::None + && let Some(next_part) = next_part + && next_part.prefix.is_empty() + && next_part.suffix.is_empty() + { + // 1. If next part’s type is "fixed-text": + if next_part.r#type == PartType::FixedText { + // 1. Set needs grouping to true if the result of running is a valid name code point given next part’s + // value's first code point and the boolean false is true. + needs_grouping = next_part + .value + .chars() + .next() + .is_some_and(|code_point| Tokenizer::is_a_valid_name_code_point(code_point as u32, false)); + } + // 2. Otherwise: + else { + // 1. Set needs grouping to true if next part’s name[0] is an ASCII digit. + needs_grouping = next_part.name.as_bytes()[0].is_ascii_digit(); + } + } + + // 8. If all of the following are true: + // * needs grouping is false; and + // * part’s prefix is the empty string; and + // * previous part is not null; and + // * previous part’s type is "fixed-text"; and + // * previous part’s value's last code point is options’s prefix code point. + // then set needs grouping to true. + if !needs_grouping + && part.prefix.is_empty() + && previous_part.is_some() + && previous_part.unwrap().r#type == PartType::FixedText + && ((previous_part.unwrap().value.is_empty() && options.prefix_code_point.is_none()) + || (options.prefix_code_point.is_some() + && previous_part.unwrap().value == options.prefix_code_point.unwrap().to_string())) + { + needs_grouping = true; + } + + // 9. Assert: part’s name is not the empty string or null. + assert!(!part.name.is_empty()); + + // 10. If needs grouping is true, then append "{" to the end of result. + if needs_grouping { + result.push('{'); + } + + // 11. Append the result of running escape a pattern string given part’s prefix to the end of result. + result.push_str(&escape_a_pattern_string(&part.prefix)); + + // 12. If custom name is true: + if custom_name { + // 1. Append ":" to the end of result. + result.push(':'); + + // 2. Append part’s name to the end of result. + result.push_str(&part.name); + } + + // 13. If part’s type is "regexp" then: + if part.r#type == PartType::Regexp { + // 1. Append "(" to the end of result. + result.push('('); + + // 2. Append part’s value to the end of result. + result.push_str(&part.value); + + // 3. Append ")" to the end of result. + result.push(')'); + } + // 14. Otherwise if part’s type is "segment-wildcard" and custom name is false: + else if part.r#type == PartType::SegmentWildcard && !custom_name { + // 1. Append "(" to the end of result. + result.push('('); + + // 2. Append the result of running generate a segment wildcard regexp given options to the end of result. + result.push_str(&generate_a_segment_wildcard_regexp(options)); + + // 3. Append ")" to the end of result. + result.push(')'); + } + // 15. Otherwise if part’s type is "full-wildcard": + else if part.r#type == PartType::FullWildcard { + // 1. If custom name is false and one of the following is true: + // * previous part is null; or + // * previous part’s type is "fixed-text"; or + // * previous part’s modifier is not "none"; or + // * needs grouping is true; or + // * part’s prefix is not the empty string + // then append "*" to the end of result. + if !custom_name + && (previous_part.is_none() + || previous_part.unwrap().r#type == PartType::FixedText + || previous_part.unwrap().modifier != crate::pattern::part::Modifier::None + || needs_grouping + || !part.prefix.is_empty()) + { + result.push('*'); + } + // 2. Otherwise: + else { + // 1. Append "(" to the end of result. + result.push('('); + + // 2. Append full wildcard regexp value to the end of result. + result.push_str(full_wildcard_regexp_value); + + // 3. Append ")" to the end of result. + result.push(')'); + } + } + + // 16. If all of the following are true: + // * part’s type is "segment-wildcard"; and + // * custom name is true; and + // * part’s suffix is not the empty string; and + // * The result of running is a valid name code point given part’s suffix's first code point and the boolean false is true + // then append U+005C (\) to the end of result. + if part.r#type == PartType::SegmentWildcard + && custom_name + && !part.suffix.is_empty() + && part + .suffix + .chars() + .next() + .is_some_and(|code_point| Tokenizer::is_a_valid_name_code_point(code_point as u32, false)) + { + result.push('\\'); + } + + // 17. Append the result of running escape a pattern string given part’s suffix to the end of result. + result.push_str(&escape_a_pattern_string(&part.suffix)); + + // 18. If needs grouping is true, then append "}" to the end of result. + if needs_grouping { + result.push('}'); + } + + // 19. Append the result of running convert a modifier to a string given part’s modifier to the end of result. + result.push_str(Part::convert_modifier_to_string(part.modifier)); + } + + // 4. Return result. + result +} diff --git a/Libraries/LibURL/Rust/src/pattern/tokenizer.rs b/Libraries/LibURL/Rust/src/pattern/tokenizer.rs new file mode 100644 index 0000000000..5303cc9b20 --- /dev/null +++ b/Libraries/LibURL/Rust/src/pattern/tokenizer.rs @@ -0,0 +1,540 @@ +/* + * Copyright (c) 2025-2026, Shannon Booth + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +use std::fmt; + +use libunicode_rust::character_types::code_point_has_identifier_continue_property; +use libunicode_rust::character_types::code_point_has_identifier_start_property; + +use crate::pattern::ErrorInfo; +use crate::pattern::PatternErrorOr; + +// https://urlpattern.spec.whatwg.org/#token +// A token is a struct representing a single lexical token within a pattern string. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Token { + // https://urlpattern.spec.whatwg.org/#token-type + // A token has an associated type, a string, initially "invalid-char". + pub r#type: Type, + + // https://urlpattern.spec.whatwg.org/#token-index + // A token has an associated index, a number, initially 0. It is the position of the first code point in the pattern string represented by the token. + pub index: u32, + + // https://urlpattern.spec.whatwg.org/#token-value + // A token has an associated value, a string, initially the empty string. It contains the code points from the pattern string represented by the token. + pub value: String, +} + +// https://urlpattern.spec.whatwg.org/#token-type +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Type { + // The token represents a U+007B ({) code point. + Open, + + // The token represents a U+007D (}) code point. + Close, + + // The token represents a string of the form "()". The regular expression is required to consist of only ASCII code points. + Regexp, + + // The token represents a string of the form ":". The name value is restricted to code points that are consistent with JavaScript identifiers. + Name, + + // The token represents a valid pattern code point without any special syntactical meaning. + Char, + + // The token represents a code point escaped using a backslash like "\". + EscapedChar, + + // The token represents a matching group modifier that is either the U+003F (?) or U+002B (+) code points. + OtherModifier, + + // The token represents a U+002A (*) code point that can be either a wildcard matching group or a matching group modifier. + Asterisk, + + // The token represents the end of the pattern string. + End, + + // The token represents a code point that is invalid in the pattern. This could be because of the code point value + // itself or due to its location within the pattern relative to other syntactic elements. + InvalidChar, +} + +impl Token { + pub fn type_to_string(r#type: Type) -> &'static str { + match r#type { + Type::Open => "Open", + Type::Close => "Close", + Type::Regexp => "Regexp", + Type::Name => "Name", + Type::Char => "Char", + Type::EscapedChar => "EscapedChar", + Type::OtherModifier => "OtherModifier", + Type::Asterisk => "Asterisk", + Type::End => "End", + Type::InvalidChar => "InvalidChar", + } + } +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}, index: {}, value: '{}'", + Self::type_to_string(self.r#type), + self.index, + self.value + ) + } +} + +// https://urlpattern.spec.whatwg.org/#tokenizer +// A tokenizer is a struct. +#[derive(Clone, Debug)] +pub struct Tokenizer { + // https://urlpattern.spec.whatwg.org/#tokenizer-input + // A tokenizer has an associated input, a pattern string, initially the empty string. + pub input: String, + + // https://urlpattern.spec.whatwg.org/#tokenizer-policy + // A tokenizer has an associated policy, a tokenize policy, initially "strict". + pub policy: Policy, + + // https://urlpattern.spec.whatwg.org/#tokenizer-token-list + // A tokenizer has an associated token list, a token list, initially an empty list. + pub token_list: Vec, + + // https://urlpattern.spec.whatwg.org/#tokenizer-index + // A tokenizer has an associated index, a number, initially 0. + pub index: usize, + + // https://urlpattern.spec.whatwg.org/#tokenizer-next-index + // A tokenizer has an associated next index, a number, initially 0. + pub next_index: usize, + + // https://urlpattern.spec.whatwg.org/#tokenizer-code-point + // A tokenizer has an associated code point, a Unicode code point, initially null. + pub code_point: u32, + + input_code_points: Vec, +} + +// https://urlpattern.spec.whatwg.org/#tokenize-policy +// A tokenize policy is a string that must be either "strict" or "lenient". +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Policy { + Strict, + Lenient, +} + +impl Tokenizer { + pub(crate) fn new(input: &str, policy: Policy) -> Self { + Self { + input: input.to_string(), + policy, + token_list: Vec::new(), + index: 0, + next_index: 0, + code_point: 0, + input_code_points: input.chars().collect(), + } + } + + // https://urlpattern.spec.whatwg.org/#tokenize + pub fn tokenize(input: &str, policy: Policy) -> PatternErrorOr> { + // 1. Let tokenizer be a new tokenizer. + // 2. Set tokenizer’s input to input. + // 3. Set tokenizer’s policy to policy. + let mut tokenizer = Self::new(input, policy); + + // 4. While tokenizer’s index is less than tokenizer’s input's code point length: + while tokenizer.index < tokenizer.input_code_points.len() { + // 1. Run seek and get the next code point given tokenizer and tokenizer’s index. + tokenizer.seek_and_get_the_next_code_point(tokenizer.index as u32); + + // 2. If tokenizer’s code point is U+002A (*): + if tokenizer.code_point == '*' as u32 { + // 1. Run add a token with default position and length given tokenizer and "asterisk". + tokenizer.add_a_token_with_default_position_and_length(Type::Asterisk); + + // 2. Continue. + continue; + } + + // 3. If tokenizer’s code point is U+002B (+) or U+003F (?): + if tokenizer.code_point == '+' as u32 || tokenizer.code_point == '?' as u32 { + // 1. Run add a token with default position and length given tokenizer and "other-modifier". + tokenizer.add_a_token_with_default_position_and_length(Type::OtherModifier); + + // 2. Continue. + continue; + } + + // 4. If tokenizer’s code point is U+005C (\): + if tokenizer.code_point == '\\' as u32 { + // 1. If tokenizer’s index is equal to tokenizer’s input's code point length − 1: + if tokenizer.index == tokenizer.input_code_points.len() - 1 { + // 1. Run process a tokenizing error given tokenizer, tokenizer’s next index, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(tokenizer.next_index as u32, tokenizer.index as u32)?; + + // 2. Continue. + continue; + } + + // 2. Let escaped index be tokenizer’s next index. + let escaped_index = tokenizer.next_index; + + // 3. Run get the next code point given tokenizer. + tokenizer.get_the_next_code_point(); + + // 4. Run add a token with default length given tokenizer, "escaped-char", tokenizer’s next index, and escaped index. + tokenizer.add_a_token_with_default_length( + Type::EscapedChar, + tokenizer.next_index as u32, + escaped_index as u32, + ); + + // 5. Continue. + continue; + } + + // 5. If tokenizer’s code point is U+007B ({): + if tokenizer.code_point == '{' as u32 { + // 1. Run add a token with default position and length given tokenizer and "open". + tokenizer.add_a_token_with_default_position_and_length(Type::Open); + + // 2. Continue. + continue; + } + + // 6. If tokenizer’s code point is U+007D (}): + if tokenizer.code_point == '}' as u32 { + // 1. Run add a token with default position and length given tokenizer and "close". + tokenizer.add_a_token_with_default_position_and_length(Type::Close); + + // 2. Continue. + continue; + } + + // 1. If tokenizer’s code point is U+003A (:): + if tokenizer.code_point == ':' as u32 { + // 1. Let name position be tokenizer’s next index. + let mut name_position = tokenizer.next_index; + + // 2. Let name start be name position. + let name_start = name_position; + + // 3. While name position is less than tokenizer’s input's code point length: + while name_position < tokenizer.input_code_points.len() { + // 1. Run seek and get the next code point given tokenizer and name position. + tokenizer.seek_and_get_the_next_code_point(name_position as u32); + + // 2. Let first code point be true if name position equals name start and false otherwise. + let first_code_point = name_position == name_start; + + // 3. Let valid code point be the result of running is a valid name code point given tokenizer’s code point and first code point. + let valid_code_point = Self::is_a_valid_name_code_point(tokenizer.code_point, first_code_point); + + // 4. If valid code point is false break. + if !valid_code_point { + break; + } + + // 5. Set name position to tokenizer’s next index. + name_position = tokenizer.next_index; + } + + // 4. If name position is less than or equal to name start: + if name_position <= name_start { + // 1. Run process a tokenizing error given tokenizer, name start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(name_start as u32, tokenizer.index as u32)?; + + // 2. Continue. + continue; + } + + // 5. Run add a token with default length given tokenizer, "name", name position, and name start. + tokenizer.add_a_token_with_default_length(Type::Name, name_position as u32, name_start as u32); + + // 6. Continue. + continue; + } + + // 8. If tokenizer’s code point is U+0028 ((): + if tokenizer.code_point == '(' as u32 { + // 1. Let depth be 1. + let mut depth = 1u32; + + // 2. Let regexp position be tokenizer’s next index. + let mut regexp_position = tokenizer.next_index; + + // 3. Let regexp start be regexp position. + let regexp_start = regexp_position; + + // 4. Let error be false. + let mut error = false; + + // 5. While regexp position is less than tokenizer’s input's code point length: + while regexp_position < tokenizer.input_code_points.len() { + // 1. Run seek and get the next code point given tokenizer and regexp position. + tokenizer.seek_and_get_the_next_code_point(regexp_position as u32); + + // 2. If the result of running is ASCII given tokenizer’s code point is false: + if tokenizer.code_point > 0x7f { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break. + break; + } + + // 3. If regexp position equals regexp start and tokenizer’s code point is U+003F (?): + if regexp_position == regexp_start && tokenizer.code_point == '?' as u32 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break. + break; + } + + // 4. If tokenizer’s code point is U+005C (\): + if tokenizer.code_point == '\\' as u32 { + // 1. If regexp position equals tokenizer’s input's code point length − 1: + if regexp_position == tokenizer.input_code_points.len() - 1 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break + break; + } + + // 2. Run get the next code point given tokenizer. + tokenizer.get_the_next_code_point(); + + // 3. If the result of running is ASCII given tokenizer’s code point is false: + if tokenizer.code_point > 0x7f { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break. + break; + } + + // 4. Set regexp position to tokenizer’s next index. + regexp_position = tokenizer.next_index; + + // 5. Continue. + continue; + } + + // 5. If tokenizer’s code point is U+0029 ()): + if tokenizer.code_point == ')' as u32 { + // 1. Decrement depth by 1. + depth -= 1; + + // 1. If depth is 0: + if depth == 0 { + // 1. Set regexp position to tokenizer’s next index. + regexp_position = tokenizer.next_index; + + // 2. Break. + break; + } + } + // 6. Otherwise if tokenizer’s code point is U+0028 ((): + else if tokenizer.code_point == '(' as u32 { + // 1. Increment depth by 1. + depth += 1; + + // 2. If regexp position equals tokenizer’s input's code point length − 1: + if regexp_position == tokenizer.input_code_points.len() - 1 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break + break; + } + + // 3. Let temporary position be tokenizer’s next index. + let temporary_position = tokenizer.next_index; + + // 4. Run get the next code point given tokenizer. + tokenizer.get_the_next_code_point(); + + // 5. If tokenizer’s code point is not U+003F (?): + if tokenizer.code_point != '?' as u32 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Set error to true. + error = true; + + // 3. Break. + break; + } + + // 6. Set tokenizer’s next index to temporary position. + tokenizer.next_index = temporary_position; + } + + // 7. Set regexp position to tokenizer’s next index. + regexp_position = tokenizer.next_index; + } + + // 6. If error is true continue. + if error { + continue; + } + + // 7. If depth is not zero: + if depth != 0 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Continue. + continue; + } + + // 8. Let regexp length be regexp position − regexp start − 1. + let regexp_length = regexp_position - regexp_start - 1; + + // 9. If regexp length is zero: + if regexp_length == 0 { + // 1. Run process a tokenizing error given tokenizer, regexp start, and tokenizer’s index. + tokenizer.process_a_tokenizing_error(regexp_start as u32, tokenizer.index as u32)?; + + // 2. Continue. + continue; + } + + // 10. Run add a token given tokenizer, "regexp", regexp position, regexp start, and regexp length. + tokenizer.add_a_token( + Type::Regexp, + regexp_position as u32, + regexp_start as u32, + regexp_length as u32, + ); + + // 11. Continue. + continue; + } + + // 9. Run add a token with default position and length given tokenizer and "char". + tokenizer.add_a_token_with_default_position_and_length(Type::Char); + } + + // 5. Run add a token with default length given tokenizer, "end", tokenizer’s index, and tokenizer’s index. + tokenizer.add_a_token_with_default_length(Type::End, tokenizer.index as u32, tokenizer.index as u32); + + // 6. Return tokenizer’s token list. + Ok(tokenizer.token_list) + } + + // https://urlpattern.spec.whatwg.org/#get-the-next-code-point + pub(crate) fn get_the_next_code_point(&mut self) { + // 1. Set tokenizer’s code point to the Unicode code point in tokenizer’s input at the position indicated by tokenizer’s next index. + self.code_point = self.input_code_points[self.next_index] as u32; + + // 2. Increment tokenizer’s next index by 1. + self.next_index += 1; + } + + // https://urlpattern.spec.whatwg.org/#seek-and-get-the-next-code-point + pub(crate) fn seek_and_get_the_next_code_point(&mut self, index: u32) { + // 1. Set tokenizer’s next index to index. + self.next_index = index as usize; + + // 2. Run get the next code point given tokenizer. + self.get_the_next_code_point(); + } + + // https://urlpattern.spec.whatwg.org/#add-a-token + pub(crate) fn add_a_token(&mut self, r#type: Type, next_position: u32, value_position: u32, value_length: u32) { + // 1. Let token be a new token. + let token = Token { + // 2. Set token’s type to type. + r#type, + + // 3. Set token’s index to tokenizer’s index. + index: self.index as u32, + + // 4. Set token’s value to the code point substring from value position with length value length within tokenizer’s input. + value: self + .input_code_points + .iter() + .skip(value_position as usize) + .take(value_length as usize) + .collect(), + }; + + // 5. Append token to the back of tokenizer’s token list. + self.token_list.push(token); + + // 5. Set tokenizer’s index to next position. + self.index = next_position as usize; + } + + // https://urlpattern.spec.whatwg.org/#add-a-token-with-default-length + pub(crate) fn add_a_token_with_default_length(&mut self, r#type: Type, next_position: u32, value_position: u32) { + // 1. Let computed length be next position − value position. + let computed_length = next_position - value_position; + + // 2. Run add a token given tokenizer, type, next position, value position, and computed length. + self.add_a_token(r#type, next_position, value_position, computed_length); + } + + // https://urlpattern.spec.whatwg.org/#add-a-token-with-default-position-and-length + pub(crate) fn add_a_token_with_default_position_and_length(&mut self, r#type: Type) { + // 1. Run add a token with default length given tokenizer, type, tokenizer’s next index, and tokenizer’s index. + self.add_a_token_with_default_length(r#type, self.next_index as u32, self.index as u32); + } + + // https://urlpattern.spec.whatwg.org/#process-a-tokenizing-error + pub(crate) fn process_a_tokenizing_error(&mut self, next_position: u32, value_position: u32) -> PatternErrorOr<()> { + // 1. If tokenizer’s policy is "strict", then throw a TypeError. + if self.policy == Policy::Strict { + return Err(ErrorInfo::new("Error processing a token")); + } + + // 2. Assert: tokenizer’s policy is "lenient". + assert!(self.policy == Policy::Lenient); + + // 3. Run add a token with default length given tokenizer, "invalid-char", next position, and value position. + self.add_a_token_with_default_length(Type::InvalidChar, next_position, value_position); + + Ok(()) + } + + // https://urlpattern.spec.whatwg.org/#is-a-valid-name-code-point + pub fn is_a_valid_name_code_point(code_point: u32, first: bool) -> bool { + // 1. If first is true return the result of checking if code point is contained in the IdentifierStart set of code points. + if first { + return code_point == '$' as u32 + || code_point == '_' as u32 + || code_point_has_identifier_start_property(code_point); + } + + // 2. Otherwise return the result of checking if code point is contained in the IdentifierPart set of code points. + code_point == '$' as u32 || code_point_has_identifier_continue_property(code_point) + } +} diff --git a/Libraries/LibURL/Rust/src/url/mod.rs b/Libraries/LibURL/Rust/src/url/mod.rs index 07f3820f51..888dfdc082 100644 --- a/Libraries/LibURL/Rust/src/url/mod.rs +++ b/Libraries/LibURL/Rust/src/url/mod.rs @@ -14,6 +14,8 @@ mod types; pub(crate) use self::scheme::default_port_for_scheme; pub(crate) use self::scheme::is_special_scheme; +pub(crate) use self::scheme::special_schemes; +pub(crate) use self::types::ExcludeFragment; pub use self::types::Host; pub use self::types::State; pub use self::types::Url; diff --git a/Libraries/LibURL/Rust/src/url/types.rs b/Libraries/LibURL/Rust/src/url/types.rs index 607f83bb62..06989fe6a6 100644 --- a/Libraries/LibURL/Rust/src/url/types.rs +++ b/Libraries/LibURL/Rust/src/url/types.rs @@ -45,7 +45,6 @@ pub(crate) enum ExcludeFragment { Yes, } -#[allow(dead_code)] impl Url { pub(crate) fn set_scheme(&mut self, scheme: String) { self.scheme = scheme; diff --git a/Libraries/LibURL/RustIntegration.cpp b/Libraries/LibURL/RustIntegration.cpp index c3d439ae88..64fd02be3a 100644 --- a/Libraries/LibURL/RustIntegration.cpp +++ b/Libraries/LibURL/RustIntegration.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,123 @@ static String string_from_ffi(FFI::RustUrlByteSlice slice) return String::from_ascii_without_validation({ reinterpret_cast(slice.data), slice.length }); } +static FFI::RustUrlByteSlice string_to_ffi(StringView string) +{ + return { + .data = reinterpret_cast(string.characters_without_null_termination()), + .length = string.length(), + }; +} + +static FFI::RustUrlByteSlice string_to_ffi(String const& string) +{ + return string_to_ffi(string.bytes_as_string_view()); +} + +static FFI::RustUrlByteSlice optional_string_to_ffi(Optional const& string) +{ + if (!string.has_value()) + return string_to_ffi(""sv); + return string_to_ffi(*string); +} + +static FFI::RustUrlPatternInit init_to_ffi(URLPattern::Init const& init) +{ + return { + .has_protocol = init.protocol.has_value(), + .protocol = optional_string_to_ffi(init.protocol), + .has_username = init.username.has_value(), + .username = optional_string_to_ffi(init.username), + .has_password = init.password.has_value(), + .password = optional_string_to_ffi(init.password), + .has_hostname = init.hostname.has_value(), + .hostname = optional_string_to_ffi(init.hostname), + .has_port = init.port.has_value(), + .port = optional_string_to_ffi(init.port), + .has_pathname = init.pathname.has_value(), + .pathname = optional_string_to_ffi(init.pathname), + .has_search = init.search.has_value(), + .search = optional_string_to_ffi(init.search), + .has_hash = init.hash.has_value(), + .hash = optional_string_to_ffi(init.hash), + .has_base_url = init.base_url.has_value(), + .base_url = optional_string_to_ffi(init.base_url), + }; +} + +static URLPattern::Init init_from_ffi(FFI::RustUrlPatternInit const& init) +{ + URLPattern::Init result; + if (init.has_protocol) + result.protocol = string_from_ffi(init.protocol); + if (init.has_username) + result.username = string_from_ffi(init.username); + if (init.has_password) + result.password = string_from_ffi(init.password); + if (init.has_hostname) + result.hostname = string_from_ffi(init.hostname); + if (init.has_port) + result.port = string_from_ffi(init.port); + if (init.has_pathname) + result.pathname = string_from_ffi(init.pathname); + if (init.has_search) + result.search = string_from_ffi(init.search); + if (init.has_hash) + result.hash = string_from_ffi(init.hash); + if (init.has_base_url) + result.base_url = string_from_ffi(init.base_url); + return result; +} + +static URLPattern::Component::Result component_result_from_ffi(FFI::RustUrlPatternComponentResult const& result) +{ + URLPattern::Component::Result component_result; + component_result.input = string_from_ffi(result.input); + + for (size_t i = 0; i < result.group_count; ++i) { + auto const& group = result.groups[i]; + auto name = string_from_ffi(group.name); + if (group.has_value) + component_result.groups.set(move(name), string_from_ffi(group.value)); + else + component_result.groups.set(move(name), Empty {}); + } + + return component_result; +} + +static URLPattern::Result result_from_ffi(FFI::RustUrlPatternExecResult const& result) +{ + URLPattern::Result converted; + + converted.inputs.ensure_capacity(result.input_count); + for (size_t i = 0; i < result.input_count; ++i) { + auto const& input = result.inputs[i]; + if (input.is_string) + converted.inputs.unchecked_append(string_from_ffi(input.string)); + else + converted.inputs.unchecked_append(init_from_ffi(input.init)); + } + + converted.protocol = component_result_from_ffi(result.protocol); + converted.username = component_result_from_ffi(result.username); + converted.password = component_result_from_ffi(result.password); + converted.hostname = component_result_from_ffi(result.hostname); + converted.port = component_result_from_ffi(result.port); + converted.pathname = component_result_from_ffi(result.pathname); + converted.search = component_result_from_ffi(result.search); + converted.hash = component_result_from_ffi(result.hash); + + return converted; +} + +static String take_error(unsigned char const* error_ptr, size_t error_len) +{ + auto error = MUST(String::from_utf8({ reinterpret_cast(error_ptr), error_len })); + FFI::rust_url_pattern_free_error(const_cast(error_ptr), error_len); + return error; +} + static FFI::FfiUrlHost host_to_ffi(Optional const& host) { FFI::FfiUrlHost result {}; @@ -113,6 +231,117 @@ static Optional url_from_ffi(FFI::RustFfiUrl const& ffi) return url; } +URLPattern::Impl::~Impl() +{ + if (rust_url_pattern) + rust_url_pattern_free(rust_url_pattern); +} + +struct ExecCallbackContext { + Optional* result; +}; + +static void on_exec_complete(void* ctx_ptr, FFI::RustUrlPatternExecResult const* ffi_result) +{ + auto* ctx = static_cast(ctx_ptr); + if (!ffi_result) + return; + *ctx->result = result_from_ffi(*ffi_result); +} + +URLPattern::ErrorOr URLPattern::create(Input const& input, Optional const& base_url, FFI::IgnoreCase ignore_case) +{ + unsigned char const* error_ptr = nullptr; + size_t error_len = 0; + FFI::RustUrlPattern* rust_url_pattern = nullptr; + + auto ffi_ignore_case = ignore_case == FFI::IgnoreCase::Yes ? FFI::IgnoreCase::Yes : FFI::IgnoreCase::No; + auto base_url_slice = optional_string_to_ffi(base_url); + if (auto const* input_string = input.get_pointer()) { + auto input_string_view = input_string->bytes_as_string_view(); + rust_url_pattern = rust_url_pattern_create_from_string( + reinterpret_cast(input_string_view.characters_without_null_termination()), + input_string_view.length(), + base_url.has_value() ? base_url_slice.data : nullptr, + base_url_slice.length, + ffi_ignore_case, + &error_ptr, + &error_len); + } else { + VERIFY(input.has()); + if (base_url.has_value()) + return ErrorInfo { "Constructor with URLPatternInit should provide no baseURL"_string }; + + auto ffi_init = init_to_ffi(input.get()); + rust_url_pattern = rust_url_pattern_create_from_init(&ffi_init, ffi_ignore_case, &error_ptr, &error_len); + } + + if (!rust_url_pattern) { + auto error = error_ptr ? take_error(error_ptr, error_len) : "Failed to create URLPattern"_string; + return ErrorInfo { move(error) }; + } + + bool has_regexp_groups = rust_url_pattern_has_regexp_groups(rust_url_pattern); + + auto impl = make(); + impl->rust_url_pattern = rust_url_pattern; + URLPattern pattern; + pattern.m_impl = move(impl); + pattern.m_has_regexp_groups = has_regexp_groups; + pattern.m_protocol_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Protocol)); + pattern.m_username_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Username)); + pattern.m_password_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Password)); + pattern.m_hostname_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Hostname)); + pattern.m_port_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Port)); + pattern.m_pathname_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Pathname)); + pattern.m_search_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Search)); + pattern.m_hash_component.pattern_string = string_from_ffi(rust_url_pattern_component_pattern_string(rust_url_pattern, FFI::RustUrlPatternComponent::Hash)); + return pattern; +} + +URLPattern::ErrorOr> URLPattern::match(Input const& input, Optional const& base_url_string) const +{ + Optional result; + ExecCallbackContext callback_context { .result = &result }; + unsigned char const* error_ptr = nullptr; + size_t error_len = 0; + + bool did_succeed = false; + auto base_url_slice = optional_string_to_ffi(base_url_string); + if (auto const* input_string = input.get_pointer()) { + auto input_string_view = input_string->bytes_as_string_view(); + did_succeed = rust_url_pattern_exec_string( + m_impl->rust_url_pattern, + reinterpret_cast(input_string_view.characters_without_null_termination()), + input_string_view.length(), + base_url_string.has_value() ? base_url_slice.data : nullptr, + base_url_slice.length, + &error_ptr, + &error_len, + &callback_context, + on_exec_complete); + } else { + VERIFY(input.has()); + if (base_url_string.has_value()) + return ErrorInfo { "Base URL cannot be provided when URLPatternInput is provided"_string }; + + auto ffi_init = init_to_ffi(input.get()); + did_succeed = rust_url_pattern_exec_init(m_impl->rust_url_pattern, &ffi_init, &error_ptr, &error_len, &callback_context, on_exec_complete); + } + + if (!did_succeed) { + auto error = error_ptr ? take_error(error_ptr, error_len) : "Failed to execute URLPattern"_string; + return ErrorInfo { move(error) }; + } + + return result; +} + +bool URLPattern::has_regexp_groups() const +{ + return m_has_regexp_groups; +} + struct UrlFfiStorage { Vector path_segments; FFI::RustFfiUrl ffi_url {}; diff --git a/Libraries/LibURL/RustIntegration.h b/Libraries/LibURL/RustIntegration.h index 95087ff0cb..b8d9752b87 100644 --- a/Libraries/LibURL/RustIntegration.h +++ b/Libraries/LibURL/RustIntegration.h @@ -6,12 +6,96 @@ #pragma once +#include #include +#include +#include #include +#include +#include +#include #include namespace URL::RustIntegration { Optional parse_basic_url(StringView input, Optional base_url = {}, URL* url = nullptr, Optional state_override = {}, Optional encoding = {}); +class URLPattern { +public: + // NOTE: All exceptions which are thrown by the URLPattern spec are TypeErrors which web-based callers are expected to assume. + // If this ever does not become the case, this should change to also include the error type. + struct ErrorInfo { + String message; + }; + + template + using ErrorOr = AK::ErrorOr; + + struct Component { + String pattern_string; + struct Result { + String input; + OrderedHashMap> groups; + }; + }; + + struct Init { + Optional protocol; + Optional username; + Optional password; + Optional hostname; + Optional port; + Optional pathname; + Optional search; + Optional hash; + Optional base_url; + }; + + using Input = Variant; + + struct Result { + Vector inputs; + + Component::Result protocol; + Component::Result username; + Component::Result password; + Component::Result hostname; + Component::Result port; + Component::Result pathname; + Component::Result search; + Component::Result hash; + }; + + static ErrorOr create(Input const&, Optional const& base_url = {}, FFI::IgnoreCase = FFI::IgnoreCase::No); + + ErrorOr> match(Input const&, Optional const& base_url_string) const; + + bool has_regexp_groups() const; + + Component const& protocol_component() const { return m_protocol_component; } + Component const& username_component() const { return m_username_component; } + Component const& password_component() const { return m_password_component; } + Component const& hostname_component() const { return m_hostname_component; } + Component const& port_component() const { return m_port_component; } + Component const& pathname_component() const { return m_pathname_component; } + Component const& search_component() const { return m_search_component; } + Component const& hash_component() const { return m_hash_component; } + +private: + struct Impl { + FFI::RustUrlPattern* rust_url_pattern { nullptr }; + ~Impl(); + }; + OwnPtr m_impl; + Component m_protocol_component; + Component m_username_component; + Component m_password_component; + Component m_hostname_component; + Component m_port_component; + Component m_pathname_component; + Component m_search_component; + Component m_hash_component; + bool m_has_regexp_groups { false }; +}; + } diff --git a/Libraries/LibWeb/URLPattern/URLPattern.cpp b/Libraries/LibWeb/URLPattern/URLPattern.cpp index 41d18ab238..f1529dd385 100644 --- a/Libraries/LibWeb/URLPattern/URLPattern.cpp +++ b/Libraries/LibWeb/URLPattern/URLPattern.cpp @@ -13,7 +13,7 @@ namespace Web::URLPattern { GC_DEFINE_ALLOCATOR(URLPattern); -static URL::Pattern::Init to_internal_url_pattern_init(Bindings::URLPatternInit const& init) +static URL::RustIntegration::URLPattern::Init to_internal_url_pattern_init(Bindings::URLPatternInit const& init) { return { .protocol = init.protocol, @@ -28,7 +28,7 @@ static URL::Pattern::Init to_internal_url_pattern_init(Bindings::URLPatternInit }; } -static Bindings::URLPatternInit to_bindings_url_pattern_init(URL::Pattern::Init const& init) +static Bindings::URLPatternInit to_bindings_url_pattern_init(URL::RustIntegration::URLPattern::Init const& init) { return { .base_url = init.base_url, @@ -43,14 +43,14 @@ static Bindings::URLPatternInit to_bindings_url_pattern_init(URL::Pattern::Init }; } -static URL::Pattern::Input to_internal_url_pattern_input(URLPatternInput const& input) +static URL::RustIntegration::URLPattern::Input to_internal_url_pattern_input(URLPatternInput const& input) { return input.visit( - [](String const& input_string) -> URL::Pattern::Input { return input_string; }, - [](Bindings::URLPatternInit const& input_init) -> URL::Pattern::Input { return to_internal_url_pattern_init(input_init); }); + [](String const& input_string) -> URL::RustIntegration::URLPattern::Input { return input_string; }, + [](Bindings::URLPatternInit const& input_init) -> URL::RustIntegration::URLPattern::Input { return to_internal_url_pattern_init(input_init); }); } -static Bindings::URLPatternComponentResult to_bindings_url_pattern_component_result(URL::Pattern::Component::Result const& result) +static Bindings::URLPatternComponentResult to_bindings_url_pattern_component_result(URL::RustIntegration::URLPattern::Component::Result const& result) { OrderedHashMap> groups; for (auto const& [key, value] : result.groups) { @@ -63,14 +63,14 @@ static Bindings::URLPatternComponentResult to_bindings_url_pattern_component_res }; } -static Bindings::URLPatternResult to_bindings_url_pattern_result(URL::Pattern::Result const& result) +static Bindings::URLPatternResult to_bindings_url_pattern_result(URL::RustIntegration::URLPattern::Result const& result) { Vector> inputs; inputs.ensure_capacity(result.inputs.size()); for (auto const& input : result.inputs) { inputs.unchecked_append(input.visit( [](String const& string_value) -> Variant { return string_value; }, - [](URL::Pattern::Init const& init_value) -> Variant { return to_bindings_url_pattern_init(init_value); })); + [](URL::RustIntegration::URLPattern::Init const& init_value) -> Variant { return to_bindings_url_pattern_init(init_value); })); } return { @@ -86,7 +86,7 @@ static Bindings::URLPatternResult to_bindings_url_pattern_result(URL::Pattern::R }; } -URLPattern::URLPattern(JS::Realm& realm, URL::Pattern::Pattern pattern) +URLPattern::URLPattern(JS::Realm& realm, URL::RustIntegration::URLPattern pattern) : PlatformObject(realm) , m_url_pattern(move(pattern)) { @@ -118,7 +118,7 @@ WebIDL::ExceptionOr> URLPattern::construct_impl(JS::Realm& r WebIDL::ExceptionOr> URLPattern::create(JS::Realm& realm, URLPatternInput const& input, Optional const& base_url, URLPatternOptions const& options) { // 1. Set this’s associated URL pattern to the result of create given input, baseURL, and options. - auto pattern_or_error = URL::Pattern::Pattern::create(to_internal_url_pattern_input(input), base_url, options.ignore_case ? URL::Pattern::IgnoreCase::Yes : URL::Pattern::IgnoreCase::No); + auto pattern_or_error = URL::RustIntegration::URLPattern::create(to_internal_url_pattern_input(input), base_url, options.ignore_case ? URL::FFI::IgnoreCase::Yes : URL::FFI::IgnoreCase::No); if (pattern_or_error.is_error()) return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, pattern_or_error.error().message }; return realm.create(realm, pattern_or_error.release_value()); diff --git a/Libraries/LibWeb/URLPattern/URLPattern.h b/Libraries/LibWeb/URLPattern/URLPattern.h index 4bf1035f57..2c2bef36fa 100644 --- a/Libraries/LibWeb/URLPattern/URLPattern.h +++ b/Libraries/LibWeb/URLPattern/URLPattern.h @@ -7,8 +7,7 @@ #pragma once #include -#include -#include +#include #include #include @@ -48,12 +47,12 @@ public: protected: virtual void initialize(JS::Realm&) override; - explicit URLPattern(JS::Realm&, URL::Pattern::Pattern); + explicit URLPattern(JS::Realm&, URL::RustIntegration::URLPattern); private: // https://urlpattern.spec.whatwg.org/#ref-for-url-pattern%E2%91%A0 // Each URLPattern has an associated URL pattern, a URL pattern. - URL::Pattern::Pattern m_url_pattern; + URL::RustIntegration::URLPattern m_url_pattern; }; } diff --git a/Tests/LibURL/TestURLPattern.cpp b/Tests/LibURL/TestURLPattern.cpp index de598fed16..40b990ff4a 100644 --- a/Tests/LibURL/TestURLPattern.cpp +++ b/Tests/LibURL/TestURLPattern.cpp @@ -6,11 +6,11 @@ #include -#include +#include TEST_CASE(url_pattern_matches_named_groups) { - auto pattern = MUST(URL::Pattern::Pattern::create("https://example.com/:category/:id"_string)); + auto pattern = MUST(URL::RustIntegration::URLPattern::create("https://example.com/:category/:id"_string)); auto result = MUST(pattern.match("https://example.com/books/42"_string, {})); VERIFY(result.has_value()); @@ -22,7 +22,7 @@ TEST_CASE(url_pattern_matches_named_groups) TEST_CASE(url_pattern_ignore_case_matching) { - auto pattern = MUST(URL::Pattern::Pattern::create("https://example.com/:value"_string, {}, URL::Pattern::IgnoreCase::Yes)); + auto pattern = MUST(URL::RustIntegration::URLPattern::create("https://example.com/:value"_string, {}, URL::FFI::IgnoreCase::Yes)); auto result = MUST(pattern.match("https://example.com/CaseSensitive"_string, {})); VERIFY(result.has_value());