Browse Source

Add a URL class to boa_runtime (#4004)

* Add a URL class (with caveats)

Some methods are NOT currently supported (some don' make sense
outside of a browser context). They are still implemented but
will throw a JavaScript Error.

Supported methods should follow the specification perfectly.

* Adding tests and using url::quirks for simpler getters/setters

* clippies

* Address comments
pull/4018/head
Hans Larsen 2 months ago committed by GitHub
parent
commit
94d08fe4e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      Cargo.lock
  2. 11
      cli/src/main.rs
  3. 5
      core/runtime/Cargo.toml
  4. 4
      core/runtime/src/console/mod.rs
  5. 53
      core/runtime/src/lib.rs
  6. 236
      core/runtime/src/url.rs
  7. 113
      core/runtime/src/url/tests.rs
  8. 13
      core/string/src/lib.rs

1
Cargo.lock generated

@ -540,6 +540,7 @@ dependencies = [
"indoc",
"rustc-hash 2.0.0",
"textwrap",
"url",
]
[[package]]

11
cli/src/main.rs

@ -14,15 +14,12 @@ use boa_engine::{
builtins::promise::PromiseState,
context::ContextBuilder,
job::{FutureJob, JobQueue, NativeJob},
js_string,
module::{Module, SimpleModuleLoader},
optimizer::OptimizerOptions,
property::Attribute,
script::Script,
vm::flowgraph::{Direction, Graph},
Context, JsError, JsNativeError, JsResult, Source,
};
use boa_runtime::Console;
use clap::{Parser, ValueEnum, ValueHint};
use colored::Colorize;
use debug::init_boa_debug_object;
@ -442,12 +439,10 @@ fn main() -> Result<(), io::Error> {
Ok(())
}
/// Adds the CLI runtime to the context.
/// Adds the CLI runtime to the context with default options.
fn add_runtime(context: &mut Context) {
let console = Console::init(context);
context
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
.expect("the console object shouldn't exist");
boa_runtime::register(context, boa_runtime::RegisterOptions::new())
.expect("should not fail while registering the runtime");
}
#[derive(Default)]

5
core/runtime/Cargo.toml

@ -15,6 +15,7 @@ boa_engine.workspace = true
boa_gc.workspace = true
boa_interop.workspace = true
rustc-hash = { workspace = true, features = ["std"] }
url = { version = "2.5.2", optional = true }
[dev-dependencies]
indoc.workspace = true
@ -25,3 +26,7 @@ workspace = true
[package.metadata.docs.rs]
all-features = true
[features]
default = ["all"]
all = ["url"]

4
core/runtime/src/console/mod.rs

@ -58,8 +58,8 @@ pub trait Logger: Trace + Sized {
/// Implements the [`Logger`] trait and output errors to stderr and all
/// the others to stdout. Will add indentation based on the number of
/// groups.
#[derive(Trace, Finalize)]
struct DefaultLogger;
#[derive(Debug, Trace, Finalize)]
pub struct DefaultLogger;
impl Logger for DefaultLogger {
#[inline]

53
core/runtime/src/lib.rs

@ -61,8 +61,60 @@ mod text;
#[doc(inline)]
pub use text::{TextDecoder, TextEncoder};
pub mod url;
/// Options used when registering all built-in objects and functions of the `WebAPI` runtime.
#[derive(Debug)]
pub struct RegisterOptions<L: Logger> {
console_logger: L,
}
impl Default for RegisterOptions<console::DefaultLogger> {
fn default() -> Self {
Self {
console_logger: console::DefaultLogger,
}
}
}
impl RegisterOptions<console::DefaultLogger> {
/// Create a new `RegisterOptions` with the default options.
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl<L: Logger> RegisterOptions<L> {
/// Set the logger for the console object.
pub fn with_console_logger<L2: Logger>(self, logger: L2) -> RegisterOptions<L2> {
RegisterOptions::<L2> {
console_logger: logger,
}
}
}
/// Register all the built-in objects and functions of the `WebAPI` runtime.
///
/// # Errors
/// This will error is any of the built-in objects or functions cannot be registered.
pub fn register(
ctx: &mut boa_engine::Context,
options: RegisterOptions<impl Logger + 'static>,
) -> boa_engine::JsResult<()> {
Console::register_with_logger(ctx, options.console_logger)?;
TextDecoder::register(ctx)?;
TextEncoder::register(ctx)?;
#[cfg(feature = "url")]
url::Url::register(ctx)?;
Ok(())
}
#[cfg(test)]
pub(crate) mod test {
use crate::{register, RegisterOptions};
use boa_engine::{builtins, Context, JsResult, JsValue, Source};
use std::borrow::Cow;
@ -126,6 +178,7 @@ pub(crate) mod test {
#[track_caller]
pub(crate) fn run_test_actions(actions: impl IntoIterator<Item = TestAction>) {
let context = &mut Context::default();
register(context, RegisterOptions::default()).expect("failed to register WebAPI objects");
run_test_actions_with(actions, context);
}

236
core/runtime/src/url.rs

@ -0,0 +1,236 @@
//! Boa's implementation of JavaScript's `URL` Web API class.
//!
//! The `URL` class can be instantiated from any global object.
//! This relies on the `url` feature.
//!
//! More information:
//! - [MDN documentation][mdn]
//! - [WHATWG `URL` specification][spec]
//!
//! [spec]: https://url.spec.whatwg.org/
//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/URL
#![cfg(feature = "url")]
#[cfg(test)]
mod tests;
use boa_engine::value::Convert;
use boa_engine::{
js_error, js_string, Context, Finalize, JsData, JsResult, JsString, JsValue, Trace,
};
use boa_interop::{js_class, IntoJsFunctionCopied, JsClass};
use std::fmt::Display;
/// The `URL` class represents a (properly parsed) Uniform Resource Locator.
#[derive(Debug, Clone, JsData, Trace, Finalize)]
#[boa_gc(unsafe_no_drop)]
pub struct Url(#[unsafe_ignore_trace] url::Url);
impl Url {
/// Register the `URL` class into the realm.
///
/// # Errors
/// This will error if the context or realm cannot register the class.
pub fn register(context: &mut Context) -> JsResult<()> {
context.register_global_class::<Self>()?;
Ok(())
}
/// Create a new `URL` object. Meant to be called from the JavaScript constructor.
///
/// # Errors
/// Any errors that might occur during URL parsing.
fn js_new(Convert(ref url): Convert<String>, base: &Option<Convert<String>>) -> JsResult<Self> {
if let Some(Convert(base)) = base {
let base_url = url::Url::parse(base)
.map_err(|e| js_error!(TypeError: "Failed to parse base URL: {}", e))?;
if base_url.cannot_be_a_base() {
return Err(js_error!(TypeError: "Base URL {} cannot be a base", base));
}
let url = base_url
.join(url)
.map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?;
Ok(Self(url))
} else {
let url = url::Url::parse(url)
.map_err(|e| js_error!(TypeError: "Failed to parse URL: {}", e))?;
Ok(Self(url))
}
}
}
impl Display for Url {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<url::Url> for Url {
fn from(url: url::Url) -> Self {
Self(url)
}
}
impl From<Url> for url::Url {
fn from(url: Url) -> url::Url {
url.0
}
}
js_class! {
class Url as "URL" {
property hash {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::hash(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
url::quirks::set_hash(&mut this.borrow_mut().0, &value.0);
}
}
property hostname {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::hostname(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let _ = url::quirks::set_hostname(&mut this.borrow_mut().0, &value.0);
}
}
property host {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::host(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let _ = url::quirks::set_host(&mut this.borrow_mut().0, &value.0);
}
}
property href {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::href(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) -> JsResult<()> {
url::quirks::set_href(&mut this.borrow_mut().0, &value.0)
.map_err(|e| js_error!(TypeError: "Failed to set href: {}", e))
}
}
property origin {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::origin(&this.borrow().0))
}
}
property password {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::password(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let _ = url::quirks::set_password(&mut this.borrow_mut().0, &value.0);
}
}
property pathname {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::pathname(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let () = url::quirks::set_pathname(&mut this.borrow_mut().0, &value.0);
}
}
property port {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::port(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<JsString>) {
let _ = url::quirks::set_port(&mut this.borrow_mut().0, &value.0.to_std_string_lossy());
}
}
property protocol {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::protocol(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let _ = url::quirks::set_protocol(&mut this.borrow_mut().0, &value.0);
}
}
property search {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(url::quirks::search(&this.borrow().0))
}
fn set(this: JsClass<Url>, value: Convert<String>) {
url::quirks::set_search(&mut this.borrow_mut().0, &value.0);
}
}
property search_params as "searchParams" {
fn get() -> JsResult<()> {
Err(js_error!(Error: "URL.searchParams is not implemented"))
}
}
property username {
fn get(this: JsClass<Url>) -> JsString {
JsString::from(this.borrow().0.username())
}
fn set(this: JsClass<Url>, value: Convert<String>) {
let _ = this.borrow_mut().0.set_username(&value.0);
}
}
constructor(url: Convert<String>, base: Option<Convert<String>>) {
Self::js_new(url, &base)
}
init(class: &mut ClassBuilder) -> JsResult<()> {
let create_object_url = (|| -> JsResult<()> {
Err(js_error!(Error: "URL.createObjectURL is not implemented"))
})
.into_js_function_copied(class.context());
let can_parse = (|url: Convert<String>, base: Option<Convert<String>>| {
Url::js_new(url, &base).is_ok()
})
.into_js_function_copied(class.context());
let parse = (|url: Convert<String>, base: Option<Convert<String>>, context: &mut Context| {
Url::js_new(url, &base)
.map_or(Ok(JsValue::null()), |u| Url::from_data(u, context).map(JsValue::from))
})
.into_js_function_copied(class.context());
let revoke_object_url = (|| -> JsResult<()> {
Err(js_error!(Error: "URL.revokeObjectURL is not implemented"))
})
.into_js_function_copied(class.context());
class
.static_method(js_string!("createObjectURL"), 1, create_object_url)
.static_method(js_string!("canParse"), 2, can_parse)
.static_method(js_string!("parse"), 2, parse)
.static_method(js_string!("revokeObjectUrl"), 1, revoke_object_url);
Ok(())
}
fn to_string as "toString"(this: JsClass<Url>) -> JsString {
JsString::from(format!("{}", this.borrow().0))
}
fn to_json as "toJSON"(this: JsClass<Url>) -> JsString {
JsString::from(format!("{}", this.borrow().0))
}
}
}

113
core/runtime/src/url/tests.rs

@ -0,0 +1,113 @@
use crate::test::{run_test_actions, TestAction};
const TEST_HARNESS: &str = r#"
function assert(condition, message) {
if (!condition) {
if (!message) {
message = "Assertion failed";
}
throw new Error(message);
}
}
function assert_eq(a, b, message) {
if (a !== b) {
throw new Error(`${message} (${JSON.stringify(a)} !== ${JSON.stringify(b)})`);
}
}
"#;
#[test]
fn url_basic() {
run_test_actions([
TestAction::run(TEST_HARNESS),
TestAction::run(
r##"
url = new URL("https://example.com:8080/path/to/resource?query#fragment");
assert(url instanceof URL);
assert_eq(url.href, "https://example.com:8080/path/to/resource?query#fragment");
assert_eq(url.protocol, "https:");
assert_eq(url.host, "example.com:8080");
assert_eq(url.hostname, "example.com");
assert_eq(url.port, "8080");
assert_eq(url.pathname, "/path/to/resource");
assert_eq(url.search, "?query");
assert_eq(url.hash, "#fragment");
"##,
),
]);
}
#[test]
fn url_base() {
run_test_actions([
TestAction::run(TEST_HARNESS),
TestAction::run(
r##"
url = new URL("https://example.com:8080/path/to/resource?query#fragment", "http://example.org/");
assert_eq(url.href, "https://example.com:8080/path/to/resource?query#fragment");
assert_eq(url.protocol, "https:");
assert_eq(url.host, "example.com:8080");
assert_eq(url.hostname, "example.com");
assert_eq(url.port, "8080");
assert_eq(url.pathname, "/path/to/resource");
assert_eq(url.search, "?query");
assert_eq(url.hash, "#fragment");
"##,
),
TestAction::run(
r##"
url = new URL("/path/to/resource?query#fragment", "http://example.org/");
assert_eq(url.href, "http://example.org/path/to/resource?query#fragment");
assert_eq(url.protocol, "http:");
assert_eq(url.host, "example.org");
assert_eq(url.hostname, "example.org");
assert_eq(url.port, "");
assert_eq(url.pathname, "/path/to/resource");
assert_eq(url.search, "?query");
assert_eq(url.hash, "#fragment");
"##,
),
]);
}
#[test]
fn url_setters() {
// These were double checked against Firefox.
run_test_actions([
TestAction::run(TEST_HARNESS),
TestAction::run(
r##"
url = new URL("https://example.com:8080/path/to/resource?query#fragment");
url.protocol = "http:";
url.host = "example.org:80"; // Since protocol is http, port is removed.
url.pathname = "/new/path";
url.search = "?new-query";
url.hash = "#new-fragment";
assert_eq(url.href, "http://example.org/new/path?new-query#new-fragment");
assert_eq(url.protocol, "http:");
assert_eq(url.host, "example.org");
assert_eq(url.hostname, "example.org");
assert_eq(url.port, "");
assert_eq(url.pathname, "/new/path");
assert_eq(url.search, "?new-query");
assert_eq(url.hash, "#new-fragment");
"##,
),
]);
}
#[test]
fn url_static_methods() {
run_test_actions([
TestAction::run(TEST_HARNESS),
TestAction::run(
r##"
assert(URL.canParse("http://example.org/new/path?new-query#new-fragment"));
assert(!URL.canParse("http//:example.org/new/path?new-query#new-fragment"));
assert(!URL.canParse("http://example.org/new/path?new-query#new-fragment", "http:"));
assert(URL.canParse("/new/path?new-query#new-fragment", "http://example.org/"));
"##,
),
]);
}

13
core/string/src/lib.rs

@ -454,6 +454,19 @@ impl JsString {
self.to_string_escaped()
}
/// Decodes a [`JsString`] into a [`String`], replacing invalid data with the
/// replacement character U+FFFD.
#[inline]
#[must_use]
pub fn to_std_string_lossy(&self) -> String {
self.code_points()
.map(|cp| match cp {
CodePoint::Unicode(c) => c,
CodePoint::UnpairedSurrogate(_) => '\u{FFFD}',
})
.collect()
}
/// Decodes a [`JsString`] into a [`String`], returning
///
/// # Errors

Loading…
Cancel
Save