diff --git a/core/engine/src/builtins/iterable/iterator_constructor.rs b/core/engine/src/builtins/iterable/iterator_constructor.rs index dbd173c81e2..a970bf867dd 100644 --- a/core/engine/src/builtins/iterable/iterator_constructor.rs +++ b/core/engine/src/builtins/iterable/iterator_constructor.rs @@ -30,6 +30,12 @@ use super::{ wrap_for_valid_iterator::WrapForValidIterator, }; +#[cfg(feature = "experimental")] +use super::{ + IteratorHint, + zip_iterator::{ZipIterator, ZipMode, ZipResultKind}, +}; + /// The `Iterator` constructor. /// /// More information: @@ -58,7 +64,7 @@ impl IntrinsicObject for IteratorConstructor { // non-enumerable get/set accessor (web-compat requirement). We use the // builder's `constructor_accessor` support so the property is part of the // shared-shape allocation rather than a post-build override. - BuiltInBuilder::from_standard_constructor::(realm) + let builder = BuiltInBuilder::from_standard_constructor::(realm) .inherits(Some( realm .intrinsics() @@ -68,7 +74,14 @@ impl IntrinsicObject for IteratorConstructor { )) // 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 // Prototype methods — lazy (return IteratorHelper) .method(Self::map, js_string!("map"), 1) .method(Self::filter, js_string!("filter"), 1) @@ -105,7 +118,10 @@ impl BuiltInObject for IteratorConstructor { impl BuiltInConstructor for IteratorConstructor { const PROTOTYPE_STORAGE_SLOTS: usize = 14; // 11 methods + @@toStringTag accessor (2 slots) + constructor accessor (2 slots) + #[cfg(not(feature = "experimental"))] const CONSTRUCTOR_STORAGE_SLOTS: usize = 2; + #[cfg(feature = "experimental")] + const CONSTRUCTOR_STORAGE_SLOTS: usize = 4; const CONSTRUCTOR_ARGUMENTS: usize = 0; const STANDARD_CONSTRUCTOR: fn(&StandardConstructors) -> &StandardConstructor = StandardConstructors::iterator; @@ -247,6 +263,328 @@ 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 { + if let Some(opts) = options.as_object() { + let p = opts.get(js_string!("padding"), context)?; + if p.is_undefined() { + None + } else if !p.is_object() { + return Err(JsNativeError::typ() + .with_message("padding must be an object") + .into()); + } else { + Some(p) + } + } else { + None + } + } 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 { + if let Some(opts) = options.as_object() { + let p = opts.get(js_string!("padding"), context)?; + if p.is_undefined() { + None + } else if !p.is_object() { + return Err(JsNativeError::typ() + .with_message("padding must be an object") + .into()); + } else { + Some(p) + } + } else { + None + } + } 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) + } + } + } // ==================== Prototype Accessor Properties ==================== diff --git a/core/engine/src/builtins/iterable/mod.rs b/core/engine/src/builtins/iterable/mod.rs index bbeda81acba..d912e5e806e 100644 --- a/core/engine/src/builtins/iterable/mod.rs +++ b/core/engine/src/builtins/iterable/mod.rs @@ -22,6 +22,11 @@ mod tests; pub(crate) use async_from_sync_iterator::AsyncFromSyncIterator; +#[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` @@ -83,6 +88,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 { @@ -100,6 +109,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(), } } } @@ -182,6 +193,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() + } } /// `%IteratorPrototype%` object diff --git a/core/engine/src/builtins/iterable/tests.rs b/core/engine/src/builtins/iterable/tests.rs index bac4bdab048..b29acba2ba5 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,3 +607,192 @@ 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]]"), + )]); +} 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..d18314aaa15 --- /dev/null +++ b/core/engine/src/builtins/iterable/zip_iterator.rs @@ -0,0 +1,418 @@ +//! 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, + js_string, + object::JsObject, + property::Attribute, + realm::Realm, + symbol::JsSymbol, +}; +use boa_gc::{Finalize, Trace}; + +/// 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 { + /// The list of underlying iterator records. An entry is set to `None` when exhausted + /// (only relevant in "longest" mode). + iters: Vec>, + + /// The total number of iterators (does not change when iterators are exhausted). + iter_count: usize, + + /// The list of iterators that are still open (indices into `iters`). + /// When this becomes empty in "longest" mode, iteration is done. + open_iters: Vec, + + /// The iteration mode. + #[unsafe_ignore_trace] + mode: ZipMode, + + /// Padding values for "longest" mode. + padding: Vec, + + /// What kind of result object to produce. + result_kind: ZipResultKind, + + /// Whether the iterator has been completed. + done: bool, +} + +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 new `ZipIterator`. + pub(crate) fn new( + iters: Vec, + mode: ZipMode, + padding: Vec, + result_kind: ZipResultKind, + ) -> Self { + let iter_count = iters.len(); + let open_iters: Vec = (0..iter_count).collect(); + let iters = iters.into_iter().map(Some).collect(); + Self { + iters, + iter_count, + open_iters, + mode, + padding, + result_kind, + done: false, + } + } + + /// 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 zip_iter = Self::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(), + zip_iter, + ); + obj.into() + } + + /// Closes all open iterators with the given completion. + fn close_all( + iters: &mut [Option], + open_iters: &[usize], + completion: JsResult, + context: &mut Context, + ) -> JsResult { + let mut result = completion; + for &idx in open_iters { + if let Some(iter) = iters[idx].take() { + let close_result = iter.close(Ok(JsValue::undefined()), context); + if result.is_ok() && close_result.is_err() { + result = close_result; + } + } + } + result + } + + /// Builds the result value from a list of values based on the `ZipResultKind`. + fn finish_results( + results: &[JsValue], + result_kind: &ZipResultKind, + context: &mut Context, + ) -> JsValue { + match result_kind { + ZipResultKind::Array => { + // CreateArrayFromList(results) + Array::create_array_from_list(results.to_vec(), context).into() + } + ZipResultKind::Keyed(keys) => { + // Create a null-prototype object with keys mapped to results. + 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("setting property on new object should not fail"); + } + } + obj.into() + } + } + } + + /// `%ZipIteratorPrototype%.next()` + /// + /// Implements the `IteratorZip` abstract operation from the TC39 Joint Iteration proposal. + /// + /// More information: + /// - [TC39 proposal][proposal] + /// + /// [proposal]: https://tc39.es/proposal-joint-iteration/#sec-iteratorzip + pub(crate) fn next(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult { + let obj = this + .as_object() + .ok_or_else(|| JsNativeError::typ().with_message("`this` is not a ZipIterator"))?; + + let mut zip_iter = obj + .downcast_mut::() + .ok_or_else(|| JsNativeError::typ().with_message("`this` is not a ZipIterator"))?; + + // If already done, return { value: undefined, done: true } + if zip_iter.done { + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + } + + // Step 1: If iterCount = 0, return done. + if zip_iter.iter_count == 0 { + zip_iter.done = true; + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + } + + let mode = zip_iter.mode.clone(); + let iter_count = zip_iter.iter_count; + + let mut results: Vec = Vec::with_capacity(iter_count); + + // Step 2: For each integer i such that 0 ≤ i < iterCount, in ascending order, do + for i in 0..iter_count { + if zip_iter.iters[i].is_none() { + // iter is null → assert mode is "longest", use padding[i] + debug_assert!(mode == ZipMode::Longest); + results.push( + zip_iter + .padding + .get(i) + .cloned() + .unwrap_or(JsValue::undefined()), + ); + continue; + } + + // Let result be Completion(IteratorStepValue(iter)) + let iter = zip_iter.iters[i] + .as_mut() + .expect("iterator is guaranteed to be present here unless exhausted"); + let step_result = iter.step_value(context); + + match step_result { + Err(err) => { + // If result is an abrupt completion: + // Remove iter from openIters. + zip_iter.open_iters.retain(|&idx| idx != i); + zip_iter.iters[i] = None; + zip_iter.done = true; + // Return ? IteratorCloseAll(openIters, result). + let open = zip_iter.open_iters.clone(); + return Self::close_all(&mut zip_iter.iters, &open, Err(err), context); + } + Ok(None) => { + // result is done. + // Remove iter from openIters. + zip_iter.open_iters.retain(|&idx| idx != i); + zip_iter.iters[i] = None; + + match mode { + ZipMode::Shortest => { + // Return ? IteratorCloseAll(openIters, ReturnCompletion(undefined)). + zip_iter.done = true; + let open = zip_iter.open_iters.clone(); + return Self::close_all( + &mut zip_iter.iters, + &open, + Ok(JsValue::undefined()), + context, + ) + .map(|_| { + create_iter_result_object(JsValue::undefined(), true, context) + }); + } + ZipMode::Strict => { + if i != 0 { + // If i ≠ 0, throw TypeError after closing all. + zip_iter.done = true; + let open = zip_iter.open_iters.clone(); + drop(Self::close_all( + &mut zip_iter.iters, + &open, + Ok(JsValue::undefined()), + context, + )); + return Err(JsNativeError::typ() + .with_message("iterators have different lengths in strict mode") + .into()); + } + + // i == 0: Check that all remaining iterators are also done. + for k in 1..iter_count { + if zip_iter.iters[k].is_none() { + continue; + } + let other = + zip_iter.iters[k].as_mut().expect("iterator is present"); + let step = other.step(context); + match step { + Err(err) => { + zip_iter.open_iters.retain(|&idx| idx != k); + zip_iter.iters[k] = None; + zip_iter.done = true; + let open = zip_iter.open_iters.clone(); + return Self::close_all( + &mut zip_iter.iters, + &open, + Err(err), + context, + ); + } + Ok(is_done) => { + if is_done { + // done → remove from openIters + zip_iter.open_iters.retain(|&idx| idx != k); + zip_iter.iters[k] = None; + } else { + // Not done → length mismatch, throw TypeError + zip_iter.done = true; + let open = zip_iter.open_iters.clone(); + drop(Self::close_all( + &mut zip_iter.iters, + &open, + Ok(JsValue::undefined()), + context, + )); + return Err(JsNativeError::typ() + .with_message( + "iterators have different lengths in strict mode", + ) + .into()); + } + } + } + } + // All done → return done. + zip_iter.done = true; + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + } + ZipMode::Longest => { + // If openIters is empty, return done. + if zip_iter.open_iters.is_empty() { + zip_iter.done = true; + return Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )); + } + // Set iters[i] to null, use padding[i]. + results.push( + zip_iter + .padding + .get(i) + .cloned() + .unwrap_or(JsValue::undefined()), + ); + } + } + } + Ok(Some(value)) => { + results.push(value); + } + } + } + + // finishResults(results) + let result_kind = zip_iter.result_kind.clone(); + let finished = Self::finish_results(&results, &result_kind, context); + + // Yield(results) → return { value: results, done: false } + Ok(create_iter_result_object(finished, false, context)) + } + + /// `%ZipIteratorPrototype%.return()` + /// + /// Closes all underlying iterators. + pub(crate) fn r#return( + this: &JsValue, + _: &[JsValue], + context: &mut Context, + ) -> JsResult { + let obj = this + .as_object() + .ok_or_else(|| JsNativeError::typ().with_message("`this` is not a ZipIterator"))?; + + let mut zip_iter = obj + .downcast_mut::() + .ok_or_else(|| JsNativeError::typ().with_message("`this` is not a ZipIterator"))?; + + zip_iter.done = true; + let open = zip_iter.open_iters.clone(); + Self::close_all( + &mut zip_iter.iters, + &open, + Ok(JsValue::undefined()), + context, + )?; + zip_iter.open_iters.clear(); + + Ok(create_iter_result_object( + JsValue::undefined(), + true, + context, + )) + } +} diff --git a/core/engine/src/builtins/mod.rs b/core/engine/src/builtins/mod.rs index f17088d9776..5a8883e7ae0 100644 --- a/core/engine/src/builtins/mod.rs +++ b/core/engine/src/builtins/mod.rs @@ -254,6 +254,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);