diff --git a/core/engine/src/builtins/iterable/iterator_constructor.rs b/core/engine/src/builtins/iterable/iterator_constructor.rs index 3f9b2c26f5a..90f6f9d03a0 100644 --- a/core/engine/src/builtins/iterable/iterator_constructor.rs +++ b/core/engine/src/builtins/iterable/iterator_constructor.rs @@ -30,6 +30,12 @@ use boa_gc::{Finalize, Trace}; use super::{iterator_helper::IteratorHelper, wrap_for_valid_iterator::WrapForValidIterator}; +#[cfg(feature = "experimental")] +use super::{ + IteratorHint, + zip_iterator::{ZipIterator, ZipMode, ZipResultKind}, +}; + /// The `Iterator` constructor. /// /// More information: @@ -42,11 +48,18 @@ pub(crate) struct IteratorConstructor; impl IntrinsicObject for IteratorConstructor { fn init(realm: &Realm) { let iterator_prototype = realm.intrinsics().constructors().iterator().prototype(); - BuiltInBuilder::from_standard_constructor::(realm) + let builder = BuiltInBuilder::from_standard_constructor::(realm) .inherits(Some(iterator_prototype.clone())) // Static methods .static_method(Self::from, js_string!("from"), 1) - .static_method(Self::concat, js_string!("concat"), 0) + .static_method(Self::concat, js_string!("concat"), 0); + + #[cfg(feature = "experimental")] + let builder = builder + .static_method(Self::zip, js_string!("zip"), 1) + .static_method(Self::zip_keyed, js_string!("zipKeyed"), 1); + + builder .static_property(PROTOTYPE, iterator_prototype, Attribute::empty()) .build_without_prototype(); } @@ -62,7 +75,10 @@ impl BuiltInObject for IteratorConstructor { impl BuiltInConstructor for IteratorConstructor { const PROTOTYPE_STORAGE_SLOTS: usize = 0; + #[cfg(not(feature = "experimental"))] const CONSTRUCTOR_STORAGE_SLOTS: usize = 3; + #[cfg(feature = "experimental")] + const CONSTRUCTOR_STORAGE_SLOTS: usize = 5; const CONSTRUCTOR_ARGUMENTS: usize = 0; const STANDARD_CONSTRUCTOR: fn(&StandardConstructors) -> &StandardConstructor = StandardConstructors::iterator; @@ -195,4 +211,329 @@ impl IteratorConstructor { // 6. Return result. Ok(helper.into()) } + + // ==================== Static Methods — Experimental ==================== + + #[cfg(feature = "experimental")] + /// `Iterator.zip ( iterables [ , options ] )` + /// + /// More information: + /// - [TC39 proposal][spec] + /// + /// [spec]: https://tc39.es/proposal-joint-iteration/#sec-iterator.zip + fn zip(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let iterables = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + + // 1. If iterables is not an Object, throw a TypeError exception. + let iterables_obj = iterables.as_object().ok_or_else(|| { + JsNativeError::typ().with_message("Iterator.zip requires an iterable object") + })?; + + // 2. Set options to ? GetOptionsObject(options). + // 3. Let mode be ? Get(options, "mode"). + // 4. If mode is undefined, set mode to "shortest". + // 5. If mode is not one of "shortest", "longest", or "strict", throw a TypeError exception. + let mode = Self::parse_zip_mode(options, context)?; + + // 6. Let paddingOption be undefined. + // 7. If mode is "longest", then + // a. Set paddingOption to ? Get(options, "padding"). + // b. If paddingOption is not undefined and paddingOption is not an Object, throw a TypeError exception. + let padding_option = if mode == ZipMode::Longest { + let p = options + .as_object() + .map(|opts| opts.get(js_string!("padding"), context)) + .transpose()? + .unwrap_or_default(); + + if p.is_undefined() { + None + } else if p.is_object() { + Some(p) + } else { + return Err(JsNativeError::typ() + .with_message("padding must be an object") + .into()); + } + } else { + None + }; + + // 8. Let iters be a new empty List. + let mut iters: Vec = Vec::new(); + + // 9. Let padding be a new empty List. + // (padding list built later in build_padding) + + // 10. Let inputIter be ? GetIterator(iterables, sync). + let iterables_val: JsValue = iterables_obj.clone().into(); + let mut input_iter = iterables_val.get_iterator(IteratorHint::Sync, context)?; + + // 11. Let next be not-started. + // 12. Repeat, while next is not done, + // a. Set next to Completion(IteratorStepValue(inputIter)). + // b. IfAbruptCloseIterators(next, iters). + // c. If next is not done, then + // i. Let iter be Completion(GetIteratorFlattenable(next, reject-primitives)). + // ii. IfAbruptCloseIterators(iter, the list-concatenation of « inputIter » and iters). + // iii. Append iter to iters. + loop { + let next = input_iter.step_value(context); + match next { + Err(err) => { + // IfAbruptCloseIterators(next, iters) + for iter in &iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + return Err(err); + } + Ok(None) => break, // done + Ok(Some(value)) => { + // GetIteratorFlattenable(next, reject-primitives) + if !value.is_object() { + // Close all collected iterators and the input iterator. + for iter in &iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + drop(input_iter.close(Ok(JsValue::undefined()), context)); + return Err(JsNativeError::typ() + .with_message("iterator value is not an object") + .into()); + } + let iter_result = value.get_iterator(IteratorHint::Sync, context); + match iter_result { + Err(err) => { + for iter in &iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + drop(input_iter.close(Ok(JsValue::undefined()), context)); + return Err(err); + } + Ok(iter) => iters.push(iter), + } + } + } + } + + // 13. Let iterCount be the number of elements in iters. + let iter_count = iters.len(); + + // 14. If mode is "longest", then ... Build padding list. + let padding = Self::build_padding(padding_option, iter_count, &iters, context)?; + + // 15. Let finishResults be a new Abstract Closure ... (handled in ZipIterator::create_zip_iterator) + // 16. Return ? IteratorZip(iters, mode, padding, finishResults). + Ok(ZipIterator::create_zip_iterator( + iters, + mode, + padding, + ZipResultKind::Array, + context, + )) + } + + #[cfg(feature = "experimental")] + /// `Iterator.zipKeyed ( iterables [ , options ] )` + /// + /// More information: + /// - [TC39 proposal][spec] + /// + /// [spec]: https://tc39.es/proposal-joint-iteration/#sec-iterator.zipkeyed + fn zip_keyed(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let iterables = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + + // 1. If iterables is not an Object, throw a TypeError exception. + let iterables_obj = iterables.as_object().ok_or_else(|| { + JsNativeError::typ().with_message("Iterator.zipKeyed requires an object") + })?; + + // 2. Set options to ? GetOptionsObject(options). + // 3. Let mode be ? Get(options, "mode"). + // 4. If mode is undefined, set mode to "shortest". + // 5. If mode is not one of "shortest", "longest", or "strict", throw a TypeError exception. + let mode = Self::parse_zip_mode(options, context)?; + + // 6. Let paddingOption be undefined. + // 7. If mode is "longest", then + // a. Set paddingOption to ? Get(options, "padding"). + // b. If paddingOption is not undefined and paddingOption is not an Object, throw a TypeError exception. + let padding_option = if mode == ZipMode::Longest { + let p = options + .as_object() + .map(|opts| opts.get(js_string!("padding"), context)) + .transpose()? + .unwrap_or_default(); + + if p.is_undefined() { + None + } else if p.is_object() { + Some(p) + } else { + return Err(JsNativeError::typ() + .with_message("padding must be an object") + .into()); + } + } else { + None + }; + + // 8. Let iters be a new empty List. + let mut iters: Vec = Vec::new(); + // 9. Let keys be a new empty List. + let mut keys: Vec = Vec::new(); + + // 10. Let iterablesKeys be ? EnumerableOwnProperties(iterables, key). + let all_keys = iterables_obj.own_property_keys(context)?; + // 11. For each element key of iterablesKeys, do + // a. Let value be ? Get(iterables, key). + // b. If value is not undefined, then + // i. Append key to keys. + // ii. Let iter be Completion(GetIteratorFlattenable(value, reject-primitives)). + // iii. IfAbruptCloseIterators(iter, iters). + // iv. Append iter to iters. + for key in all_keys { + let key_val: JsValue = key.clone().into(); + let value = iterables_obj.get(key.clone(), context)?; + if !value.is_undefined() { + keys.push(key_val); + if !value.is_object() { + for iter in &iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + return Err(JsNativeError::typ() + .with_message("iterator value is not an object") + .into()); + } + let iter = value.get_iterator(IteratorHint::Sync, context); + match iter { + Err(err) => { + for it in &iters { + drop(it.close(Ok(JsValue::undefined()), context)); + } + return Err(err); + } + Ok(iter) => iters.push(iter), + } + } + } + + // 12. Let iterCount be the number of elements in iters. + let iter_count = iters.len(); + + // 13. Let padding be a new empty List. + // 14. If mode is "longest", then ... (Build padding for zipKeyed) + let padding = if mode == ZipMode::Longest { + match padding_option { + None => vec![JsValue::undefined(); iter_count], + Some(pad_obj) => { + let pad = pad_obj + .as_object() + .expect("padding object verification already executed above"); + let mut padding = Vec::with_capacity(iter_count); + for key in &keys { + let prop_key = key.to_string(context).unwrap_or_default(); + let val = pad.get(prop_key, context)?; + padding.push(val); + } + padding + } + } + } else { + Vec::new() + }; + + // 15. Let finishResults be a new Abstract Closure ... (handled in ZipIterator::create_zip_iterator) + // 16. Return ? IteratorZip(iters, mode, padding, finishResults). + Ok(ZipIterator::create_zip_iterator( + iters, + mode, + padding, + ZipResultKind::Keyed(keys), + context, + )) + } + + #[cfg(feature = "experimental")] + /// Parses the `mode` option from the options object. + fn parse_zip_mode(options: &JsValue, context: &mut Context) -> JsResult { + if options.is_undefined() || options.is_null() { + return Ok(ZipMode::Shortest); + } + let opts = options + .as_object() + .ok_or_else(|| JsNativeError::typ().with_message("options must be an object"))?; + let mode_val = opts.get(js_string!("mode"), context)?; + if mode_val.is_undefined() { + return Ok(ZipMode::Shortest); + } + let mode_str = mode_val.to_string(context)?; + match mode_str.to_std_string_escaped().as_str() { + "shortest" => Ok(ZipMode::Shortest), + "longest" => Ok(ZipMode::Longest), + "strict" => Ok(ZipMode::Strict), + _ => Err(JsNativeError::typ() + .with_message("mode must be \"shortest\", \"longest\", or \"strict\"") + .into()), + } + } + + #[cfg(feature = "experimental")] + /// Builds the padding list for "longest" mode. + fn build_padding( + padding_option: Option, + iter_count: usize, + iters: &[super::IteratorRecord], + context: &mut Context, + ) -> JsResult> { + match padding_option { + None => Ok(vec![JsValue::undefined(); iter_count]), + Some(pad_val) => { + let mut padding_iter = pad_val + .get_iterator(IteratorHint::Sync, context) + .inspect_err(|_err| { + for iter in iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + })?; + let mut padding = Vec::new(); + let mut using_iterator = true; + + for _ in 0..iter_count { + if using_iterator { + match padding_iter.step_value(context) { + Err(err) => { + for iter in iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + return Err(err); + } + Ok(None) => { + using_iterator = false; + padding.push(JsValue::undefined()); + } + Ok(Some(val)) => { + padding.push(val); + } + } + } else { + padding.push(JsValue::undefined()); + } + } + + if using_iterator { + let close_result = padding_iter.close(Ok(JsValue::undefined()), context); + if let Err(err) = close_result { + for iter in iters { + drop(iter.close(Ok(JsValue::undefined()), context)); + } + return Err(err); + } + } + + Ok(padding) + } + } + } } diff --git a/core/engine/src/builtins/iterable/mod.rs b/core/engine/src/builtins/iterable/mod.rs index 837f6da38f1..392b426e571 100644 --- a/core/engine/src/builtins/iterable/mod.rs +++ b/core/engine/src/builtins/iterable/mod.rs @@ -26,6 +26,11 @@ mod tests; pub(crate) use async_from_sync_iterator::AsyncFromSyncIterator; pub(crate) use iterator_prototype::Iterator; +#[cfg(feature = "experimental")] +mod zip_iterator; +#[cfg(feature = "experimental")] +pub(crate) use zip_iterator::ZipIterator; + /// `IfAbruptCloseIterator ( value, iteratorRecord )` /// /// `IfAbruptCloseIterator` is a shorthand for a sequence of algorithm steps that use an `Iterator` @@ -87,6 +92,10 @@ pub struct IteratorPrototypes { /// The `%WrapForValidIteratorPrototype%` prototype object. wrap_for_valid_iterator: JsObject, + + /// The `ZipIteratorPrototype` prototype object. + #[cfg(feature = "experimental")] + zip_iterator: JsObject, } impl Default for IteratorPrototypes { @@ -104,6 +113,8 @@ impl Default for IteratorPrototypes { segment: JsObject::with_null_proto(), iterator_helper: JsObject::with_null_proto(), wrap_for_valid_iterator: JsObject::with_null_proto(), + #[cfg(feature = "experimental")] + zip_iterator: JsObject::with_null_proto(), } } } @@ -179,6 +190,14 @@ impl IteratorPrototypes { pub fn wrap_for_valid_iterator(&self) -> JsObject { self.wrap_for_valid_iterator.clone() } + + /// Returns the `ZipIteratorPrototype` object. + #[inline] + #[must_use] + #[cfg(feature = "experimental")] + pub fn zip_iterator(&self) -> JsObject { + self.zip_iterator.clone() + } } /// `%AsyncIteratorPrototype%` object diff --git a/core/engine/src/builtins/iterable/tests.rs b/core/engine/src/builtins/iterable/tests.rs index 2cb898ab76b..856ece121f8 100644 --- a/core/engine/src/builtins/iterable/tests.rs +++ b/core/engine/src/builtins/iterable/tests.rs @@ -375,6 +375,44 @@ fn iterator_concat_zero_arguments() { )]); } +// ── Iterator.zip — shortest mode (default) ────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_basic_two_arrays() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2,3], ['a','b','c']]).toArray())", + js_str!("[[1,\"a\"],[2,\"b\"],[3,\"c\"]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_basic_three_arrays() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2], ['a','b'], [true, false]]).toArray())", + js_str!("[[1,\"a\",true],[2,\"b\",false]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_stops_at_shortest() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2,3], ['a']]).toArray())", + js_str!("[[1,\"a\"]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_empty_iterables() { + run_test_actions([TestAction::assert_eq( + "Iterator.zip([]).toArray().length", + 0, + )]); +} + #[test] fn iterator_concat_single_argument() { run_test_actions([TestAction::assert_eq( @@ -399,6 +437,130 @@ fn iterator_concat_lazy_next() { )]); } +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_single_iterable() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2,3]]).toArray())", + js_str!("[[1],[2],[3]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_shortest_mode_explicit() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2,3], ['a','b']], { mode: 'shortest' }).toArray())", + js_str!("[[1,\"a\"],[2,\"b\"]]"), + )]); +} + +// ── Iterator.zip — longest mode ───────────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_longest_pads_with_undefined() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zip([[1,2,3], ['a']], { mode: 'longest' }).toArray(); + JSON.stringify(result) + "#, + js_str!("[[1,\"a\"],[2,null],[3,null]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_longest_custom_padding() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zip( + [[1,2,3], ['a']], + { mode: 'longest', padding: ['?', '!'] } + ).toArray(); + JSON.stringify(result) + "#, + js_str!("[[1,\"a\"],[2,\"!\"],[3,\"!\"]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_longest_same_length() { + run_test_actions([TestAction::assert_eq( + r#" + JSON.stringify(Iterator.zip([[1,2], ['a','b']], { mode: 'longest' }).toArray()) + "#, + js_str!("[[1,\"a\"],[2,\"b\"]]"), + )]); +} + +// ── Iterator.zip — strict mode ────────────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_strict_same_length() { + run_test_actions([TestAction::assert_eq( + "JSON.stringify(Iterator.zip([[1,2], ['a','b']], { mode: 'strict' }).toArray())", + js_str!("[[1,\"a\"],[2,\"b\"]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_strict_different_length_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip([[1,2,3], ['a','b']], { mode: 'strict' }).toArray()", + JsNativeErrorKind::Type, + "iterators have different lengths in strict mode", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_strict_first_shorter_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip([[1], ['a','b','c']], { mode: 'strict' }).toArray()", + JsNativeErrorKind::Type, + "iterators have different lengths in strict mode", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_strict_empty_iterators() { + run_test_actions([TestAction::assert_eq( + "Iterator.zip([[], []], { mode: 'strict' }).toArray().length", + 0, + )]); +} + +// ── Iterator.zipKeyed ─────────────────────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_basic() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zipKeyed({ a: [1,2,3], b: ['x','y','z'] }).toArray(); + result.map(o => o.a + ':' + o.b).join(',') + "#, + js_str!("1:x,2:y,3:z"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_shortest_default() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zipKeyed({ x: [1,2,3], y: ['a'] }).toArray(); + result.length + "#, + 1, + )]); +} + #[test] fn iterator_concat_non_object_throws() { run_test_actions([TestAction::assert_native_error( @@ -445,6 +607,194 @@ fn iterator_concat_return_result_shape() { const r = it.return(); r.done === true && r.value === undefined", )]); } +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_longest_mode() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zipKeyed( + { x: [1,2,3], y: ['a'] }, + { mode: 'longest' } + ).toArray(); + result.length + "#, + 3, + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_longest_with_padding() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zipKeyed( + { x: [1,2,3], y: ['a'] }, + { mode: 'longest', padding: { y: 'default' } } + ).toArray(); + result.map(o => o.x + ':' + o.y).join(',') + "#, + js_str!("1:a,2:default,3:default"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_strict_same_length() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zipKeyed( + { a: [1,2], b: ['x','y'] }, + { mode: 'strict' } + ).toArray(); + result.length + "#, + 2, + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_strict_different_length_throws() { + run_test_actions([TestAction::assert_native_error( + r#" + Iterator.zipKeyed( + { a: [1,2,3], b: ['x','y'] }, + { mode: 'strict' } + ).toArray() + "#, + JsNativeErrorKind::Type, + "iterators have different lengths in strict mode", + )]); +} + +// ── Error handling ────────────────────────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_non_object_iterables_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip(42)", + JsNativeErrorKind::Type, + "Iterator.zip requires an iterable object", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_invalid_mode_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip([[1]], { mode: 'invalid' })", + JsNativeErrorKind::Type, + "mode must be \"shortest\", \"longest\", or \"strict\"", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_non_object_padding_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip([[1]], { mode: 'longest', padding: 42 })", + JsNativeErrorKind::Type, + "padding must be an object", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_options_must_be_object() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zip([[1]], 'notAnObject')", + JsNativeErrorKind::Type, + "options must be an object", + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_keyed_non_object_iterables_throws() { + run_test_actions([TestAction::assert_native_error( + "Iterator.zipKeyed(42)", + JsNativeErrorKind::Type, + "Iterator.zipKeyed requires an object", + )]); +} + +// ── ZipIterator protocol ──────────────────────────────────────────────────── + +#[cfg(feature = "experimental")] +#[test] +fn zip_iterator_return_closes_iterators() { + run_test_actions([ + TestAction::run( + r#" + let closed1 = false; + let closed2 = false; + const iter1 = { + [Symbol.iterator]() { return this; }, + next() { return { value: 1, done: false }; }, + return() { closed1 = true; return { value: undefined, done: true }; } + }; + const iter2 = { + [Symbol.iterator]() { return this; }, + next() { return { value: 2, done: false }; }, + return() { closed2 = true; return { value: undefined, done: true }; } + }; + const zipped = Iterator.zip([iter1, iter2]); + zipped.return(); + "#, + ), + TestAction::assert("closed1"), + TestAction::assert("closed2"), + ]); +} + +#[cfg(feature = "experimental")] +#[test] +fn zip_iterator_next_after_done() { + run_test_actions([ + TestAction::run( + r#" + const zipped = Iterator.zip([[]]); + "#, + ), + TestAction::assert_eq("JSON.stringify(zipped.next())", js_str!("{\"done\":true}")), + TestAction::assert_eq("JSON.stringify(zipped.next())", js_str!("{\"done\":true}")), + ]); +} + +#[cfg(feature = "experimental")] +#[test] +fn zip_iterator_to_string_tag() { + run_test_actions([TestAction::assert_eq( + "Iterator.zip([[1]]).next(); Iterator.zip([[1]])[Symbol.toStringTag]", + js_str!("Iterator Helper"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_with_generators() { + run_test_actions([TestAction::assert_eq( + r#" + function* nums() { yield 1; yield 2; yield 3; } + function* letters() { yield 'a'; yield 'b'; yield 'c'; } + JSON.stringify(Iterator.zip([nums(), letters()]).toArray()) + "#, + js_str!("[[1,\"a\"],[2,\"b\"],[3,\"c\"]]"), + )]); +} + +#[cfg(feature = "experimental")] +#[test] +fn iterator_zip_longest_with_three_iterators() { + run_test_actions([TestAction::assert_eq( + r#" + const result = Iterator.zip([[1], [10, 20], [100, 200, 300]], { mode: 'longest' }).toArray(); + JSON.stringify(result) + "#, + js_str!("[[1,10,100],[null,20,200],[null,null,300]]"), + )]); +} #[test] fn iterator_includes_basic() { diff --git a/core/engine/src/builtins/iterable/zip_iterator.rs b/core/engine/src/builtins/iterable/zip_iterator.rs new file mode 100644 index 00000000000..2cc6812f0d5 --- /dev/null +++ b/core/engine/src/builtins/iterable/zip_iterator.rs @@ -0,0 +1,436 @@ +//! This module implements the `ZipIterator` object backing `Iterator.zip` and `Iterator.zipKeyed`. +//! +//! More information: +//! - [TC39 proposal][proposal] +//! +//! [proposal]: https://tc39.es/proposal-joint-iteration/ + +use crate::property::PropertyKey; +use crate::{ + Context, JsData, JsResult, JsValue, + builtins::{ + Array, BuiltInBuilder, IntrinsicObject, + iterable::{IteratorRecord, create_iter_result_object}, + }, + context::intrinsics::Intrinsics, + error::{JsNativeError, PanicError}, + js_string, + native_function::{CoroutineState, NativeCoroutine}, + object::JsObject, + property::Attribute, + realm::Realm, + symbol::JsSymbol, + vm::CompletionRecord, +}; +use boa_gc::{Finalize, Trace}; +use std::cell::Cell; +use std::ops::ControlFlow; + +/// The mode for zip iteration. +#[derive(Debug, Clone, PartialEq, Eq, Trace, Finalize)] +pub(crate) enum ZipMode { + /// Stops when the shortest iterator is done. + Shortest, + /// Continues until the longest iterator is done, padding with `undefined` or user values. + Longest, + /// All iterators must have the same length, otherwise throws a `TypeError`. + Strict, +} + +/// The kind of result to produce from the zip iterator. +#[derive(Debug, Clone, Trace, Finalize)] +pub(crate) enum ZipResultKind { + /// Produces arrays (for `Iterator.zip`). + Array, + /// Produces objects with the given keys (for `Iterator.zipKeyed`). + Keyed(Vec), +} + +/// The `ZipIterator` object represents a joint iteration over multiple iterators. +/// +/// It implements the iterator protocol and is returned by `Iterator.zip()` and `Iterator.zipKeyed()`. +/// +/// More information: +/// - [TC39 proposal][proposal] +/// +/// [proposal]: https://tc39.es/proposal-joint-iteration/ +#[derive(Debug, Finalize, Trace, JsData)] +pub(crate) struct ZipIterator { + pub(crate) coroutine: Option, +} + +impl IntrinsicObject for ZipIterator { + fn init(realm: &Realm) { + BuiltInBuilder::with_intrinsic::(realm) + .prototype(realm.intrinsics().constructors().iterator().prototype()) + .static_method(Self::next, js_string!("next"), 0) + .static_method(Self::r#return, js_string!("return"), 0) + .static_property( + JsSymbol::to_string_tag(), + js_string!("Iterator Helper"), + Attribute::CONFIGURABLE, + ) + .build(); + } + + fn get(intrinsics: &Intrinsics) -> JsObject { + intrinsics.objects().iterator_prototypes().zip_iterator() + } +} + +impl ZipIterator { + /// Creates a `ZipIterator` JS object and wraps it as a `JsValue`. + pub(crate) fn create_zip_iterator( + iters: Vec, + mode: ZipMode, + padding: Vec, + result_kind: ZipResultKind, + context: &mut Context, + ) -> JsValue { + let op = Zip::new(iters, mode, padding, result_kind); + let obj = JsObject::from_proto_and_data_with_shared_shape( + context.root_shape(), + context + .intrinsics() + .objects() + .iterator_prototypes() + .zip_iterator(), + Self { + coroutine: Some(op), + }, + ); + obj.into() + } + + #[track_caller] + pub(crate) fn generator_validate(this: &JsValue) -> JsResult> { + this.as_object() + .and_then(|o| o.downcast::().ok()) + .ok_or_else(|| { + JsNativeError::typ() + .with_message("ZipIterator method called on non-object") + .into() + }) + } + + /// `%ZipIteratorPrototype%.next()` + pub(crate) fn next(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult { + let zip_iter = Self::generator_validate(this)?; + let coroutine = zip_iter + .borrow_mut() + .data_mut() + .coroutine + .take() + .ok_or_else(|| JsNativeError::typ().with_message("ZipIterator is already executing"))?; + + let result = match coroutine.call(CompletionRecord::Normal(JsValue::undefined()), context) { + ControlFlow::Continue(value) => Ok(create_iter_result_object(value, false, context)), + ControlFlow::Break(Ok(())) => Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )), + ControlFlow::Break(Err(err)) => Err(err), + }; + + zip_iter.borrow_mut().data_mut().coroutine = Some(coroutine); + result + } + + /// `%ZipIteratorPrototype%.return()` + pub(crate) fn r#return( + this: &JsValue, + _: &[JsValue], + context: &mut Context, + ) -> JsResult { + let zip_iter = Self::generator_validate(this)?; + let coroutine = zip_iter + .borrow_mut() + .data_mut() + .coroutine + .take() + .ok_or_else(|| JsNativeError::typ().with_message("ZipIterator is already executing"))?; + + let result = match coroutine.call(CompletionRecord::Return(JsValue::undefined()), context) { + ControlFlow::Continue(_) => { + Err(PanicError::new("ZipIterator cannot yield after return").into()) + } + ControlFlow::Break(Ok(())) => Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )), + ControlFlow::Break(Err(err)) => Err(err), + }; + + zip_iter.borrow_mut().data_mut().coroutine = Some(coroutine); + result + } +} + +#[derive(Trace, Finalize, Default)] +#[boa_gc(unsafe_no_drop)] +pub(crate) enum Zip { + #[default] + Completed, + Yielding { + iters: Vec>, + iter_count: usize, + open_iters: Vec, + mode: ZipMode, + padding: Vec, + result_kind: ZipResultKind, + }, +} + +impl Zip { + #[allow( + clippy::new_ret_no_self, + reason = "slightly cleaner to have this be a `new` method" + )] + pub(crate) fn new( + iters: Vec, + mode: ZipMode, + padding: Vec, + result_kind: ZipResultKind, + ) -> NativeCoroutine { + let iter_count = iters.len(); + let open_iters: Vec = (0..iter_count).collect(); + let iters = iters.into_iter().map(Some).collect(); + + NativeCoroutine::from_copy_closure_with_captures( + |completion, state, context| { + let mut st = state.take(); + match &mut st { + Self::Yielding { + iters, open_iters, .. + } => { + let is_abrupt = matches!( + &completion, + CompletionRecord::Return(_) | CompletionRecord::Throw(_) + ); + if is_abrupt { + let err_result: JsResult<()> = match completion { + CompletionRecord::Throw(err) => Err(err), + _ => Ok(()), + }; + let mut final_result = err_result; + for &idx in open_iters.iter() { + if let Some(iter) = &mut iters[idx] { + let close_result = + iter.close(Ok(JsValue::undefined()), context); + if final_result.is_ok() && close_result.is_err() { + final_result = close_result.map(|_| ()); + } + } + } + return ControlFlow::Break(final_result); + } + } + Self::Completed => return ControlFlow::Break(Ok(())), + } + + let Self::Yielding { + mut iters, + iter_count, + mut open_iters, + mode, + padding, + result_kind, + } = st + else { + unreachable!() + }; + + // 2. If iterCount = 0, return NormalCompletion(undefined). + if iter_count == 0 { + return CoroutineState::Break(Ok(())); + } + + // 3. Let results be a new empty List. + let mut results: Vec = Vec::with_capacity(iter_count); + + // 4. For each integer i such that 0 <= i < iterCount, in ascending order, do + for i in 0..iter_count { + // a. If iters[i] is empty, then + // i. Append padding[i] to results. + if iters[i].is_none() { + results.push(padding.get(i).cloned().unwrap_or(JsValue::undefined())); + continue; + } + + // b. Else, + // i. Let iter be iters[i]. + let iter = iters[i].as_mut().expect("present"); + // ii. Let step be ? Call(iter.[[NextMethod]], iter.[[Iterator]]). + let step_result = iter.step_value(context); + + match step_result { + Err(err) => { + open_iters.retain(|&idx| idx != i); + iters[i] = None; + let mut result = Err(err); + for &idx in &open_iters { + if let Some(it) = &mut iters[idx] { + let close_rc = it.close(Ok(JsValue::undefined()), context); + if result.is_ok() && close_rc.is_err() { + result = close_rc; + } + } + } + return CoroutineState::Break(result.map(|_| ())); + } + Ok(None) => { + // v. If IteratorComplete(step) is true, then + // 1. Set iters[i] to empty. + open_iters.retain(|&idx| idx != i); + iters[i] = None; + + match mode { + // 2. If mode is "shortest", then + // a. Return ? IteratorCloseAll(iters, NormalCompletion(undefined)). + ZipMode::Shortest => { + let mut result = Ok(()); + for &idx in &open_iters { + if let Some(it) = &mut iters[idx] { + let close_rc = + it.close(Ok(JsValue::undefined()), context); + if result.is_ok() && close_rc.is_err() { + result = close_rc.map(|_| ()); + } + } + } + return CoroutineState::Break(result); + } + // 3. If mode is "strict", then + ZipMode::Strict => { + // a. If i > 0, then + // i. Return ? IteratorCloseAll(iters, ThrowCompletion(TypeError)). + if i != 0 { + let mut result = Ok(()); + for &idx in &open_iters { + if let Some(it) = &mut iters[idx] { + let close_rc = + it.close(Ok(JsValue::undefined()), context); + if result.is_ok() && close_rc.is_err() { + result = close_rc.map(|_| ()); + } + } + } + if result.is_ok() { + return CoroutineState::Break(Err(JsNativeError::typ( + ) + .with_message( + "iterators have different lengths in strict mode", + ) + .into())); + } + return CoroutineState::Break(result); + } + + for k in 1..iter_count { + if iters[k].is_none() { + continue; + } + let other = iters[k].as_mut().expect("present"); + let step = other.step(context); + match step { + Err(err) => { + open_iters.retain(|&idx| idx != k); + iters[k] = None; + let mut result = Err(err); + for &idx in &open_iters { + if let Some(it) = &mut iters[idx] { + let close_rc = it.close( + Ok(JsValue::undefined()), + context, + ); + if result.is_ok() && close_rc.is_err() { + result = close_rc; + } + } + } + return CoroutineState::Break(result.map(|_| ())); + } + Ok(is_done) => { + if is_done { + open_iters.retain(|&idx| idx != k); + iters[k] = None; + } else { + let mut result = Ok(()); + for &idx in &open_iters { + if let Some(it) = &mut iters[idx] { + let close_rc = it.close( + Ok(JsValue::undefined()), + context, + ); + if result.is_ok() && close_rc.is_err() { + result = close_rc.map(|_| ()); + } + } + } + if result.is_ok() { + return CoroutineState::Break(Err(JsNativeError::typ().with_message("iterators have different lengths in strict mode").into())); + } + return CoroutineState::Break(result); + } + } + } + } + // d. Return NormalCompletion(undefined). + return CoroutineState::Break(Ok(())); + } + // 4. If mode is "longest", then ... + ZipMode::Longest => { + if open_iters.is_empty() { + return CoroutineState::Break(Ok(())); + } + results.push( + padding.get(i).cloned().unwrap_or(JsValue::undefined()), + ); + } + } + } + Ok(Some(value)) => { + results.push(value); + } + } + } + + let finished = match &result_kind { + ZipResultKind::Array => Array::create_array_from_list(results, context).into(), + ZipResultKind::Keyed(keys) => { + let obj = JsObject::with_null_proto(); + for (i, key) in keys.iter().enumerate() { + if let Some(val) = results.get(i) { + let prop_key: PropertyKey = + key.to_string(context).unwrap_or_default().into(); + obj.set(prop_key, val.clone(), false, context) + .expect("new object"); + } + } + obj.into() + } + }; + + state.set(Self::Yielding { + iters, + iter_count, + open_iters, + mode, + padding, + result_kind, + }); + CoroutineState::Continue(finished) + }, + Cell::new(Self::Yielding { + iters, + iter_count, + open_iters, + mode, + padding, + result_kind, + }), + ) + } +} diff --git a/core/engine/src/builtins/mod.rs b/core/engine/src/builtins/mod.rs index 6b6e8470ec3..a617c2a2d3b 100644 --- a/core/engine/src/builtins/mod.rs +++ b/core/engine/src/builtins/mod.rs @@ -251,6 +251,8 @@ impl Realm { IteratorConstructor::init(self); WrapForValidIterator::init(self); IteratorHelper::init(self); + #[cfg(feature = "experimental")] + iterable::ZipIterator::init(self); Math::init(self); Json::init(self); Array::init(self); diff --git a/core/macros/src/module.rs b/core/macros/src/module.rs index e4bfcff5fe0..5b50806813b 100644 --- a/core/macros/src/module.rs +++ b/core/macros/src/module.rs @@ -169,10 +169,7 @@ fn module_impl_impl(_args: ModuleArguments, mut mod_: ItemMod) -> SpannedResult< let mut generics = vec![]; for item in mod_.content.map_or_else(Vec::new, |c| c.1).as_mut_slice() { - // Check for skip attributes. - #[allow(clippy::collapsible_match)] - // Allowed because take_path_attr would borrow attrs as mutable - match item { + let skip = match item { Item::Const(ItemConst { attrs, .. }) | Item::Enum(ItemEnum { attrs, .. }) | Item::ExternCrate(ItemExternCrate { attrs, .. }) @@ -187,16 +184,16 @@ fn module_impl_impl(_args: ModuleArguments, mut mod_: ItemMod) -> SpannedResult< | Item::TraitAlias(ItemTraitAlias { attrs, .. }) | Item::Type(ItemType { attrs, .. }) | Item::Union(ItemUnion { attrs, .. }) - | Item::Use(ItemUse { attrs, .. }) => { - if take_path_attr(attrs, "skip") { - original_module_decl = quote! { - #original_module_decl - #item - }; - continue; - } - } - _ => {} + | Item::Use(ItemUse { attrs, .. }) => take_path_attr(attrs, "skip"), + _ => false, + }; + + if skip { + original_module_decl = quote! { + #original_module_decl + #item + }; + continue; } let result = match item {