diff --git a/src/rust/jsg-compile-tests/non-coercible-type.rs b/src/rust/jsg-compile-tests/non-coercible-type.rs new file mode 100644 index 00000000000..d1beea0e0af --- /dev/null +++ b/src/rust/jsg-compile-tests/non-coercible-type.rs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// NonCoercible takes only the types a value is otherwise coerced to (String, bool, f64 +// and the integers), as C++ JSG's takes only a CoercibleType. A union or a resource +// would convert a value as it always does, coercion included, once its JavaScript type passed a +// coarse check. + +use jsg::FromJS; +use jsg::NonCoercible; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] +pub enum ThingOrString { + Thing(jsg::Rc), + String(String), +} + +pub fn union_name() -> String { + as FromJS<'static>>::type_name() //~ ERROR E0277 +} + +pub fn resource_name() -> String { + > as FromJS<'static>>::type_name() //~ ERROR E0277 +} diff --git a/src/rust/jsg-compile-tests/ok-oneof.rs b/src/rust/jsg-compile-tests/ok-oneof.rs new file mode 100644 index 00000000000..2cce97ead74 --- /dev/null +++ b/src/rust/jsg-compile-tests/ok-oneof.rs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The unions that Web IDL allows compile, including several resources, a handle of any type with +// a dictionary, an object handle with primitives, and different resources and typed array handles +// in a union and a union nested in it. + +use jsg::Nullable; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +pub struct Point { + pub x: f64, +} + +#[jsg_resource] +pub struct First; + +#[jsg_resource] +impl First {} + +#[jsg_resource] +pub struct Second; + +#[jsg_resource] +impl Second {} + +#[jsg_oneof] +pub enum Everything<'s> { + First(jsg::Rc), + Second(jsg::Rc), + Point(Point), + Numbers(Vec), + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), + Number(i32), + Bool(bool), + Date(std::time::SystemTime), +} + +#[jsg_oneof] +pub enum Handles<'s> { + Object(v8::Local<'s, v8::Object>), + String(Nullable), + Number(v8::Local<'s, v8::BigInt>), + Function(v8::Local<'s, v8::Function>), +} + +#[jsg_oneof] +pub enum AnyOrPoint<'s> { + Any(v8::Local<'s, v8::Value>), + Point(Point), +} + +#[jsg_oneof] +pub enum SecondOrClamped<'s> { + Second(jsg::Rc), + Clamped(v8::Local<'s, v8::Uint8ClampedArray>), +} + +#[jsg_oneof] +pub enum FirstOrNested<'s> { + First(jsg::Rc), + Bytes(v8::Local<'s, v8::Uint8Array>), + Nested(SecondOrClamped<'s>), +} diff --git a/src/rust/jsg-compile-tests/oneof-duplicate-types.rs b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs new file mode 100644 index 00000000000..99a145b1dd8 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-duplicate-types.rs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A #[jsg_oneof] union may not contain the same type twice, as C++ JSG's +// webidl::UnionTypeValidator requires of a kj::OneOf, even of a kind it may have more than one +// of, such as resources. As there, a nullable type counts as its inner type. Each union is +// rejected twice: by conflicting trait impls, which point at the second variant, and because +// the union's names contain the resource's twice. + +use jsg::Nullable; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +type SameThing = jsg::Rc; + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum SameResourceTwice { + First(jsg::Rc), + Second(SameThing), //~ ERROR E0119 +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum ResourceAndNullableResource { + Thing(jsg::Rc), + Nullable(Nullable>), //~ ERROR E0119 +} diff --git a/src/rust/jsg-compile-tests/oneof-generic-parameters.rs b/src/rust/jsg-compile-tests/oneof-generic-parameters.rs new file mode 100644 index 00000000000..a034f6e31e9 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-generic-parameters.rs @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A #[jsg_oneof] union, like a kj::OneOf, has no type parameters. It may have one lifetime +// parameter, for the scope of the handles it holds. + +use jsg::v8; +use jsg_macros::jsg_oneof; + +#[jsg_oneof] +pub enum TypeParameter { + //~^ ERROR only one lifetime parameter + Value(T), + String(String), +} + +#[jsg_oneof] +pub enum TwoLifetimes<'a, 'b> { + //~^ ERROR only one lifetime parameter + First(v8::Local<'a, v8::Object>), + Second(v8::Local<'b, v8::String>), +} diff --git a/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs b/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs new file mode 100644 index 00000000000..df62dcf7587 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-nested-duplicate-types.rs @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A #[jsg_oneof] union may not contain a type that a union nested in it also contains, as C++ +// JSG's webidl::FlattenedTypeTraits flattens a nested kj::OneOf before checking for duplicate +// types. These types are in no Web IDL category with a limit, so only their names catch them. + +use std::time::SystemTime; + +use jsg::Nullable; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] +pub enum ThingOrString { + Thing(jsg::Rc), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum ResourceInNestedUnion { + Thing(jsg::Rc), + Nested(ThingOrString), +} + +#[jsg_oneof] +pub enum NullableThingOrString { + Thing(Nullable>), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum ResourceInNullableInNestedUnion { + Thing(jsg::Rc), + Nested(NullableThingOrString), +} + +#[jsg_oneof] +pub enum BytesOrString<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum HandleInNestedUnion<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + Nested(BytesOrString<'s>), +} + +#[jsg_oneof] +pub enum DateOrString { + Date(SystemTime), + String(String), +} + +#[jsg_oneof] +pub enum DateOrBool { + Date(SystemTime), + Bool(bool), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain the same type twice +pub enum DateInTwoNestedUnions { + First(DateOrString), + Second(DateOrBool), +} diff --git a/src/rust/jsg-compile-tests/oneof-variant-type.rs b/src/rust/jsg-compile-tests/oneof-variant-type.rs new file mode 100644 index 00000000000..96c73aebf95 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-variant-type.rs @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A #[jsg_oneof] variant is a type that converts from JavaScript and that C++ JSG's kj::OneOf +// could hold. + +use jsg_macros::jsg_oneof; + +#[jsg_oneof] //~ ERROR E0277 +pub enum FloatOrString { + Float(f32), //~ ERROR cannot be a `#[jsg_oneof]` variant + //~| ERROR E0277 + String(String), +} diff --git a/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs b/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs new file mode 100644 index 00000000000..58679ada045 --- /dev/null +++ b/src/rust/jsg-compile-tests/oneof-web-idl-rules.rs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// A #[jsg_oneof] union follows Web IDL's rules for unions, as C++ JSG's +// webidl::UnionTypeValidator checks a kj::OneOf at compile time: at most one type of each kind +// that a value can't tell apart, counting the types of nested unions and nullable types. + +use jsg::Dict; +use jsg::Nullable; +use jsg::v8; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +pub struct Point { + pub x: f64, +} + +#[jsg_resource] +pub struct Thing; + +#[jsg_resource] +impl Thing {} + +#[jsg_oneof] //~ ERROR E0080: at most one boolean type +pub enum TwoBooleans { + Bool(bool), + Strict(jsg::NonCoercible), +} + +#[jsg_oneof] //~ ERROR E0080: at most one numeric type +pub enum TwoNumerics { + Number(f64), + Integer(u32), +} + +#[jsg_oneof] //~ ERROR E0080: at most one string type +pub enum TwoStrings<'s> { + String(String), + Handle(v8::Local<'s, v8::String>), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndResource<'s> { + Object(v8::Local<'s, v8::Object>), + Thing(jsg::Rc), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndRecord<'s> { + Object(v8::Local<'s, v8::Object>), + Dict(Dict), +} + +#[jsg_oneof] //~ ERROR E0080: an object type only if +pub enum ObjectAndSequence<'s> { + Object(v8::Local<'s, v8::Object>), + Sequence(Vec), +} + +#[jsg_oneof] //~ ERROR E0080: at most one dictionary-like type +pub enum DictionaryAndRecord { + Point(Point), + Dict(Dict), +} + +#[jsg_oneof] //~ ERROR E0080: at most one sequence-like type +pub enum TwoSequences { + Numbers(Vec), + Strings(Vec), +} + +#[jsg_oneof] //~ ERROR E0080: at most one nullable or dictionary type +pub enum NullableAndDictionary { + Number(Nullable), + Point(Point), +} + +#[jsg_oneof] //~ ERROR E0080: at most one nullable or dictionary type +pub enum TwoNullables { + Number(Nullable), + Integer(Nullable), +} + +#[jsg_oneof] //~ ERROR E0080: may not contain Option or Lenient +pub enum Optional { + Number(Option), + String(String), +} + +#[jsg_oneof] +pub enum StringOrBool { + String(String), + Bool(bool), +} + +// The nested union's string type counts as the outer union's. +#[jsg_oneof] //~ ERROR E0080: at most one string type +pub enum NestedStrings { + String(String), + Nested(StringOrBool), +} diff --git a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs index 39557933e8b..0c9a771cb6f 100644 --- a/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs +++ b/src/rust/jsg-compile-tests/owned-fromjs-bound-rejects-local.rs @@ -3,12 +3,13 @@ // https://opensource.org/licenses/Apache-2.0 // `T: for<'s> FromJS<'s, ResultType = T>` (used where a value must outlive the scope) excludes -// handle types. +// handle types. Compilers word the error differently: some report that the `FromJS` impl is not +// general enough, others that the bound `for<'s> FromJS<'s>` is not satisfied (E0277). use jsg::FromJS; use jsg::v8; pub fn owned FromJS<'s, ResultType = T>>() {} pub fn t() { - owned::>(); //~ ERROR E0277 + owned::>(); //~ ERROR FromJS } diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index 3a3113af3ad..2c483481dbb 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -414,11 +414,17 @@ impl Env { ## `#[jsg_oneof]` -Generates `jsg::Type` and `jsg::FromJS` for a union enum — the Rust equivalent of -`kj::OneOf<…>`. Each variant must be a single-field tuple whose inner type implements -`jsg::Type` + `jsg::FromJS`. Variants are tried in declaration order using -exact-type matching; if none matches, a `TypeError` is thrown listing all expected -types. +Generates `jsg::Type`, `jsg::FromJS` and `jsg::Traced` for a union enum — the Rust +equivalent of `kj::OneOf<…>` — and `jsg::ToJS` when every variant implements it. Each +variant must be a single-field tuple whose inner type converts from JavaScript and is +`jsg::Type`. A union that holds handles has one lifetime parameter, +their scope, and no `jsg::Traced` that can be used; a union has no other generic +parameters. A value converts as it would to the equivalent `kj::OneOf`: variants are +tried in passes that leave coercion for last, in declaration order within a pass. If no +variant converts the value, a `TypeError` is thrown naming the expected types, such as +`Expected string or number but got symbol`. Like `kj::OneOf`, a union that breaks Web +IDL's rules for unions, such as one with two string types, doesn't compile. See +[Union Types](../jsg/README.md#union-types). ```rust #[jsg_oneof] diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index a88c892fe61..7fd1dcd4794 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -22,6 +22,7 @@ #![forbid(unsafe_code)] +mod oneof; mod resource; mod trace; mod utils; @@ -31,16 +32,13 @@ use proc_macro::TokenStream; use quote::quote; use resource::generate_resource_impl; use resource::generate_resource_struct; -use syn::Data; use syn::DeriveInput; -use syn::Fields; use syn::FnArg; use syn::ItemFn; use syn::ItemImpl; use syn::parse_macro_input; use trace::generate_trace_statements; use utils::elide_named_lifetimes; -use utils::error; use utils::extract_name_attribute; use utils::extract_named_fields; use utils::fast_api_signature; @@ -123,6 +121,9 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { #input impl jsg::Type for #name { + // A Web IDL dictionary type, like a C++ `JSG_STRUCT`. + const UNION_TYPES: jsg::UnionTypes = jsg::UnionTypes::DICTIONARY; + fn class_name() -> &'static str { #class_name } fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { @@ -828,12 +829,19 @@ pub fn jsg_wildcard_property(attr: TokenStream, item: TokenStream) -> TokenStrea // #[jsg_oneof] // ============================================================================= -/// Generates `jsg::Type` and `jsg::FromJS` for a union enum, equivalent to -/// `kj::OneOf<…>` in C++ JSG. +/// Generates `jsg::Type`, `jsg::FromJS`, `jsg::Traced` and `jsg::ToJS` for a +/// union enum, equivalent to `kj::OneOf<…>` in C++ JSG. /// -/// Each variant must be a single-field tuple variant whose inner type implements -/// `jsg::Type` and `jsg::FromJS`. The macro tries each variant in declaration -/// order using exact-type matching and returns the first that succeeds. +/// Each variant must be a single-field tuple variant whose inner type converts +/// from JavaScript and is `jsg::Type`. A value converts as it +/// would to the equivalent `kj::OneOf`: the variants are tried in passes that +/// keep coercion for last (see "Union Types" in `src/rust/jsg/README.md`), and +/// in declaration order within a pass. A variant that is itself a union takes +/// part in each pass with its own variants. The union traces the variant it +/// holds, so it can be a resource field, unless it holds handles: such a union +/// has one lifetime parameter, their scope. `jsg::ToJS` is implemented when +/// every variant implements it. A union that breaks Web IDL's rules for unions, +/// as C++ JSG's `webidl::UnionTypeValidator` checks them, doesn't compile. /// /// ```ignore /// #[jsg_oneof] @@ -845,98 +853,5 @@ pub fn jsg_wildcard_property(attr: TokenStream, item: TokenStream) -> TokenStrea /// ``` #[proc_macro_attribute] pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - let name = &input.ident; - - let Data::Enum(data) = &input.data else { - return error(&input, "#[jsg_oneof] can only be applied to enums"); - }; - - let mut variants = Vec::new(); - for variant in &data.variants { - let variant_name = &variant.ident; - let Fields::Unnamed(fields) = &variant.fields else { - return error( - variant, - "#[jsg_oneof] variants must be tuple variants (e.g., `Variant(Type)`)", - ); - }; - if fields.unnamed.len() != 1 { - return error(variant, "#[jsg_oneof] variants must have exactly one field"); - } - let inner_type = &fields.unnamed[0].ty; - variants.push((variant_name, inner_type)); - } - - if variants.is_empty() { - return error(&input, "#[jsg_oneof] requires at least one variant"); - } - - let type_checks: Vec<_> = variants - .iter() - .map(|(variant_name, inner_type)| { - quote! { - if let Some(result) = <#inner_type as jsg::FromJS<'s>>::try_from_js_exact(lock, value) { - return result.map(|value| Some(Self::#variant_name(value))); - } - } - }) - .collect(); - - let inner_types: Vec<_> = variants.iter().map(|(_, t)| t).collect(); - let type_names: Vec<_> = variants - .iter() - .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::class_name() }) - .collect(); - - let is_exact_checks: Vec<_> = variants - .iter() - .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::is_exact(value) }) - .collect(); - - let error_msg = quote! { - let expected: Vec<&str> = vec![#(#type_names),*]; - format!( - "Expected one of [{}] but got {}", - expected.join(", "), - type_name - ) - }; - - quote! { - #input - - #[automatically_derived] - impl jsg::Type for #name { - fn class_name() -> &'static str { - stringify!(#name) - } - - fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { - #(#is_exact_checks)||* - } - } - - #[automatically_derived] - impl<'s> jsg::FromJS<'s> for #name { - type ResultType = Self; - - fn type_name() -> String { - [#(<#inner_types as jsg::FromJS<'s>>::type_name()),*].join(" or ") - } - - fn from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result { - let type_name = value.type_of(lock); - Self::try_from_js(lock, value)?.ok_or_else(|| { - jsg::Error::new_type_error({ #error_msg }) - }) - } - - fn try_from_js(lock: &mut jsg::Lock<'s>, value: jsg::v8::Local<'s, jsg::v8::Value>) -> Result, jsg::Error> { - #(#type_checks)* - Ok(None) - } - } - } - .into() + oneof::expand(item) } diff --git a/src/rust/jsg-macros/oneof-test.rs b/src/rust/jsg-macros/oneof-test.rs new file mode 100644 index 00000000000..da5452ff5da --- /dev/null +++ b/src/rust/jsg-macros/oneof-test.rs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use quote::quote; + +use super::*; + +/// `tokens` with the lifetime `'s` replaced, as a string. +fn replaced(tokens: proc_macro2::TokenStream) -> String { + let name = proc_macro2::Ident::new("s", proc_macro2::Span::call_site()); + replace_lifetime(tokens, &name).to_string() +} + +#[test] +fn replace_lifetime_replaces_only_the_named_lifetime() { + assert_eq!( + replaced(quote! { jsg::v8::Local<'s, jsg::v8::Value> }), + quote! { jsg::v8::Local<'static, jsg::v8::Value> }.to_string() + ); + assert_eq!( + replaced(quote! { Vec<(&'a str, Local<'s, Value>, Nested<'s>)> }), + quote! { Vec<(&'a str, Local<'static, Value>, Nested<'static>)> }.to_string() + ); + assert_eq!(replaced(quote! { s }), quote! { s }.to_string()); + assert_eq!(replaced(quote! { 'x' }), quote! { 'x' }.to_string()); +} + +/// `ty`, parsed, without the `Nullable<…>`s around it, as a string. +fn stripped(ty: &str) -> String { + let ty: syn::Type = syn::parse_str(ty).unwrap(); + strip_nullable(&ty).to_token_stream().to_string() +} + +#[test] +fn strip_nullable_strips_every_nullable_as_written() { + assert_eq!(stripped("Nullable"), "String"); + assert_eq!( + stripped("jsg::Nullable>"), + "jsg :: Rc < Thing >" + ); + assert_eq!(stripped("Nullable>"), "u32"); + assert_eq!(stripped("Vec>"), "Vec < Nullable < u32 > >"); + assert_eq!(stripped("Option"), "Option < String >"); +} diff --git a/src/rust/jsg-macros/oneof.rs b/src/rust/jsg-macros/oneof.rs new file mode 100644 index 00000000000..35a210a251b --- /dev/null +++ b/src/rust/jsg-macros/oneof.rs @@ -0,0 +1,376 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! `#[jsg_oneof]`: unions, the Rust counterpart of C++ JSG's `kj::OneOf`. + +use proc_macro::TokenStream; +use proc_macro2::Spacing; +use proc_macro2::TokenTree; +use quote::ToTokens; +use quote::quote; +use quote::quote_spanned; +use syn::Data; +use syn::DeriveInput; +use syn::Fields; +use syn::GenericParam; +use syn::Lifetime; +use syn::spanned::Spanned; + +use crate::utils::error; + +/// Expands `#[jsg_oneof]` on `item`. +pub fn expand(item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as DeriveInput); + match Union::parse(&input) { + Ok(union) => union.generate(&input).into(), + Err(err) => err, + } +} + +/// A `#[jsg_oneof]` enum. +struct Union<'a> { + /// The enum's lifetime parameter, which its handle variants borrow, if it has one. + lifetime: Option<&'a Lifetime>, + /// The name of each variant. + variant_names: Vec<&'a syn::Ident>, + /// The type each variant holds. + inner_types: Vec<&'a syn::Type>, +} + +impl<'a> Union<'a> { + /// Checks that `input` is an enum the macro supports, and collects its variants. + fn parse(input: &'a DeriveInput) -> Result { + let Data::Enum(data) = &input.data else { + return Err(error(input, "#[jsg_oneof] can only be applied to enums")); + }; + let lifetime = Self::parse_lifetime(input)?; + + let mut variant_names = Vec::new(); + let mut inner_types = Vec::new(); + for variant in &data.variants { + let Fields::Unnamed(fields) = &variant.fields else { + return Err(error( + variant, + "#[jsg_oneof] variants must be tuple variants (e.g., `Variant(Type)`)", + )); + }; + if fields.unnamed.len() != 1 { + return Err(error( + variant, + "#[jsg_oneof] variants must have exactly one field", + )); + } + variant_names.push(&variant.ident); + inner_types.push(&fields.unnamed[0].ty); + } + if variant_names.is_empty() { + return Err(error(input, "#[jsg_oneof] requires at least one variant")); + } + + Ok(Self { + lifetime, + variant_names, + inner_types, + }) + } + + /// The enum's lifetime parameter. A union takes at most one, for the scope of the handles + /// it holds, and no type or const parameters, as `kj::OneOf` has none. + fn parse_lifetime(input: &'a DeriveInput) -> Result, TokenStream> { + let generics = &input.generics; + if let Some(where_clause) = &generics.where_clause { + return Err(error( + where_clause, + "#[jsg_oneof] unions can't have a where clause", + )); + } + let mut lifetime = None; + for param in &generics.params { + match param { + GenericParam::Lifetime(param) if lifetime.is_none() && param.bounds.is_empty() => { + lifetime = Some(¶m.lifetime); + } + _ => { + return Err(error( + param, + "#[jsg_oneof] unions can have only one lifetime parameter, with no \ + bounds, for the handles they hold", + )); + } + } + } + Ok(lifetime) + } + + /// Generates the enum and the union's trait implementations. + fn generate(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let checks = self.generate_checks(input); + let type_impl = self.generate_type(input); + let from_js_impl = self.generate_from_js(input); + let traced_impl = self.generate_traced(input); + let to_js_impl = self.generate_to_js(input); + quote! { + #input + #checks + #type_impl + #from_js_impl + #traced_impl + #to_js_impl + } + } + + /// The enum's type, with its lifetime parameter if it has one. + fn self_type(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let lifetime = self.lifetime.into_iter(); + quote! { #name<#(#lifetime)*> } + } + + /// `ty` with the enum's lifetime replaced by `'static`, so that it can be named outside the + /// enum's impls, in a constant. + fn with_static_lifetime(&self, ty: impl ToTokens) -> proc_macro2::TokenStream { + let tokens = ty.into_token_stream(); + if let Some(lifetime) = self.lifetime { + replace_lifetime(tokens, &lifetime.ident) + } else { + tokens + } + } + + /// Compile-time checks that the union follows Web IDL's rules for unions, as C++ JSG's + /// `webidl::UnionTypeValidator` checks a `kj::OneOf`. The rules on the variants' categories + /// are checked through their types' `jsg::UnionVariant::UNION_TYPES`, which flatten nested + /// unions and nullable types. A variant type that appears twice has two conflicting + /// implementations of a local trait, whose error points at the variant; a `Nullable` + /// variant implements it for `T`, as `webidl::FlattenedTypeTraits` flattens `kj::Maybe` to + /// `T`. That can't see into a nested union, so the names in `jsg::Type::UNION_NAMES`, which + /// flatten as the categories do, catch a resource, handle or `SystemTime` that appears in + /// both a union and a union nested in it. + fn generate_checks(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.with_static_lifetime(self.self_type(input)); + // Spanned on the variant, so that a duplicate's error points at it. + let distinct_impls = self.inner_types.iter().map(|ty| { + let static_type = self.with_static_lifetime(strip_nullable(ty)); + quote_spanned! {ty.span()=> + impl JsgOneofVariantsMustHaveDistinctTypes for #static_type {} + } + }); + quote! { + const _: () = <#self_type as jsg::Type>::UNION_TYPES.validate(); + const _: () = jsg::validate_union_names(<#self_type as jsg::Type>::UNION_NAMES); + + const _: () = { + /// A `#[jsg_oneof]` union may not contain duplicate types; a type that has two + /// implementations of this trait is the type of two variants. + trait JsgOneofVariantsMustHaveDistinctTypes {} + #(#distinct_impls)* + }; + } + } + + /// `jsg::Type`, so that the union can be a variant of another union or a sequence's element. + /// A union is not `jsg::Coercible`, so it can't be a `NonCoercible`. + fn generate_type(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let name = &input.ident; + let self_type = self.self_type(input); + let lifetime = self.lifetime.into_iter(); + let inner_types = &self.inner_types; + // A constant's type can't use the enum's lifetime, so the names are those of the + // `'static` variant types; a lifetime doesn't change a type's name. + let static_types: Vec<_> = inner_types + .iter() + .map(|ty| self.with_static_lifetime(ty)) + .collect(); + quote! { + #[automatically_derived] + impl<#(#lifetime)*> jsg::Type for #self_type { + const UNION_TYPES: jsg::UnionTypes = jsg::UnionTypes::OTHER + #(.plus(<#inner_types as jsg::UnionVariant>::UNION_TYPES))*; + + const UNION_NAMES: &'static [&'static str] = { + const LEN: usize = + 0 #(+ <#static_types as jsg::UnionVariant>::UNION_NAMES.len())*; + const NAMES: [&str; LEN] = jsg::concat_union_names( + &[#(<#static_types as jsg::UnionVariant>::UNION_NAMES),*], + ); + &NAMES + }; + + fn class_name() -> &'static str { + stringify!(#name) + } + + fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { + #(<#inner_types as jsg::UnionVariant>::is_exact(value))||* + } + } + } + } + + /// `jsg::FromJS`, which tries the variants in the passes of `jsg::try_union_from_js`. + fn generate_from_js(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let s = self.scope_lifetime(); + let impl_lifetime = &s; + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + quote! { + #[automatically_derived] + impl<#impl_lifetime> jsg::FromJS<#s> for #self_type { + type ResultType = Self; + + fn type_name() -> String { + [#(<#inner_types as jsg::FromJS<#s>>::type_name()),*].join(" or ") + } + + fn from_js(lock: &mut jsg::Lock<#s>, value: jsg::v8::Local<#s, jsg::v8::Value>) -> Result { + let type_name = value.type_of(lock); + Self::try_from_js(lock, value)?.ok_or_else(|| { + jsg::Error::new_type_error(format!( + "Expected {} but got {}", + >::type_name(), + type_name + )) + }) + } + + fn try_from_js(lock: &mut jsg::Lock<#s>, value: jsg::v8::Local<#s, jsg::v8::Value>) -> Result, jsg::Error> { + jsg::try_union_from_js(lock, value, Self::try_from_js_in_union_pass) + } + + fn try_from_js_in_union_pass( + lock: &mut jsg::Lock<#s>, + value: jsg::v8::Local<#s, jsg::v8::Value>, + pass: jsg::UnionMember, + ) -> Result, jsg::Error> { + #( + if let Some(value) = <#inner_types as jsg::FromJS<#s>>::try_from_js_in_union_pass(lock, value, pass)? { + return Ok(Some(Self::#variant_names(value))); + } + )* + Ok(None) + } + } + } + } + + /// `jsg::Traced`: a union holds whatever its variant holds, so it can be a resource field. + /// A union that holds handles can't, so its implementation requires what its variants can't + /// provide, and it is never used. + fn generate_traced(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let lifetime = self.lifetime.into_iter(); + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + let where_clause = self.lifetime.map(|_| { + quote! { where #(#inner_types: jsg::Traced,)* } + }); + quote! { + #[automatically_derived] + impl<#(#lifetime)*> jsg::Traced for #self_type #where_clause { + fn holds_gc_references() -> bool { + #(<#inner_types as jsg::Traced>::holds_gc_references())||* + } + + fn trace(&self, visitor: &mut jsg::v8::GcVisitor) { + match self { + #(Self::#variant_names(value) => jsg::Traced::trace(value, visitor),)* + } + } + } + } + } + + /// `jsg::ToJS`, when every variant converts to JavaScript. + fn generate_to_js(&self, input: &DeriveInput) -> proc_macro2::TokenStream { + let self_type = self.self_type(input); + let s = self.scope_lifetime(); + let variant_names = &self.variant_names; + let inner_types = &self.inner_types; + quote! { + #[automatically_derived] + impl<#s> jsg::ToJS<#s> for #self_type + where + #(#inner_types: jsg::ToJS<#s>,)* + { + fn to_js(self, lock: &mut jsg::Lock<#s>) -> Result, jsg::Error> { + match self { + #(Self::#variant_names(value) => jsg::ToJS::to_js(value, lock),)* + } + } + } + } + } + + /// The lifetime of the lock in the union's `FromJS` and `ToJS` impls: the enum's lifetime + /// parameter, which its handles borrow, or a fresh one. + fn scope_lifetime(&self) -> Lifetime { + self.lifetime + .cloned() + .unwrap_or_else(|| Lifetime::new("'s", proc_macro2::Span::call_site())) + } +} + +/// `ty` without the `Nullable<…>`s around it, as written: `T` for `Nullable` or +/// `jsg::Nullable>`. A type alias of a `Nullable` is not seen through. +fn strip_nullable(ty: &syn::Type) -> &syn::Type { + let syn::Type::Path(path) = ty else { + return ty; + }; + if path.qself.is_some() { + return ty; + } + let Some(segment) = path.path.segments.last() else { + return ty; + }; + if segment.ident != "Nullable" { + return ty; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return ty; + }; + match args.args.first() { + Some(syn::GenericArgument::Type(inner)) if args.args.len() == 1 => strip_nullable(inner), + _ => ty, + } +} + +/// `tokens` with every use of the lifetime named `name` replaced by `'static`. +fn replace_lifetime( + tokens: proc_macro2::TokenStream, + name: &proc_macro2::Ident, +) -> proc_macro2::TokenStream { + let mut output = Vec::new(); + let mut tokens = tokens.into_iter().peekable(); + while let Some(token) = tokens.next() { + match token { + TokenTree::Punct(punct) + if punct.as_char() == '\'' && punct.spacing() == Spacing::Joint => + { + if let Some(TokenTree::Ident(ident)) = tokens.peek() + && ident == name + { + tokens.next(); + output.extend(quote! { 'static }); + } else { + output.push(TokenTree::Punct(punct)); + } + } + TokenTree::Group(group) => { + let mut replaced = proc_macro2::Group::new( + group.delimiter(), + replace_lifetime(group.stream(), name), + ); + replaced.set_span(group.span()); + output.push(TokenTree::Group(replaced)); + } + token => output.push(token), + } + } + output.into_iter().collect() +} + +#[cfg(test)] +#[path = "oneof-test.rs"] +mod tests; diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index b82124ee2da..25a2caf4a23 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -101,6 +101,9 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> #[automatically_derived] impl jsg::Type for #name { + const UNION_NAMES: &'static [&'static str] = + &[concat!(module_path!(), "::", #name_str)]; + fn class_name() -> &'static str { #class_name } fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { @@ -123,6 +126,8 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> impl<'s> jsg::FromJS<'s> for #name { type ResultType = jsg::Rc; + const UNION_MEMBER: jsg::UnionMember = jsg::UnionMember::Resource; + fn type_name() -> String { ::class_name().to_owned() } diff --git a/src/rust/jsg-test/tests/argument_errors.rs b/src/rust/jsg-test/tests/argument_errors.rs index d24ea45e182..e8726aeeb01 100644 --- a/src/rust/jsg-test/tests/argument_errors.rs +++ b/src/rust/jsg-test/tests/argument_errors.rs @@ -19,10 +19,12 @@ struct Point { pub x: u32, } +// `Text` is `NonCoercible` so that a number is not converted to a string, +// leaving the union nothing to convert it to. #[jsg_oneof] #[derive(Debug)] enum Key { - Text(String), + Text(jsg::NonCoercible), Point(Point), } @@ -64,7 +66,7 @@ impl Args { #[jsg_method] pub fn take_key(&self, key: Key) -> String { match key { - Key::Text(text) => text, + Key::Text(text) => text.into_inner(), Key::Point(point) => point.x.to_string(), } } diff --git a/src/rust/jsg-test/tests/jsg_oneof.rs b/src/rust/jsg-test/tests/jsg_oneof.rs index 0116cd952f7..c353baa0792 100644 --- a/src/rust/jsg-test/tests/jsg_oneof.rs +++ b/src/rust/jsg-test/tests/jsg_oneof.rs @@ -2,11 +2,72 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 +//! `#[jsg_oneof]` unions convert values as C++ JSG's `kj::OneOf` does +//! (`OneOfWrapper` in `jsg/value.h`). The first tests mirror the `OneOf` test in +//! `src/workerd/jsg/value-test.c++`, except `returnOneOf(null, null, null)`, +//! since a Rust enum can't be empty. Later ones port the unions in other tests +//! there. + +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + use jsg::ExceptionType; +use jsg::NonCoercible; +use jsg::Nullable; use jsg::ToJS; use jsg_macros::jsg_method; use jsg_macros::jsg_oneof; +use jsg_macros::jsg_property; use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_resource] +struct OneOfBox { + value: f64, +} + +#[jsg_resource] +impl OneOfBox { + #[jsg_property(prototype, readonly)] + pub fn get_value(&self) -> f64 { + self.value + } +} + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct Point { + pub x: u32, +} + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct OptionalPoint { + pub x: Option, +} + +/// `kj::OneOf>`. +#[jsg_oneof] +#[derive(Debug, Clone)] +enum NumberStringOrBox { + Number(f64), + String(String), + Box(jsg::Rc), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrBool { + String(String), + Bool(bool), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NumberOrBool { + Number(f64), + Bool(bool), +} #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] @@ -22,227 +83,651 @@ enum NumberOrString { String(String), } +/// `kj::OneOf>`. #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] -enum StringOrBool { +enum NumberOrNested { + Number(f64), + Nested(StringOrBool), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrBytes { String(String), - Bool(bool), + Bytes(Vec), } #[jsg_oneof] #[derive(Debug, Clone, PartialEq)] -enum ThreeTypes { +enum StringOrOptionalPoint { String(String), + Point(OptionalPoint), +} + +#[jsg_oneof] +#[derive(Debug, Clone)] +enum PointOrBox { + Point(OptionalPoint), + Box(jsg::Rc), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum PointOrBytes { + Point(Point), + Bytes(Vec), +} + +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StrictStringOrNumber { + String(NonCoercible), + Number(f64), +} + +/// `kj::OneOf, NonCoercible>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StrictBoolOrString { + Bool(NonCoercible), + String(NonCoercible), +} + +/// `kj::OneOf>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum StringOrStrings { + String(String), + Strings(Vec), +} + +/// `kj::OneOf>`, taken as a `kj::Maybe`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum OnlyStrictString { + String(NonCoercible), +} + +/// A string that converts from JavaScript but not to it. +#[derive(Debug, Clone, PartialEq)] +struct FromJsOnly(String); + +jsg::jsg_traced!(FromJsOnly); + +impl jsg::Type for FromJsOnly { + fn class_name() -> &'static str { + "FromJsOnly" + } + + fn is_exact(value: jsg::v8::Local<'_, jsg::v8::Value>) -> bool { + value.is_string() + } +} + +impl<'s> jsg::FromJS<'s> for FromJsOnly { + type ResultType = Self; + + fn from_js( + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + ) -> Result { + >::from_js(lock, value).map(Self) + } + + fn try_from_js( + lock: &mut jsg::Lock<'s>, + value: jsg::v8::Local<'s, jsg::v8::Value>, + ) -> Result, jsg::Error> { + if !value.is_string() { + return Ok(None); + } + Self::from_js(lock, value).map(Some) + } +} + +/// A union that has no `ToJS`, since `FromJsOnly` has none. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum FromJsOnlyOrNumber { + Text(FromJsOnly), Number(f64), - Bool(bool), } #[jsg_resource] -struct EnumTestResource; +struct OneOfContext; #[jsg_resource] -#[expect(clippy::unnecessary_wraps)] -impl EnumTestResource { +impl OneOfContext { + #[jsg_method] + pub fn take_one_of(&self, value: NumberStringOrBox) -> String { + match value { + NumberStringOrBox::Number(n) => format!("number:{n}"), + NumberStringOrBox::String(s) => format!("string:{s}"), + NumberStringOrBox::Box(b) => format!("box:{}", b.value), + } + } + + #[jsg_method] + pub fn return_one_of(&self, kind: String) -> NumberStringOrBox { + match kind.as_str() { + "number" => NumberStringOrBox::Number(123.0), + "string" => NumberStringOrBox::String("foo".to_owned()), + _ => NumberStringOrBox::Box(jsg::Rc::new(OneOfBox { value: 321.0 })), + } + } + + #[jsg_method] + pub fn take_string_or_bool(&self, value: StringOrBool) -> String { + describe_string_or_bool(&value) + } + + #[jsg_method] + pub fn take_number_or_bool(&self, value: NumberOrBool) -> String { + match value { + NumberOrBool::Number(n) => format!("number:{n}"), + NumberOrBool::Bool(b) => format!("bool:{b}"), + } + } + + #[jsg_method] + pub fn take_string_or_number(&self, value: StringOrNumber) -> String { + match value { + StringOrNumber::String(s) => format!("string:{s}"), + StringOrNumber::Number(n) => format!("number:{n}"), + } + } + + #[jsg_method] + pub fn take_string_or_number_ref(&self, value: &StringOrNumber) -> String { + match value { + StringOrNumber::String(s) => format!("ref_string:{s}"), + StringOrNumber::Number(n) => format!("ref_number:{n}"), + } + } + + #[jsg_method] + pub fn take_number_or_string(&self, value: NumberOrString) -> String { + match value { + NumberOrString::Number(n) => format!("number:{n}"), + NumberOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn take_nested_one_of(&self, value: NumberOrNested) -> String { + match value { + NumberOrNested::Number(n) => format!("number:{n}"), + NumberOrNested::Nested(nested) => describe_string_or_bool(&nested), + } + } + + #[jsg_method] + pub fn take_string_or_bytes(&self, value: StringOrBytes) -> String { + match value { + StringOrBytes::String(s) => format!("string:{s}"), + StringOrBytes::Bytes(b) => format!("bytes:{b:?}"), + } + } + + #[jsg_method] + pub fn take_string_or_optional_point(&self, value: StringOrOptionalPoint) -> String { + match value { + StringOrOptionalPoint::String(s) => format!("string:{s}"), + StringOrOptionalPoint::Point(p) => format!("point:{:?}", p.x), + } + } + + #[jsg_method] + pub fn take_point_or_box(&self, value: PointOrBox) -> String { + match value { + PointOrBox::Point(p) => format!("point:{:?}", p.x), + PointOrBox::Box(b) => format!("box:{}", b.value), + } + } + #[jsg_method] - pub fn string_or_number(&self, value: StringOrNumber) -> Result { + pub fn take_from_js_only_or_number(&self, value: FromJsOnlyOrNumber) -> String { match value { - StringOrNumber::String(s) => Ok(format!("string:{s}")), - StringOrNumber::Number(n) => Ok(format!("number:{n}")), + FromJsOnlyOrNumber::Text(t) => format!("text:{}", t.0), + FromJsOnlyOrNumber::Number(n) => format!("number:{n}"), } } #[jsg_method] - pub fn string_or_number_ref(&self, value: &StringOrNumber) -> Result { + pub fn take_point_or_bytes(&self, value: PointOrBytes) -> String { match value { - StringOrNumber::String(s) => Ok(format!("ref_string:{s}")), - StringOrNumber::Number(n) => Ok(format!("ref_number:{n}")), + PointOrBytes::Point(p) => format!("point:{}", p.x), + PointOrBytes::Bytes(b) => format!("bytes:{b:?}"), } } #[jsg_method] - pub fn string_or_bool(&self, value: StringOrBool) -> Result { + pub fn take_strict_string_or_number(&self, value: StrictStringOrNumber) -> String { match value { - StringOrBool::String(s) => Ok(format!("string:{s}")), - StringOrBool::Bool(b) => Ok(format!("bool:{b}")), + StrictStringOrNumber::String(s) => format!("string:{}", s.into_inner()), + StrictStringOrNumber::Number(n) => format!("number:{n}"), } } #[jsg_method] - pub fn three_types(&self, value: ThreeTypes) -> Result { + pub fn take_strict_bool_or_string(&self, value: StrictBoolOrString) -> String { match value { - ThreeTypes::String(s) => Ok(format!("string:{s}")), - ThreeTypes::Number(n) => Ok(format!("number:{n}")), - ThreeTypes::Bool(b) => Ok(format!("bool:{b}")), + StrictBoolOrString::Bool(b) => format!("bool:{}", b.into_inner()), + StrictBoolOrString::String(s) => format!("string:{}", s.into_inner()), } } #[jsg_method] - pub fn number_or_string(&self, value: NumberOrString) -> Result { + pub fn take_string_or_strings(&self, value: StringOrStrings) -> String { match value { - NumberOrString::Number(n) => Ok(format!("number:{n}")), - NumberOrString::String(s) => Ok(format!("string:{s}")), + StringOrStrings::String(s) => format!("string:{s}"), + StringOrStrings::Strings(v) => format!("strings:{v:?}"), } } + + #[jsg_method] + pub fn take_maybe_strict_string(&self, value: Nullable) -> String { + match value { + Nullable::Some(OnlyStrictString::String(s)) => format!("string:{}", s.into_inner()), + Nullable::Null => "null".to_owned(), + } + } + + #[jsg_method] + pub fn take_points_or_bytes(&self, values: Vec) -> String { + format!("{}", values.len()) + } +} + +fn describe_string_or_bool(value: &StringOrBool) -> String { + match value { + StringOrBool::String(s) => format!("string:{s}"), + StringOrBool::Bool(b) => format!("bool:{b}"), + } +} + +/// Runs `f` with a `OneOfContext` installed as the global `cx` and a +/// `OneOfBox` holding 321 as the global `box321`. +fn with_context(f: impl for<'s, 'c> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'c>)) { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let cx = jsg::Rc::new(OneOfContext).to_js(lock)?; + ctx.set_global(lock, "cx", cx); + let boxed = jsg::Rc::new(OneOfBox { value: 321.0 }).to_js(lock)?; + ctx.set_global(lock, "box321", boxed); + f(lock, ctx); + Ok(()) + }); +} + +fn expect_eval(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str, expected: &str) { + let result: String = ctx.eval(lock, code).unwrap(); + assert_eq!(result, expected, "{code}"); } +fn expect_type_error(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let err = ctx + .eval::(lock, code) + .unwrap_err() + .unwrap_jsg_err(lock); + assert_eq!(err.name, ExceptionType::TypeError, "{code}"); + err.message +} + +// ============================================================================= +// value-test.c++ "OneOf" +// ============================================================================= + #[test] -fn jsg_oneof_derives_debug_and_clone() { - let s = StringOrNumber::String("test".to_owned()); - let cloned = s.clone(); - assert_eq!(s, cloned); +fn takes_one_of_number_string_or_resource() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeOneOf(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeOneOf('foo')", "string:foo"); + expect_eval(lock, ctx, "cx.takeOneOf(box321)", "box:321"); + expect_eval(lock, ctx, "cx.takeOneOf(undefined)", "string:undefined"); + }); +} - let debug_str = format!("{s:?}"); - assert!(debug_str.contains("String")); - assert!(debug_str.contains("test")); +#[test] +fn returns_one_of() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "typeof cx.returnOneOf('number')", "number"); + expect_eval(lock, ctx, "String(cx.returnOneOf('number'))", "123"); + expect_eval(lock, ctx, "typeof cx.returnOneOf('string')", "string"); + expect_eval(lock, ctx, "cx.returnOneOf('string')", "foo"); + expect_eval( + lock, + ctx, + "String(Object.getPrototypeOf(cx.returnOneOf('box')) === \ + Object.getPrototypeOf(box321))", + "true", + ); + expect_eval(lock, ctx, "String(cx.returnOneOf('box').value)", "321"); + }); } #[test] -fn jsg_oneof_string_or_number_accepts_string() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn string_or_bool_coerces_to_string() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrBool(123)", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrBool('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrBool(true)", "bool:true"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrNumber('hello')").unwrap(); - assert_eq!(result, "string:hello"); - Ok(()) +#[test] +fn number_or_bool_coerces_to_number() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNumberOrBool(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeNumberOrBool('123')", "number:123"); + expect_eval(lock, ctx, "cx.takeNumberOrBool(true)", "bool:true"); }); } #[test] -fn jsg_oneof_string_or_number_accepts_number() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn string_or_number_coerces_boolean_to_string() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrNumber(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeStringOrNumber('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeStringOrNumber(true)", "string:true"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrNumber(42)").unwrap(); - assert_eq!(result, "number:42"); - Ok(()) +#[test] +fn nested_one_of_is_flattened() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNestedOneOf(123)", "number:123"); + expect_eval(lock, ctx, "cx.takeNestedOneOf('123')", "string:123"); + expect_eval(lock, ctx, "cx.takeNestedOneOf(true)", "bool:true"); + expect_eval( + lock, + ctx, + "cx.takeNestedOneOf(undefined)", + "string:undefined", + ); + expect_eval(lock, ctx, "cx.takeNestedOneOf(null)", "string:null"); + expect_eval( + lock, + ctx, + "cx.takeNestedOneOf({})", + "string:[object Object]", + ); }); } +// ============================================================================= +// Unions in other value-test.c++ tests +// ============================================================================= + +// "NonCoercible Types". #[test] -fn jsg_oneof_string_or_number_rejects_boolean() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn non_coercible_variants_take_only_their_own_type() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStrictBoolOrString(false)", "bool:false"); + expect_eval(lock, ctx, "cx.takeStrictBoolOrString('')", "string:"); + assert_eq!( + expect_type_error(lock, ctx, "cx.takeStrictBoolOrString(new String(''))"), + "Failed to execute 'takeStrictBoolOrString' on 'OneOfContext': parameter 1 is not \ + of type 'boolean or string'." + ); + }); +} - let err = ctx - .eval::(lock, "resource.stringOrNumber(true)") - .unwrap_err() - .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); - assert!(err.message.contains("string")); - assert!(err.message.contains("number")); - Ok(()) +// "Sequence of types": a string is iterable, but goes to the string variant. +#[test] +fn string_is_not_taken_as_a_sequence() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrStrings('aa')", "string:aa"); + expect_eval( + lock, + ctx, + "cx.takeStringOrStrings(['b', 'b'])", + r#"strings:["b", "b"]"#, + ); }); } +// "kj::Maybe noSubstituteNull", whose union C++ tests with `noSubstituteNull`, as +// Rust JSG always converts `Nullable`. #[test] -fn jsg_oneof_string_or_bool_accepts_both() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn nullable_union_rejects_a_value_no_variant_takes() { + with_context(|lock, ctx| { + assert_eq!( + expect_type_error(lock, ctx, "cx.takeMaybeStrictString({})"), + "Failed to execute 'takeMaybeStrictString' on 'OneOfContext': parameter 1 is not \ + of type 'string'." + ); + expect_eval(lock, ctx, "cx.takeMaybeStrictString('a')", "string:a"); + expect_eval(lock, ctx, "cx.takeMaybeStrictString(null)", "null"); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrBool('test')").unwrap(); - assert_eq!(result, "string:test"); +// ============================================================================= +// Pass order +// ============================================================================= - let result: String = ctx.eval(lock, "resource.stringOrBool(true)").unwrap(); - assert_eq!(result, "bool:true"); - Ok(()) +#[test] +fn pass_order_beats_declaration_order() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrNumber('42')", "string:42"); + expect_eval(lock, ctx, "cx.takeStringOrNumber(42)", "number:42"); + expect_eval(lock, ctx, "cx.takeNumberOrString('42')", "string:42"); + expect_eval(lock, ctx, "cx.takeNumberOrString(42)", "number:42"); }); } #[test] -fn jsg_oneof_three_types_accepts_all() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn resource_is_tried_before_dictionary() { + with_context(|lock, ctx| { + // A dictionary would take any object, the resource included. + expect_eval(lock, ctx, "cx.takePointOrBox(box321)", "box:321"); + expect_eval(lock, ctx, "cx.takePointOrBox({ x: 1 })", "point:Some(1)"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes('hello')").unwrap(); - assert_eq!(result, "string:hello"); +#[test] +fn sequence_is_tried_before_string_coercion() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringOrBytes([1, 2])", "bytes:[1, 2]"); + expect_eval(lock, ctx, "cx.takeStringOrBytes(5)", "string:5"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes(123.5)").unwrap(); - assert_eq!(result, "number:123.5"); +#[test] +fn dictionary_is_tried_before_string_coercion() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint({ x: 1 })", + "point:Some(1)", + ); + // Like a C++ dictionary, null and undefined are an empty dictionary. + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint(null)", + "point:None", + ); + expect_eval( + lock, + ctx, + "cx.takeStringOrOptionalPoint(undefined)", + "point:None", + ); + expect_eval(lock, ctx, "cx.takeStringOrOptionalPoint('a')", "string:a"); + }); +} - let result: String = ctx.eval(lock, "resource.threeTypes(false)").unwrap(); - assert_eq!(result, "bool:false"); - Ok(()) +#[test] +fn non_coercible_string_leaves_coercion_to_number() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber('a')", "string:a"); + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeStrictStringOrNumber(true)", "number:1"); }); } #[test] -fn jsg_oneof_three_types_rejects_null_and_undefined() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn big_int_goes_to_a_numeric_variant_first() { + with_context(|lock, ctx| { + // A number can't hold a BigInt, so the conversion throws rather than + // falling through to the string variant. + let message = expect_type_error(lock, ctx, "cx.takeStringOrNumber(1n)"); + assert!(message.contains("BigInt"), "{message}"); + // Without a numeric variant, a BigInt becomes a string. + expect_eval(lock, ctx, "cx.takeStringOrBool(1n)", "string:1"); + }); +} - let err = ctx - .eval::(lock, "resource.threeTypes(null)") - .unwrap_err() - .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); +#[test] +fn conversion_errors_are_kept() { + with_context(|lock, ctx| { + let message = expect_type_error(lock, ctx, "cx.takeStringOrNumber(Symbol())"); + assert!(message.contains("Symbol"), "{message}"); + }); +} +#[test] +fn exception_from_coercion_stops_the_conversion() { + with_context(|lock, ctx| { + // The string pass throws, so the number pass never calls valueOf(). let err = ctx - .eval::(lock, "resource.threeTypes(undefined)") + .eval::( + lock, + "globalThis.valueOfCalled = false; cx.takeStringOrNumber({ \ + toString() { throw new RangeError('from toString'); }, \ + valueOf() { globalThis.valueOfCalled = true; return 1; } })", + ) .unwrap_err() .unwrap_jsg_err(lock); - assert_eq!(err.name, ExceptionType::TypeError); - Ok(()) + assert_eq!(err.name, ExceptionType::RangeError); + assert_eq!(err.message, "from toString"); + expect_eval(lock, ctx, "String(globalThis.valueOfCalled)", "false"); }); } #[test] -fn jsg_oneof_variant_order_matches_declaration() { - let harness = crate::Harness::new(); - harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); +fn value_no_variant_takes_is_a_type_error() { + with_context(|lock, ctx| { + assert_eq!( + expect_type_error(lock, ctx, "cx.takePointOrBytes(5)"), + "Failed to execute 'takePointOrBytes' on 'OneOfContext': parameter 1 is not of \ + type 'Point or Array'." + ); + }); +} - // StringOrNumber has String first, Number second - let result: String = ctx.eval(lock, "resource.stringOrNumber('42')").unwrap(); - assert_eq!(result, "string:42"); +#[test] +fn nested_value_no_variant_takes_names_the_union() { + with_context(|lock, ctx| { + let message = expect_type_error(lock, ctx, "cx.takePointsOrBytes([5])"); + assert!( + message.contains("Expected Point or Array but got number"), + "{message}" + ); + }); +} - let result: String = ctx.eval(lock, "resource.stringOrNumber(42)").unwrap(); - assert_eq!(result, "number:42"); +// ============================================================================= +// Rust-side behavior +// ============================================================================= - // NumberOrString has Number first, String second - let result: String = ctx.eval(lock, "resource.numberOrString(42)").unwrap(); - assert_eq!(result, "number:42"); +static TRACED_BOX_DROPS: AtomicUsize = AtomicUsize::new(0); - let result: String = ctx.eval(lock, "resource.numberOrString('42')").unwrap(); - assert_eq!(result, "string:42"); - Ok(()) - }); +#[jsg_resource] +struct TracedBox; + +impl Drop for TracedBox { + fn drop(&mut self) { + TRACED_BOX_DROPS.fetch_add(1, Ordering::SeqCst); + } } +#[jsg_resource] +impl TracedBox {} + +#[jsg_oneof] +#[derive(Clone)] +enum TracedBoxOrNumber { + Box(jsg::Rc), + Number(f64), +} + +#[jsg_resource] +struct HoldsUnion { + pub held: TracedBoxOrNumber, +} + +#[jsg_resource] +impl HoldsUnion {} + +// The union traces its resource variant, so a resource holding the union keeps the +// variant's resource alive, and both are collected together. #[test] -fn jsg_oneof_reference_parameter() { +fn union_field_traces_its_resource_variant() { + TRACED_BOX_DROPS.store(0, Ordering::SeqCst); let harness = crate::Harness::new(); harness.run_in_context(|lock, ctx| { - let resource = jsg::Rc::new(EnumTestResource); - let wrapped = resource.to_js(lock)?; - ctx.set_global(lock, "resource", wrapped); - - let result: String = ctx - .eval(lock, "resource.stringOrNumberRef('hello')") - .unwrap(); - assert_eq!(result, "ref_string:hello"); + let held = jsg::Rc::new(TracedBox); + let parent = jsg::Rc::new(HoldsUnion { + held: TracedBoxOrNumber::Box(held.clone()), + }); + // Give the inner box a wrapper too, so that only tracing keeps it alive. + let _wrapper = held.clone().to_js(lock)?; + let wrapped = parent.clone().to_js(lock)?; + ctx.set_global(lock, "parent", wrapped); + std::mem::drop(held); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(TRACED_BOX_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); - let result: String = ctx.eval(lock, "resource.stringOrNumberRef(42)").unwrap(); - assert_eq!(result, "ref_number:42"); + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(TRACED_BOX_DROPS.load(Ordering::SeqCst), 1); Ok(()) }); } + +#[test] +fn union_holds_gc_references_if_a_variant_does() { + use jsg::Traced; + assert!(TracedBoxOrNumber::holds_gc_references()); + assert!(!StringOrNumber::holds_gc_references()); +} + +#[test] +fn union_without_to_js_converts_from_js() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeFromJsOnlyOrNumber('a')", "text:a"); + expect_eval(lock, ctx, "cx.takeFromJsOnlyOrNumber(5)", "number:5"); + }); +} + +#[test] +fn reference_parameter() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeStringOrNumberRef('hello')", + "ref_string:hello", + ); + expect_eval(lock, ctx, "cx.takeStringOrNumberRef(42)", "ref_number:42"); + }); +} + +#[test] +fn derives_debug_and_clone() { + let s = StringOrNumber::String("test".to_owned()); + let cloned = s.clone(); + assert_eq!(s, cloned); + + let debug_str = format!("{s:?}"); + assert!(debug_str.contains("String")); + assert!(debug_str.contains("test")); +} diff --git a/src/rust/jsg-test/tests/jsg_oneof_variants.rs b/src/rust/jsg-test/tests/jsg_oneof_variants.rs new file mode 100644 index 00000000000..e74c214910d --- /dev/null +++ b/src/rust/jsg-test/tests/jsg_oneof_variants.rs @@ -0,0 +1,627 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! `#[jsg_oneof]` variants of the types that a `kj::OneOf` can hold beyond strings, numbers, +//! booleans, resources, structs and sequences: integers, `i64`, `SystemTime`, `Nullable`, +//! `Dict` and V8 handles. Each converts in the pass that C++ JSG's `OneOfWrapper` gives its C++ +//! counterpart (`int`, `int64_t`, `kj::Date`, `kj::Maybe`, `jsg::Dict` and `v8::Local`). +//! What a union may not contain is checked at compile time, by the snippets in +//! `src/rust/jsg-compile-tests/oneof-*.rs`. + +use std::time::Duration; +use std::time::SystemTime; + +use jsg::Dict; +use jsg::ExceptionType; +use jsg::FromJS; +use jsg::Nullable; +use jsg::ToJS; +use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_struct; + +#[jsg_struct] +#[derive(Debug, Clone, PartialEq)] +struct OptionalPoint { + pub x: Option, +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum U32OrString { + U32(u32), + String(String), +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum I64OrString { + I64(i64), + String(String), +} + +/// `kj::OneOf`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum DateOrNumber { + Date(SystemTime), + Number(f64), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NullableStringOrNumber { + String(Nullable), + Number(f64), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum NullableU32OrString { + U32(Nullable), + String(String), +} + +/// `kj::OneOf, kj::Array, kj::String>`. +#[jsg_oneof] +#[derive(Debug, Clone, PartialEq)] +enum DictSequenceOrString { + Dict(Dict), + Sequence(Vec), + String(String), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +enum StringHandleOrNumber<'s> { + String(v8::Local<'s, v8::String>), + Number(f64), +} + +/// `kj::OneOf, kj::String>`, with the lifetime named otherwise. +#[jsg_oneof] +enum ObjectOrString<'a> { + Object(v8::Local<'a, v8::Object>), + String(String), +} + +/// `kj::OneOf, double>`. +#[jsg_oneof] +enum FunctionOrNumber<'s> { + Function(v8::Local<'s, v8::Function>), + Number(f64), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum BigIntOrString<'s> { + BigInt(v8::Local<'s, v8::BigInt>), + String(String), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum Uint8ArrayOrString<'s> { + Bytes(v8::Local<'s, v8::Uint8Array>), + String(String), +} + +/// `kj::OneOf, kj::String>`. +#[jsg_oneof] +enum Uint8ClampedArrayOrString<'s> { + Clamped(v8::Local<'s, v8::Uint8ClampedArray>), + String(String), +} + +/// A dictionary and a value of any type, in both orders: both are tried in the same pass, in +/// declaration order. +#[jsg_oneof] +enum PointOrValue<'s> { + Point(OptionalPoint), + Value(v8::Local<'s, v8::Value>), +} + +#[jsg_oneof] +enum ValueOrPoint<'s> { + Value(v8::Local<'s, v8::Value>), + Point(OptionalPoint), +} + +/// A union with a handle, nested in another; the inner union's variants are tried in the outer +/// union's passes. +#[jsg_oneof] +enum NumberOrNestedHandle<'s> { + Number(f64), + Nested(StringHandleOrBool<'s>), +} + +#[jsg_oneof] +enum StringHandleOrBool<'s> { + String(v8::Local<'s, v8::String>), + Bool(bool), +} + +#[jsg_resource] +struct VariantContext; + +#[jsg_resource] +impl VariantContext { + #[jsg_method] + pub fn take_u32_or_string(&self, value: U32OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_i64_or_string(&self, value: I64OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_date_or_number(&self, value: DateOrNumber) -> String { + match value { + DateOrNumber::Date(date) => { + let millis = date + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("date:{millis}") + } + DateOrNumber::Number(n) => format!("number:{n}"), + } + } + + #[jsg_method] + pub fn return_date_or_number(&self, date: bool) -> DateOrNumber { + if date { + DateOrNumber::Date(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + } else { + DateOrNumber::Number(1.0) + } + } + + #[jsg_method] + pub fn take_nullable_string_or_number(&self, value: NullableStringOrNumber) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_nullable_u32_or_string(&self, value: NullableU32OrString) -> String { + format!("{value:?}") + } + + #[jsg_method] + pub fn take_dict_sequence_or_string(&self, value: DictSequenceOrString) -> String { + match value { + DictSequenceOrString::Dict(dict) => { + let entries: Vec<_> = dict + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + format!("dict:{}", entries.join(",")) + } + DictSequenceOrString::Sequence(items) => format!("sequence:{}", items.join(",")), + DictSequenceOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn return_dict_or_sequence(&self, dict: bool) -> DictSequenceOrString { + if dict { + DictSequenceOrString::Dict(Dict::from(vec![("a".to_owned(), 1.0)])) + } else { + DictSequenceOrString::Sequence(vec!["x".to_owned()]) + } + } + + #[jsg_method] + pub fn take_string_handle_or_number<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: StringHandleOrNumber<'s>, + ) -> Result { + Ok(match value { + StringHandleOrNumber::String(s) => { + format!("handle:{}", String::from_js(lock, s.into())?) + } + StringHandleOrNumber::Number(n) => format!("number:{n}"), + }) + } + + #[jsg_method] + pub fn take_object_or_string<'s>(&self, value: ObjectOrString<'s>) -> String { + match value { + ObjectOrString::Object(_) => "object".to_owned(), + ObjectOrString::String(s) => format!("string:{s}"), + } + } + + /// Returns the object it is given, as the same union. + #[jsg_method] + pub fn echo_object_or_string<'s>(&self, value: ObjectOrString<'s>) -> ObjectOrString<'s> { + value + } + + #[jsg_method] + pub fn take_function_or_number<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: FunctionOrNumber<'s>, + ) -> Result { + Ok(match value { + FunctionOrNumber::Function(f) => { + let result = f.call::(lock, None, &[])?; + format!("function:{result}") + } + FunctionOrNumber::Number(n) => format!("number:{n}"), + }) + } + + #[jsg_method] + pub fn take_big_int_or_string<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: BigIntOrString<'s>, + ) -> Result { + Ok(match value { + BigIntOrString::BigInt(b) => format!("bigint:{}", i64::from_js(lock, b.into())?), + BigIntOrString::String(s) => format!("string:{s}"), + }) + } + + #[jsg_method] + pub fn take_uint8_array_or_string<'s>(&self, value: Uint8ArrayOrString<'s>) -> String { + match value { + Uint8ArrayOrString::Bytes(bytes) => format!("bytes:{}", bytes.len()), + Uint8ArrayOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn take_uint8_clamped_array_or_string<'s>( + &self, + value: Uint8ClampedArrayOrString<'s>, + ) -> String { + match value { + Uint8ClampedArrayOrString::Clamped(bytes) => format!("clamped:{}", bytes.len()), + Uint8ClampedArrayOrString::String(s) => format!("string:{s}"), + } + } + + #[jsg_method] + pub fn take_point_or_value<'s>(&self, value: PointOrValue<'s>) -> String { + match value { + PointOrValue::Point(p) => format!("point:{:?}", p.x), + PointOrValue::Value(_) => "value".to_owned(), + } + } + + #[jsg_method] + pub fn take_value_or_point<'s>(&self, value: ValueOrPoint<'s>) -> String { + match value { + ValueOrPoint::Value(_) => "value".to_owned(), + ValueOrPoint::Point(p) => format!("point:{:?}", p.x), + } + } + + #[jsg_method] + pub fn take_number_or_nested_handle<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: NumberOrNestedHandle<'s>, + ) -> Result { + Ok(match value { + NumberOrNestedHandle::Number(n) => format!("number:{n}"), + NumberOrNestedHandle::Nested(nested) => describe_string_handle_or_bool(lock, &nested)?, + }) + } + + #[jsg_method] + pub fn take_string_handle_or_bool<'s>( + &self, + lock: &mut jsg::Lock<'s>, + value: StringHandleOrBool<'s>, + ) -> Result { + describe_string_handle_or_bool(lock, &value) + } +} + +fn describe_string_handle_or_bool<'s>( + lock: &mut jsg::Lock<'s>, + value: &StringHandleOrBool<'s>, +) -> Result { + Ok(match value { + StringHandleOrBool::String(s) => format!("handle:{}", String::from_js(lock, (*s).into())?), + StringHandleOrBool::Bool(b) => format!("bool:{b}"), + }) +} + +fn with_context(f: impl for<'s, 'c> FnOnce(&mut jsg::Lock<'s>, &crate::EvalContext<'c>)) { + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let cx = jsg::Rc::new(VariantContext).to_js(lock)?; + ctx.set_global(lock, "cx", cx); + f(lock, ctx); + Ok(()) + }); +} + +fn expect_eval(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str, expected: &str) { + let result: String = ctx.eval(lock, code).unwrap(); + assert_eq!(result, expected, "{code}"); +} + +fn expect_type_error(lock: &mut jsg::Lock<'_>, ctx: &crate::EvalContext<'_>, code: &str) -> String { + let err = ctx + .eval::(lock, code) + .unwrap_err() + .unwrap_jsg_err(lock); + assert_eq!(err.name, ExceptionType::TypeError, "{code}"); + err.message +} + +// ============================================================================= +// Integers and i64 +// ============================================================================= + +/// An integer is a numeric type: it takes a number in the exact-number pass, and anything else +/// only after the string pass. An error from converting a number ends the conversion, as +/// `JSG_REQUIRE` in C++ JSG's integer conversion does, rather than trying the string variant. +#[test] +fn integer_variant_is_numeric() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeU32OrString(5)", "U32(5)"); + expect_eval(lock, ctx, "cx.takeU32OrString('5')", "String(\"5\")"); + expect_eval(lock, ctx, "cx.takeU32OrString(true)", "String(\"true\")"); + let message = expect_type_error(lock, ctx, "cx.takeU32OrString(-1)"); + assert!(message.contains("negative"), "{message}"); + }); +} + +/// `i64`, like `int64_t`, takes a `BigInt` in the exact-number pass too. +#[test] +fn i64_variant_takes_big_int() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeI64OrString(5n)", "I64(5)"); + expect_eval(lock, ctx, "cx.takeI64OrString(5)", "I64(5)"); + expect_eval(lock, ctx, "cx.takeI64OrString('5')", "String(\"5\")"); + }); +} + +// ============================================================================= +// SystemTime +// ============================================================================= + +/// `kj::Date` is in none of Web IDL's categories, so it is tried with the types that aren't +/// strings, numbers or booleans, before any numeric variant, and there takes a number of +/// milliseconds as well as a `Date`. +#[test] +fn date_variant_is_tried_before_numbers() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeDateOrNumber(new Date(1000))", + "date:1000", + ); + expect_eval(lock, ctx, "cx.takeDateOrNumber(5)", "date:5"); + expect_eval(lock, ctx, "cx.takeDateOrNumber('5')", "number:5"); + expect_eval( + lock, + ctx, + "String(cx.returnDateOrNumber(true) instanceof Date && \ + cx.returnDateOrNumber(true).getTime())", + "1000", + ); + expect_eval(lock, ctx, "typeof cx.returnDateOrNumber(false)", "number"); + }); +} + +// ============================================================================= +// Nullable +// ============================================================================= + +/// A nullable variant takes `null` and `undefined`. Like `kj::Maybe`, it is not itself a +/// string, number or boolean type, so it is tried before them, and converts any other value as +/// its inner type would, coercion included. +#[test] +fn nullable_variant_is_tried_before_primitives() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(null)", + "String(Null)", + ); + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(undefined)", + "String(Null)", + ); + expect_eval( + lock, + ctx, + "cx.takeNullableStringOrNumber(5)", + "String(Some(\"5\"))", + ); + expect_eval(lock, ctx, "cx.takeNullableU32OrString(null)", "U32(Null)"); + expect_eval(lock, ctx, "cx.takeNullableU32OrString(5)", "U32(Some(5))"); + }); +} + +// ============================================================================= +// Dict +// ============================================================================= + +/// A record takes an object other than an array, which a sequence takes. +#[test] +fn dict_variant_takes_objects_but_not_arrays() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeDictSequenceOrString({ a: 1, b: 2 })", + "dict:a=1,b=2", + ); + expect_eval( + lock, + ctx, + "cx.takeDictSequenceOrString(['x', 'y'])", + "sequence:x,y", + ); + expect_eval(lock, ctx, "cx.takeDictSequenceOrString('z')", "string:z"); + expect_eval( + lock, + ctx, + "JSON.stringify(cx.returnDictOrSequence(true))", + "{\"a\":1}", + ); + expect_eval( + lock, + ctx, + "JSON.stringify(cx.returnDictOrSequence(false))", + "[\"x\"]", + ); + }); +} + +// ============================================================================= +// Handles +// ============================================================================= + +/// A string handle is a string type, but takes only a string, as C++ JSG's `v8::Local` +/// does, so a boolean is coerced by the numeric variant instead. +#[test] +fn string_handle_variant_takes_only_strings() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeStringHandleOrNumber(true)", "number:1"); + }); +} + +#[test] +fn object_handle_variant_takes_objects() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeObjectOrString({})", "object"); + expect_eval(lock, ctx, "cx.takeObjectOrString([])", "object"); + expect_eval(lock, ctx, "cx.takeObjectOrString(5)", "string:5"); + expect_eval( + lock, + ctx, + "const o = {}; String(cx.echoObjectOrString(o) === o)", + "true", + ); + expect_eval(lock, ctx, "cx.echoObjectOrString(5)", "5"); + }); +} + +#[test] +fn function_handle_variant_takes_functions() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeFunctionOrNumber(() => 'called')", + "function:called", + ); + expect_eval(lock, ctx, "cx.takeFunctionOrNumber('5')", "number:5"); + }); +} + +/// A `BigInt` handle is a numeric type, as C++ JSG's `v8::Local` is, but takes only a +/// `BigInt`, so a number becomes a string. +#[test] +fn big_int_handle_variant_takes_only_big_ints() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeBigIntOrString(5n)", "bigint:5"); + expect_eval(lock, ctx, "cx.takeBigIntOrString(5)", "string:5"); + }); +} + +#[test] +fn typed_array_handle_variant_takes_its_typed_array() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeUint8ArrayOrString(new Uint8Array(3))", + "bytes:3", + ); + expect_eval( + lock, + ctx, + "cx.takeUint8ArrayOrString(new Uint16Array(3))", + "string:0,0,0", + ); + }); +} + +/// A `Uint8ClampedArray` handle takes only a `Uint8ClampedArray`, not a `Uint8Array` with the same +/// elements, as C++ JSG's `v8::Local` does. +#[test] +fn uint8_clamped_array_handle_variant_takes_only_its_typed_array() { + with_context(|lock, ctx| { + expect_eval( + lock, + ctx, + "cx.takeUint8ClampedArrayOrString(new Uint8ClampedArray(3))", + "clamped:3", + ); + expect_eval( + lock, + ctx, + "cx.takeUint8ClampedArrayOrString(new Uint8Array(3))", + "string:0,0,0", + ); + }); +} + +/// A value handle takes anything, in the pass for types that aren't strings, numbers or +/// booleans, so within that pass declaration order decides. +#[test] +fn declaration_order_decides_within_a_pass() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takePointOrValue({ x: 1 })", "point:Some(1)"); + expect_eval(lock, ctx, "cx.takePointOrValue(null)", "point:None"); + expect_eval(lock, ctx, "cx.takePointOrValue(5)", "value"); + expect_eval(lock, ctx, "cx.takeValueOrPoint({ x: 1 })", "value"); + }); +} + +#[test] +fn nested_union_with_handles_is_flattened() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle(true)", "bool:true"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle(5)", "number:5"); + expect_eval(lock, ctx, "cx.takeNumberOrNestedHandle({})", "number:NaN"); + }); +} + +/// A string handle takes only a string, so with no numeric variant a value that is neither a +/// string nor a boolean reaches the last pass, where the boolean variant coerces it, as in +/// `kj::OneOf, bool>`. +#[test] +fn boolean_variant_coerces_in_the_last_pass() { + with_context(|lock, ctx| { + expect_eval(lock, ctx, "cx.takeStringHandleOrBool('a')", "handle:a"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(true)", "bool:true"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(0)", "bool:false"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool(1)", "bool:true"); + expect_eval(lock, ctx, "cx.takeStringHandleOrBool({})", "bool:true"); + expect_eval( + lock, + ctx, + "cx.takeStringHandleOrBool(undefined)", + "bool:false", + ); + }); +} diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index d1bbdbd0ca4..3dbe5799ac5 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -21,6 +21,7 @@ mod harness; mod integer; mod isolates; mod jsg_oneof; +mod jsg_oneof_variants; mod jsg_struct; mod local_cast; mod local_handle; diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index fbff207386f..acbacca6bbc 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -410,6 +410,13 @@ let name: v8::Local = v8::Local::::try_from(str_value).unw assert!(v8::Local::::try_from(str_value).is_err()); ``` +A method parameter can be a handle of the types that C++ JSG's `v8::Local` parameters +take: `Local` takes any value, and `Local`, `Local`, +`Local`, `Local`, `Local`, `Local`, +`Local` and the typed arrays take a value of exactly that type, with no +coercion, and throw a `TypeError` for any other. `Local` takes any value, as a +promise resolved with it. + ### `Global` A persistent handle that outlives `HandleScope`s, for handles that Rust code owns outside @@ -663,7 +670,70 @@ pub fn process(&self, value: StringOrNumber) -> Result { } ``` -This is similar to `kj::OneOf<>` in C++ JSG. +A union converts a value as the equivalent `kj::OneOf<>` does in C++ JSG, following +Web IDL's union conversion. It tries its variants in passes, in declaration order +within each pass, and takes the first that converts: + +1. resource types (`jsg::Rc`); +2. every type that is not a string, numeric or boolean type, such as resources, arrays, + typed arrays and structs; +3. boolean types, if the value is a boolean; +4. numeric types, if the value is a number or a `BigInt`; +5. string types; +6. numeric types; +7. boolean types. + +So a value is coerced only when no variant takes it as it is, and coerced to a string +in preference to a number or boolean: `StringOrNumber` takes `true` as `"true"`, and +`null` as `"null"`. An error from a variant's conversion, such as a `Symbol` that +cannot become a string, or an exception thrown by `toString()`, is thrown rather than +trying another variant. A `NonCoercible` variant, where `T` is `String`, `bool`, +`f64` or an integer type, takes part in `T`'s passes but converts only a value of +exactly that type. A variant that is itself a union takes part +in each pass with its own variants, as a nested `kj::OneOf` does. + +Variants can be any type that converts from JavaScript and that a `kj::OneOf` can hold: +strings, numbers, the integer types, booleans, `SystemTime`, resources, structs, +`Vec`, `Dict`, `Nullable`, `NonCoercible`, other unions, and +handles (`v8::Local<'s, T>`). Each is tried in the passes of its C++ counterpart, so a few +behave as they do in C++ rather than as their position suggests: + +- `SystemTime`, like `kj::Date`, is not a numeric type, so it is tried in pass 2, where it + also takes a number of milliseconds, before any numeric variant does. +- `Nullable`, like `kj::Maybe`, is tried in pass 2 whatever `T` is: it takes `null` + and `undefined`, and converts any other value as `T` would, coercion included. A value + that `T` does not take is not taken as `null`, as in C++ JSG with the + `dont_substitute_null_on_type_error` compatibility flag, which Rust JSG always follows. +- A handle takes only a value of its type, as C++ JSG's `v8::Local` does: + `v8::Local<'s, v8::String>` is a string type, but never coerces, and + `v8::Local<'s, v8::BigInt>` is a numeric type that takes only a `BigInt`. + `v8::Local<'s, v8::Value>` takes any value, in pass 2. + +A union that holds handles takes their scope as its one lifetime parameter, and can't +outlive it: + +```rust +#[jsg_oneof] +enum ObjectOrString<'s> { + Object(jsg::v8::Local<'s, jsg::v8::Object>), + String(String), +} +``` + +Like C++ JSG's `webidl::UnionTypeValidator` (`src/workerd/jsg/web-idl.h`), `#[jsg_oneof]` +rejects at compile time a union that breaks Web IDL's rules for unions, counting the +variants of nested unions and the inner types of nullable types: at most one boolean, +numeric, string, dictionary-like (struct or `Dict`) and sequence-like type; at most one +nullable or struct type in all; an object handle (`v8::Local<'s, v8::Object>`) only +without resources, dictionary-like or sequence-like types; no `Option` or +`Lenient`, which mark optional parameters and fields; and no type twice, counting a +`Nullable` as `T` and the variants of a nested union as the union's own. A union may +have several resource types, as Web IDL allows, but not the same one in both a union and +a union nested in it. + +The union traces the variant it holds, so a union with a `jsg::Rc` variant can be a +resource field; one that holds handles can't. A union implements `jsg::ToJS` when every +variant does. ## Feature Flags (Compatibility Flags) diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 7432af5cec6..aa7ac1ceadb 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -17,6 +17,7 @@ pub mod lenient; pub mod macros; pub mod modules; pub mod nullable; +mod oneof; mod promise; pub mod resource; mod scope; @@ -31,6 +32,18 @@ pub use feature_flags::FeatureFlags; use isolate_state::IsolateState; pub use lenient::Lenient; pub use nullable::Nullable; +#[doc(hidden)] +pub use oneof::UnionMember; +#[doc(hidden)] +pub use oneof::UnionTypes; +#[doc(hidden)] +pub use oneof::UnionVariant; +#[doc(hidden)] +pub use oneof::concat_union_names; +#[doc(hidden)] +pub use oneof::try_union_from_js; +#[doc(hidden)] +pub use oneof::validate_union_names; pub use resource::NestedType; pub use resource::Rc; pub use resource::Resource; @@ -474,8 +487,8 @@ impl Error { /// /// # Supported Types /// -/// Any type implementing the [`Type`] trait can be used with `NonCoercible`. Built-in -/// implementations include: +/// `T` is one of the types a value is otherwise coerced to, those that implement +/// [`Coercible`]: /// /// - `NonCoercible` - only accepts JavaScript strings /// - `NonCoercible` - only accepts JavaScript booleans @@ -541,6 +554,33 @@ impl Deref for NonCoercible { } } +/// A type that [`NonCoercible`] can wrap: one that JavaScript values are coerced to. +/// +/// These are `String`, `bool`, `f64` and the integer types. C++ JSG's `CoercibleType` +/// (jsg/jsg.h) is the first three; Rust JSG adds the integers. +/// +/// For these, [`Type::is_exact`] is a precise test of the JavaScript type. Other types, such as +/// resources and unions, have no coercion of their own to disable, and their `is_exact` is only +/// a coarse test. The trait is sealed. +#[diagnostic::on_unimplemented( + message = "`NonCoercible<{Self}>` is not supported", + note = "`NonCoercible` takes only `String`, `bool`, `f64` or an integer type" +)] +pub trait Coercible: Type + coercible::Sealed {} + +mod coercible { + pub trait Sealed {} +} + +macro_rules! impl_coercible { + ($($t:ty),* $(,)?) => { + $(impl coercible::Sealed for $t {} + impl Coercible for $t {})* + }; +} + +impl_coercible!(String, bool, f64, u8, u16, u32, i8, i16, i32, i64, u64); + #[expect( unsafe_code, reason = "`Lock` holds the raw isolate pointer and calls V8 through the FFI" @@ -906,7 +946,28 @@ pub trait Type: Sized { /// Returns true if the V8 value is exactly this type (no coercion). /// Used by `NonCoercible` to reject values that would require coercion. + /// + /// It is precise only for the [`Coercible`] types that `NonCoercible` takes. For other + /// types it can only check the JavaScript type, without a lock: a resource's accepts any + /// object, and a union's any value that one of its variants' accepts. fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; + + /// The type's Web IDL categories, which a `#[jsg_oneof]` union with it as a variant checks + /// at compile time; see [`UnionTypes`]. + #[doc(hidden)] + const UNION_TYPES: UnionTypes = UnionTypes::OTHER; + + /// Names that identify the type in a `#[jsg_oneof]` union, flattened as + /// [`Self::UNION_TYPES`] is: a union's are those of its variants, and a `Nullable`'s are + /// `T`'s. A union may not contain a name twice, as C++ JSG's `webidl::FlattenedTypeTraits` + /// rejects a type that appears twice in a flattened `kj::OneOf`. + /// + /// Resources, handles and `SystemTime` have a name, since most of them are in no category, or + /// in one with no limit, so [`UnionTypes::validate`] can't tell that one appears twice. The + /// other types that `jsg` implements `Type` for need none: each is in a category that a union + /// may hold at most one type of. + #[doc(hidden)] + const UNION_NAMES: &'static [&'static str] = &[]; } /// Represents a constant value that can be exposed to JavaScript. diff --git a/src/rust/jsg/oneof-test.rs b/src/rust/jsg/oneof-test.rs new file mode 100644 index 00000000000..bb6966b9573 --- /dev/null +++ b/src/rust/jsg/oneof-test.rs @@ -0,0 +1,202 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use std::panic::AssertUnwindSafe; +use std::time::SystemTime; + +use super::*; +use crate::Dict; +use crate::FromJS; +use crate::NonCoercible; +use crate::Nullable; + +/// The message `check` panics with, or `None` if it returns. +fn panic_message(check: impl FnOnce()) -> Option { + let payload = std::panic::catch_unwind(AssertUnwindSafe(check)).err()?; + payload + .downcast_ref::<&str>() + .map(|message| (*message).to_owned()) + .or_else(|| payload.downcast_ref::().cloned()) +} + +/// The message `validate` panics with for `types`, or `None` if they are valid. +fn rejection(types: UnionTypes) -> Option { + panic_message(|| types.validate()) +} + +/// The types of a union of the types in `members`. +fn union(members: &[UnionTypes]) -> UnionTypes { + members + .iter() + .fold(UnionTypes::OTHER, |sum, &member| sum.plus(member)) +} + +#[test] +fn valid_unions_pass() { + for members in [ + vec![UnionTypes::STRING, UnionTypes::NUMERIC, UnionTypes::BOOLEAN], + vec![ + UnionTypes::INTERFACE_LIKE, + UnionTypes::INTERFACE_LIKE, + UnionTypes::DICTIONARY, + UnionTypes::SEQUENCE_LIKE, + UnionTypes::STRING, + ], + vec![UnionTypes::OBJECT, UnionTypes::STRING, UnionTypes::OTHER], + vec![UnionTypes::RECORD, UnionTypes::STRING.nullable()], + vec![UnionTypes::OTHER, UnionTypes::OTHER], + ] { + assert_eq!(rejection(union(&members)), None, "{members:?}"); + } +} + +/// Each rule of C++ JSG's `webidl::UnionTypeValidator`, broken by the smallest union. +#[test] +fn each_web_idl_rule_rejects_a_union() { + let cases = [ + ( + vec![UnionTypes::DICTIONARY, UnionTypes::NUMERIC.nullable()], + "at most one nullable or dictionary type", + ), + ( + vec![ + UnionTypes::STRING.nullable(), + UnionTypes::NUMERIC.nullable(), + ], + "at most one nullable or dictionary type", + ), + ( + vec![UnionTypes::BOOLEAN, UnionTypes::BOOLEAN], + "at most one boolean type", + ), + ( + vec![UnionTypes::NUMERIC, UnionTypes::NUMERIC], + "at most one numeric type", + ), + ( + vec![UnionTypes::STRING, UnionTypes::STRING], + "at most one string type", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::OBJECT], + "at most one object type", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::INTERFACE_LIKE], + "an object type only if", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::RECORD], + "an object type only if", + ), + ( + vec![UnionTypes::OBJECT, UnionTypes::SEQUENCE_LIKE], + "an object type only if", + ), + ( + vec![UnionTypes::DICTIONARY, UnionTypes::RECORD], + "at most one dictionary-like type", + ), + ( + vec![UnionTypes::SEQUENCE_LIKE, UnionTypes::SEQUENCE_LIKE], + "at most one sequence-like type", + ), + ( + vec![UnionTypes::STRING.optional()], + "may not contain Option or Lenient", + ), + ]; + for (members, rule) in cases { + let message = rejection(union(&members)).unwrap_or_default(); + assert!(message.contains(rule), "{members:?}: {message:?}"); + } +} + +/// Web IDL flattens a nullable type to its inner type, and counts the nullable type as well. +#[test] +fn nullable_counts_its_inner_type() { + let types = UnionTypes::STRING.nullable(); + assert_eq!(types.string, 1); + assert_eq!(types.nullable, 1); + assert!(rejection(union(&[types, UnionTypes::STRING])).is_some()); +} + +/// Asserts that `T`'s pass in the union conversion and its Web IDL categories are `member` and +/// `types`. The two are separate constants, `FromJS::UNION_MEMBER` and +/// `UnionVariant::UNION_TYPES`, so this table keeps them consistent with each other and with C++ +/// JSG's predicates (`IsResourceType`, `IsFallibleType` and the like in jsg/value.h, and the +/// categories in jsg/web-idl.h). +fn assert_kind>(member: UnionMember, types: UnionTypes) { + let name = std::any::type_name::(); + assert_eq!(T::UNION_MEMBER, member, "{name}"); + assert_eq!(::UNION_TYPES, types, "{name}"); +} + +#[test] +fn each_type_has_the_kind_of_its_cpp_counterpart() { + use UnionMember::Boolean; + use UnionMember::Fallible; + use UnionMember::Numeric; + + assert_kind::(Boolean, UnionTypes::BOOLEAN); + assert_kind::>(Boolean, UnionTypes::BOOLEAN); + assert_kind::(UnionMember::String, UnionTypes::STRING); + assert_kind::>(UnionMember::String, UnionTypes::STRING); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Numeric, UnionTypes::NUMERIC); + assert_kind::(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::SEQUENCE_LIKE); + assert_kind::>(Fallible, UnionTypes::SEQUENCE_LIKE); + assert_kind::>(Fallible, UnionTypes::RECORD); + assert_kind::>(Fallible, UnionTypes::STRING.nullable()); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(UnionMember::String, UnionTypes::STRING); + assert_kind::>(Numeric, UnionTypes::NUMERIC); + assert_kind::>(Fallible, UnionTypes::OBJECT); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); + assert_kind::>(Fallible, UnionTypes::OTHER); +} + +#[test] +fn union_names_concatenate_in_order() { + let names: [&str; 3] = concat_union_names(&[&["a"], &[], &["b", "c"]]); + assert_eq!(names, ["a", "b", "c"]); +} + +#[test] +fn a_name_twice_is_rejected() { + assert_eq!( + panic_message(|| validate_union_names(&["a", "b", "ab"])), + None + ); + let message = panic_message(|| validate_union_names(&["a", "b", "a"])).unwrap_or_default(); + assert!( + message.contains("may not contain the same type twice"), + "{message:?}" + ); +} + +/// Handles, nullable types and `SystemTime` have the names of the types they hold or are; other +/// types are kept from appearing twice by their categories, and have none. +#[test] +fn names_flatten_as_categories_do() { + let names = as UnionVariant>::UNION_NAMES; + assert_eq!(names, ["v8::Uint8ClampedArray"]); + let names = > as UnionVariant>::UNION_NAMES; + assert_eq!(names, ["v8::Value"]); + assert_eq!(::UNION_NAMES, ["SystemTime"]); + assert!(::UNION_NAMES.is_empty()); + assert!(> as UnionVariant>::UNION_NAMES.is_empty()); +} diff --git a/src/rust/jsg/oneof.rs b/src/rust/jsg/oneof.rs new file mode 100644 index 00000000000..ccbbae44a7c --- /dev/null +++ b/src/rust/jsg/oneof.rs @@ -0,0 +1,386 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! The support code of `#[jsg_oneof]` unions, the Rust counterpart of C++ JSG's `kj::OneOf`. +//! +//! A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` (jsg/value.h) does, in the +//! passes that [`UnionMember`] describes, and checks at compile time that its variants follow +//! Web IDL's rules for unions, as C++ JSG's `webidl::UnionTypeValidator` (jsg/web-idl.h) does; +//! see [`UnionTypes`]. Everything here is used only by the code the macro generates. + +use crate::Error; +use crate::Lenient; +use crate::Lock; +use crate::Type; +use crate::v8; + +/// The kind of a `#[jsg_oneof]` variant, which decides the passes in which the +/// variant is tried. +/// +/// A union converts a value as C++ JSG's `OneOfWrapper::tryUnwrap()` +/// (jsg/value.h) does, following Web IDL's union conversion: it makes a series +/// of passes over its variants and takes the first one that converts, trying +/// in order +/// +/// 1. resource types, +/// 2. every type that is not a string, numeric or boolean type, +/// 3. boolean types, if the value is a boolean, +/// 4. numeric types, if the value is a number or a `BigInt`, +/// 5. string types, +/// 6. numeric types, +/// 7. boolean types. +/// +/// Passes 5 to 7 coerce, so a value becomes a string only if no other variant +/// takes it, and becomes a number or boolean only if no string variant +/// converts it either. An error from a variant's conversion, such as a +/// `Symbol` that cannot become a string, ends the conversion with that error +/// rather than trying another variant. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnionMember { + /// A resource type (`jsg::Rc`). + Resource, + /// Any other type that is not a string, numeric or boolean type, such as + /// an array, a typed array or a struct (C++ JSG's `IsFallibleType`). + Fallible, + /// A string type. + String, + /// A numeric type. + Numeric, + /// A boolean type. + Boolean, +} + +impl UnionMember { + /// Whether a variant of this kind is tried in `pass`. + pub(crate) const fn takes_part_in(self, pass: Self) -> bool { + matches!( + (self, pass), + (Self::Resource, Self::Resource | Self::Fallible) + | (Self::Fallible, Self::Fallible) + | (Self::String, Self::String) + | (Self::Numeric, Self::Numeric) + | (Self::Boolean, Self::Boolean) + ) + } +} + +/// Converts `value` to a union by running `try_pass` for each pass of the +/// union conversion algorithm (see [`UnionMember`]) until one converts it. +#[doc(hidden)] +pub fn try_union_from_js<'s, R>( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + try_pass: impl Fn(&mut Lock<'s>, v8::Local<'s, v8::Value>, UnionMember) -> Result, Error>, +) -> Result, Error> { + let passes = [ + (true, UnionMember::Resource), + (true, UnionMember::Fallible), + (value.is_boolean(), UnionMember::Boolean), + ( + value.is_number() || value.is_big_int(), + UnionMember::Numeric, + ), + (true, UnionMember::String), + (true, UnionMember::Numeric), + (true, UnionMember::Boolean), + ]; + for (applies, pass) in passes { + if applies && let Some(result) = try_pass(lock, value, pass)? { + return Ok(Some(result)); + } + } + Ok(None) +} + +/// How many types of each Web IDL category a union, or a type that may be one of its variants, +/// contains, as C++ JSG's `webidl::FlattenedTypeTraits` (jsg/web-idl.h) counts them. +/// +/// Web IDL flattens unions: a variant that is itself a union, or a `Nullable`, counts with the +/// types it contains, as a nested `kj::OneOf` or a `kj::Maybe` does. [`UnionTypes::validate`] +/// then applies `webidl::UnionTypeValidator`'s rules to the sum. A type that falls in no category, +/// such as `jsg::v8::Local<'s, jsg::v8::Value>` or `SystemTime`, is [`UnionTypes::OTHER`]; C++ JSG +/// does not reject such types either. Rust has no symbol or callback function types in C++ JSG's +/// sense, so their rules never apply. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnionTypes { + /// Dictionary types: `#[jsg_struct]`s (C++ JSG's `JSG_STRUCT`s). + pub dictionary: u32, + /// Boolean types. + pub boolean: u32, + /// Numeric types. + pub numeric: u32, + /// String types. + pub string: u32, + /// The object type, `jsg::v8::Local<'s, jsg::v8::Object>`. + pub object: u32, + /// Interface-like types: resources. C++ JSG's buffer sources, such as `kj::Array`, + /// have no counterpart: `Vec` is a sequence, and a typed array handle is in no category, + /// as C++ JSG's `v8::Local` is. + pub interface_like: u32, + /// Dictionary-like types: dictionaries and records (`jsg::Dict`). + pub dictionary_like: u32, + /// Sequence-like types: `Vec` and `&[T]`. + pub sequence_like: u32, + /// Nullable types: `Nullable`. + pub nullable: u32, + /// Optional types, `Option` and `Lenient`, which may not be variants. + pub optional: u32, +} + +impl UnionTypes { + /// A type in no category. + pub const OTHER: Self = Self { + dictionary: 0, + boolean: 0, + numeric: 0, + string: 0, + object: 0, + interface_like: 0, + dictionary_like: 0, + sequence_like: 0, + nullable: 0, + optional: 0, + }; + /// A boolean type. + pub const BOOLEAN: Self = Self { + boolean: 1, + ..Self::OTHER + }; + /// A numeric type. + pub const NUMERIC: Self = Self { + numeric: 1, + ..Self::OTHER + }; + /// A string type. + pub const STRING: Self = Self { + string: 1, + ..Self::OTHER + }; + /// The object type. + pub const OBJECT: Self = Self { + object: 1, + ..Self::OTHER + }; + /// An interface-like type. + pub const INTERFACE_LIKE: Self = Self { + interface_like: 1, + ..Self::OTHER + }; + /// A dictionary type, which is also dictionary-like. + pub const DICTIONARY: Self = Self { + dictionary: 1, + dictionary_like: 1, + ..Self::OTHER + }; + /// A record type, which is dictionary-like. + pub const RECORD: Self = Self { + dictionary_like: 1, + ..Self::OTHER + }; + /// A sequence-like type. + pub const SEQUENCE_LIKE: Self = Self { + sequence_like: 1, + ..Self::OTHER + }; + + /// The types of a union of `self`'s and `other`'s types. + #[must_use] + pub const fn plus(self, other: Self) -> Self { + Self { + dictionary: self.dictionary + other.dictionary, + boolean: self.boolean + other.boolean, + numeric: self.numeric + other.numeric, + string: self.string + other.string, + object: self.object + other.object, + interface_like: self.interface_like + other.interface_like, + dictionary_like: self.dictionary_like + other.dictionary_like, + sequence_like: self.sequence_like + other.sequence_like, + nullable: self.nullable + other.nullable, + optional: self.optional + other.optional, + } + } + + /// The types of a nullable type whose inner type has `self`'s types. + #[must_use] + pub const fn nullable(self) -> Self { + Self { + nullable: self.nullable + 1, + ..self + } + } + + /// The types of an optional type whose inner type has `self`'s types. + #[must_use] + pub const fn optional(self) -> Self { + Self { + optional: self.optional + 1, + ..self + } + } + + /// Panics, at compile time when evaluated in a constant, unless a union with these types + /// follows Web IDL's rules for unions, as `webidl::UnionTypeValidator` checks them. + /// + /// # Panics + /// + /// Panics with the rule the union breaks. + pub const fn validate(self) { + assert!( + self.nullable + self.dictionary <= 1, + "A #[jsg_oneof] union may contain at most one nullable or dictionary type." + ); + assert!( + self.boolean <= 1, + "A #[jsg_oneof] union may contain at most one boolean type." + ); + assert!( + self.numeric <= 1, + "A #[jsg_oneof] union may contain at most one numeric type." + ); + assert!( + self.string <= 1, + "A #[jsg_oneof] union may contain at most one string type." + ); + assert!( + self.object <= 1, + "A #[jsg_oneof] union may contain at most one object type." + ); + assert!( + self.object == 0 + || self.interface_like + self.dictionary_like + self.sequence_like == 0, + "A #[jsg_oneof] union may contain an object type only if it also contains no \ + interface-like, dictionary-like or sequence-like types." + ); + assert!( + self.dictionary_like <= 1, + "A #[jsg_oneof] union may contain at most one dictionary-like type." + ); + assert!( + self.sequence_like <= 1, + "A #[jsg_oneof] union may contain at most one sequence-like type." + ); + assert!( + self.optional == 0, + "A #[jsg_oneof] union may not contain Option or Lenient, which mark optional \ + parameters and fields. Use Nullable for a nullable type." + ); + } +} + +/// The names of a union's types: `parts`, its variants' names, one after another. `N` is their +/// total number. +/// +/// # Panics +/// +/// Panics, at compile time when evaluated in a constant, if `N` is not that number. +#[doc(hidden)] +#[must_use] +pub const fn concat_union_names(parts: &[&[&'static str]]) -> [&'static str; N] { + let mut names = [""; N]; + let mut len = 0; + let mut part = 0; + while part < parts.len() { + let mut i = 0; + while i < parts[part].len() { + names[len] = parts[part][i]; + len += 1; + i += 1; + } + part += 1; + } + assert!(len == N, "`N` is not the number of names in `parts`"); + names +} + +/// Panics, at compile time when evaluated in a constant, if a name appears twice in `names`, +/// the names of a union's types, as `webidl::UnionTypeValidator` rejects a `kj::OneOf` whose +/// flattened types contain a type twice. +/// +/// # Panics +/// +/// Panics if `names` contains a name twice. +#[doc(hidden)] +pub const fn validate_union_names(names: &[&str]) { + let mut i = 0; + while i < names.len() { + let mut j = i + 1; + while j < names.len() { + assert!( + !str_eq(names[i], names[j]), + "A #[jsg_oneof] union may not contain the same type twice, counting the variants \ + of nested unions and the inner types of nullable types." + ); + j += 1; + } + i += 1; + } +} + +/// Whether `a` and `b` are equal, in a constant: `PartialEq` for `str` is not `const`. +const fn str_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +/// A type that can be a `#[jsg_oneof]` variant: every [`Type`], and the optional types, which +/// are not `Type`s and which a union rejects with an explanation rather than with a missing +/// impl. +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "`{Self}` cannot be a `#[jsg_oneof]` variant", + note = "a variant must implement `jsg::Type`" +)] +pub trait UnionVariant { + /// The variant's types, flattened; see [`UnionTypes`]. + const UNION_TYPES: UnionTypes; + + /// The names of the variant's types, flattened; see [`Type::UNION_NAMES`]. + const UNION_NAMES: &'static [&'static str]; + + /// Whether `value` is exactly of the variant's type; see [`Type::is_exact`]. + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool; +} + +impl UnionVariant for T { + const UNION_TYPES: UnionTypes = T::UNION_TYPES; + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + T::is_exact(value) + } +} + +impl UnionVariant for Option { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_undefined() || T::is_exact(value) + } +} + +impl UnionVariant for Lenient { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.optional(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_null_or_undefined() || T::is_exact(value) + } +} + +#[cfg(test)] +#[path = "oneof-test.rs"] +mod tests; diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index c8544015487..e147cb59d7d 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -26,6 +26,8 @@ use crate::Member; use crate::ToJS; use crate::Traced; use crate::Type; +use crate::UnionMember; +use crate::UnionTypes; use crate::feature_flags::compatibility_flags; use crate::isolate_state::IsolateState; use crate::v8; @@ -180,9 +182,29 @@ impl<'s, R: Resource + 'static> ToJS<'s> for Rc { } } +impl Type for Rc { + // A Web IDL non-callback interface type. + const UNION_TYPES: UnionTypes = UnionTypes::INTERFACE_LIKE; + // The resource's, which `#[jsg_resource]` makes its module path and name. + const UNION_NAMES: &'static [&'static str] = R::UNION_NAMES; + + fn class_name() -> &'static str { + R::class_name() + } + + // Any object: telling whether it wraps an `R` takes a lock, which `is_exact` doesn't have. + // `FromJS::try_from_js` makes that check. A resource is not `Coercible`, so no + // `NonCoercible>` relies on this. + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_object() + } +} + impl<'s, R: Resource + 'static> FromJS<'s> for Rc { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Resource; + fn type_name() -> String { R::class_name().to_owned() } diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index 0efa5fba13a..ea1c755300f 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -32,6 +32,8 @@ //! | `Result` | `T` or throws | //! | `NonCoercible` | `T` (strict type checking) | //! | `T: Struct` | `object` | +//! | `#[jsg_oneof]` enum | any of its variants' types (see `src/rust/jsg/README.md`) | +//! | `jsg::Rc` | the resource's JavaScript object | //! | `Vec` | `Array` | //! | `&[T]` | `Array` (parameter only) | //! | `Dict` | `Record`: an object other than an array | @@ -68,6 +70,7 @@ //! `uint64_t`: a `BigInt` that does not fit is a `TypeError`, and any other value converts as //! above. +use crate::Coercible; use crate::Dict; use crate::Error; use crate::Lenient; @@ -75,6 +78,8 @@ use crate::Lock; use crate::NonCoercible; use crate::Nullable; use crate::Type; +use crate::UnionMember; +use crate::UnionTypes; use crate::v8; // ============================================================================= @@ -393,8 +398,11 @@ pub trait ToJS<'s>: Sized { /// Trait for converting JavaScript values to Rust. /// /// `from_js()` is the normal, required conversion path. `try_from_js()` is an -/// internal shallow-probing path used by [`Lenient`]. Implementations only need -/// to override `try_from_js()` when their top-level type can fail to match. +/// internal shallow-probing path used by [`Lenient`] and by `#[jsg_oneof]` +/// unions. Implementations only need to override `try_from_js()` when their +/// top-level type can fail to match, and the override must return `Ok(None)` +/// rather than an error for a value of the wrong type, so that a union can go +/// on to its other variants. pub trait FromJS<'s>: Sized { type ResultType; @@ -432,24 +440,24 @@ pub trait FromJS<'s>: Sized { Self::from_js(lock, value).map(Some) } - /// Tries to convert only if the JavaScript type matches exactly. - /// Returns `None` if the type doesn't match, `Some(result)` if conversion was attempted. - /// Used by `#[jsg_oneof]` macro to try each variant without coercion. - fn try_from_js_exact( + /// The type's kind in the union conversion algorithm; see [`UnionMember`]. + #[doc(hidden)] + const UNION_MEMBER: UnionMember = UnionMember::Fallible; + + /// Converts `value` during `pass` of a union conversion, or returns + /// `Ok(None)` if this type does not take part in `pass` or does not match. + /// A union overrides this to try its own variants, which flattens nested + /// unions into the enclosing union's passes. + #[doc(hidden)] + fn try_from_js_in_union_pass( lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>, - ) -> Option> - where - Self: Type, - { - if !Self::is_exact(value) { - return None; - } - - match Self::try_from_js(lock, value) { - Ok(Some(result)) => Some(Ok(result)), - Ok(None) => None, - Err(err) => Some(Err(err)), + pass: UnionMember, + ) -> Result, Error> { + if Self::UNION_MEMBER.takes_part_in(pass) { + Self::try_from_js(lock, value) + } else { + Ok(None) } } } @@ -460,6 +468,8 @@ pub trait FromJS<'s>: Sized { // Boolean implementation for JavaScript booleans. impl Type for bool { + const UNION_TYPES: UnionTypes = UnionTypes::BOOLEAN; + fn class_name() -> &'static str { "boolean" } @@ -481,6 +491,8 @@ impl<'s> ToJS<'s> for bool { impl<'s> FromJS<'s> for bool { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Boolean; + fn type_name() -> String { "boolean".to_owned() } @@ -495,6 +507,8 @@ impl<'s> FromJS<'s> for bool { } impl Type for std::string::String { + const UNION_TYPES: UnionTypes = UnionTypes::STRING; + fn class_name() -> &'static str { "string" } @@ -523,6 +537,8 @@ impl<'s> ToJS<'s> for &str { impl<'s> FromJS<'s> for std::string::String { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::String; + fn type_name() -> String { "string".to_owned() } @@ -537,6 +553,8 @@ impl<'s> FromJS<'s> for std::string::String { // Like C++ JSG's double, an f64 is a number. From JavaScript, any value converts with `ToNumber`. impl Type for f64 { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "number" } @@ -556,6 +574,8 @@ impl<'s> ToJS<'s> for f64 { impl<'s> FromJS<'s> for f64 { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "number".to_owned() } @@ -580,6 +600,8 @@ impl<'s> ToJS<'s> for f32 { // by converting JavaScript strings to owned Strings, then borrowing. // The macro handles passing &arg instead of arg for reference types. impl Type for &str { + const UNION_TYPES: UnionTypes = UnionTypes::STRING; + fn class_name() -> &'static str { "string" } @@ -592,6 +614,8 @@ impl Type for &str { impl<'s> FromJS<'s> for &str { type ResultType = String; + const UNION_MEMBER: UnionMember = UnionMember::String; + fn type_name() -> String { "string".to_owned() } @@ -607,6 +631,8 @@ impl<'s> FromJS<'s> for &str { impl<'s, T: FromJS<'s, ResultType = T>> FromJS<'s> for &T { type ResultType = T; + const UNION_MEMBER: UnionMember = T::UNION_MEMBER; + fn type_name() -> String { T::type_name() } @@ -624,11 +650,21 @@ impl<'s, T: FromJS<'s, ResultType = T>> FromJS<'s> for &T { ) -> Result, Error> { T::try_from_js(lock, value) } + + fn try_from_js_in_union_pass( + lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + pass: UnionMember, + ) -> Result, Error> { + T::try_from_js_in_union_pass(lock, value, pass) + } } // Slice type - allows functions to accept &[T] parameters. // JavaScript arrays are converted to Vec, then borrowed as &[T] by the macro. impl Type for &[T] { + const UNION_TYPES: UnionTypes = UnionTypes::SEQUENCE_LIKE; + fn class_name() -> &'static str { "Array" } @@ -675,6 +711,8 @@ macro_rules! impl_integer { ($($type:ty => $name:literal, $new:ident, $non_finite:expr, $range:expr);* $(;)?) => { $( impl Type for $type { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "number" } @@ -713,6 +751,8 @@ macro_rules! impl_integer { impl<'s> FromJS<'s> for $type { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { $name.to_owned() } @@ -781,6 +821,8 @@ impl_integer!( ); impl Type for i64 { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "bigint" } @@ -803,6 +845,8 @@ impl<'s> ToJS<'s> for i64 { impl<'s> FromJS<'s> for i64 { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "bigint".to_owned() } @@ -833,6 +877,8 @@ impl<'s> FromJS<'s> for i64 { } impl Type for u64 { + const UNION_TYPES: UnionTypes = UnionTypes::NUMERIC; + fn class_name() -> &'static str { "bigint" } @@ -855,6 +901,8 @@ impl<'s> ToJS<'s> for u64 { impl<'s> FromJS<'s> for u64 { type ResultType = Self; + const UNION_MEMBER: UnionMember = UnionMember::Numeric; + fn type_name() -> String { "bigint".to_owned() } @@ -892,6 +940,20 @@ impl<'s> FromJS<'s> for u64 { } } +// Like C++ JSG's `kj::Date`, which is in none of Web IDL's categories, so a union tries it in +// the pass for types that aren't strings, numbers or booleans, where it also takes a number. +impl Type for std::time::SystemTime { + const UNION_NAMES: &'static [&'static str] = &["SystemTime"]; + + fn class_name() -> &'static str { + "Date" + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_date() + } +} + // Like C++ JSG's kj::Date (jsg/value.h), a Date or a number of milliseconds since the epoch, which // must be finite and within the range of `kj::Date`, `i64` nanoseconds either side of the epoch. // Fractions of a millisecond are dropped. @@ -990,7 +1052,7 @@ impl<'s, T: ToJS<'s>> ToJS<'s> for Option { } } -impl<'s, T: Type + ToJS<'s>> ToJS<'s> for NonCoercible { +impl<'s, T: Coercible + ToJS<'s>> ToJS<'s> for NonCoercible { fn to_js(self, lock: &mut Lock<'s>) -> Result, Error> { self.into_inner().to_js(lock) } @@ -1048,9 +1110,25 @@ impl<'s, T: FromJS<'s>> FromJS<'s> for Option { } } -impl<'s, T: Type + FromJS<'s>> FromJS<'s> for NonCoercible { +impl Type for NonCoercible { + const UNION_TYPES: UnionTypes = T::UNION_TYPES; + + fn class_name() -> &'static str { + T::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + T::is_exact(value) + } +} + +// Like C++ `NonCoercible` (jsg/web-idl.h), this is the same kind of union +// member as `T`, but converts only a value of exactly that type. +impl<'s, T: Coercible + FromJS<'s>> FromJS<'s> for NonCoercible { type ResultType = NonCoercible; + const UNION_MEMBER: UnionMember = T::UNION_MEMBER; + fn type_name() -> String { T::type_name() } @@ -1081,6 +1159,20 @@ impl<'s, T: Type + FromJS<'s>> FromJS<'s> for NonCoercible { } } +// Like C++ JSG's `kj::Maybe`: a union with it as a variant counts it as nullable, and as `T`. +impl Type for Nullable { + const UNION_TYPES: UnionTypes = T::UNION_TYPES.nullable(); + const UNION_NAMES: &'static [&'static str] = T::UNION_NAMES; + + fn class_name() -> &'static str { + T::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.is_null_or_undefined() || T::is_exact(value) + } +} + // Like C++ `kj::Maybe`, Web IDL nullable conversion reads `undefined` as `null`. // Nullable arguments remain required; only an outer `Option` permits omission. impl<'s, T: FromJS<'s>> FromJS<'s> for Nullable { @@ -1146,6 +1238,8 @@ impl<'s, T: FromJS<'s>> FromJS<'s> for Lenient { // ============================================================================= impl Type for Vec { + const UNION_TYPES: UnionTypes = UnionTypes::SEQUENCE_LIKE; + fn class_name() -> &'static str { "Array" } @@ -1208,6 +1302,8 @@ impl<'s, T: Type + FromJS<'s, ResultType = T>> FromJS<'s> for Vec { // Like C++ JSG's DictWrapper (jsg/value.h): any object other than an array. impl Type for Dict { + const UNION_TYPES: UnionTypes = UnionTypes::RECORD; + fn class_name() -> &'static str { "object" } @@ -1301,6 +1397,8 @@ macro_rules! impl_typed_array_type { ($($marker:ident => $is_check:ident),* $(,)?) => { $( impl Type for v8::$marker { + const UNION_NAMES: &'static [&'static str] = &[concat!("v8::", stringify!($marker))]; + fn class_name() -> &'static str { stringify!($marker) } @@ -1314,6 +1412,7 @@ macro_rules! impl_typed_array_type { impl_typed_array_type!( Uint8Array => is_uint8_array, + Uint8ClampedArray => is_uint8clamped_array, Uint16Array => is_uint16_array, Uint32Array => is_uint32_array, Int8Array => is_int8_array, @@ -1329,10 +1428,12 @@ impl_typed_array_type!( // ArrayBuffer, ArrayBufferView, SharedArrayBuffer // ============================================================================= // -// These types only implement `Type` (used by `NonCoercible` for type checking); their +// These markers implement `Type` so that their handles can be `#[jsg_oneof]` variants; their // conversions are those of their handles, in `v8.rs`. impl Type for v8::ArrayBuffer { + const UNION_NAMES: &'static [&'static str] = &["v8::ArrayBuffer"]; + fn class_name() -> &'static str { "ArrayBuffer" } @@ -1342,6 +1443,8 @@ impl Type for v8::ArrayBuffer { } impl Type for v8::ArrayBufferView { + const UNION_NAMES: &'static [&'static str] = &["v8::ArrayBufferView"]; + fn class_name() -> &'static str { "ArrayBufferView" } @@ -1376,6 +1479,103 @@ impl<'s> FromJS<'s> for v8::Local<'s, v8::Value> { } } +// A handle is the type its marker is, as C++ JSG's `v8::Local` is. +impl Type for v8::Local<'_, M> { + const UNION_TYPES: UnionTypes = M::UNION_TYPES; + const UNION_NAMES: &'static [&'static str] = M::UNION_NAMES; + + fn class_name() -> &'static str { + M::class_name() + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + M::is_exact(value) + } +} + +/// Implements `Type` for handle markers. Their Web IDL categories are those that C++ JSG's +/// `webidl` traits (jsg/web-idl.h) give the corresponding `v8::Local`. +macro_rules! impl_marker_type { + ($($marker:ident: $name:literal, $is_exact:ident, $types:expr;)*) => { + $(impl Type for v8::$marker { + const UNION_TYPES: UnionTypes = $types; + const UNION_NAMES: &'static [&'static str] = &[concat!("v8::", stringify!($marker))]; + + fn class_name() -> &'static str { + $name + } + + fn is_exact(value: v8::Local<'_, v8::Value>) -> bool { + value.$is_exact() + } + })* + }; +} + +impl_marker_type! { + String: "String", is_string, UnionTypes::STRING; + BigInt: "BigInt", is_big_int, UnionTypes::NUMERIC; + Object: "Object", is_object, UnionTypes::OBJECT; + Function: "Function", is_function, UnionTypes::OTHER; + Promise: "Promise", is_promise, UnionTypes::OTHER; + TypedArray: "TypedArray", is_typed_array, UnionTypes::OTHER; +} + +// Any value is a `v8::Value`. +impl Type for v8::Value { + const UNION_NAMES: &'static [&'static str] = &["v8::Value"]; + + fn class_name() -> &'static str { + "Value" + } + + fn is_exact(_value: v8::Local<'_, v8::Value>) -> bool { + true + } +} + +/// Implements `FromJS` for handles of markers that C++ JSG converts a value to without coercion +/// (`V8HandleWrapper` in jsg/type-wrapper.h): a value of the marker's type, and nothing else. +macro_rules! impl_handle_from_js { + ($($marker:ident: $member:expr;)*) => { + $(impl<'s> FromJS<'s> for v8::Local<'s, v8::$marker> { + type ResultType = Self; + + const UNION_MEMBER: UnionMember = $member; + + fn type_name() -> String { + // C++ JSG names `v8::Local` after `T`. + ::class_name().to_owned() + } + + fn from_js(lock: &mut Lock<'s>, value: v8::Local<'s, v8::Value>) -> Result { + let type_name = value.type_of(lock); + Self::try_from_js(lock, value)?.ok_or_else(|| { + Error::new_type_error(format!( + "Expected {} but got {type_name}", + >::type_name() + )) + }) + } + + fn try_from_js( + _lock: &mut Lock<'s>, + value: v8::Local<'s, v8::Value>, + ) -> Result, Error> { + Ok(Self::try_from(value).ok()) + } + })* + }; +} + +impl_handle_from_js! { + String: UnionMember::String; + BigInt: UnionMember::Numeric; + Object: UnionMember::Fallible; + Function: UnionMember::Fallible; + TypedArray: UnionMember::Fallible; +} + #[cfg(test)] #[path = "wrappable-test.rs"] mod tests;