From a3b309d69f223afad5fbc086a84c2fa2fe29ecec Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 14:15:15 +0300 Subject: [PATCH] Update test262 suite and implement Iterator.prototype.join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the pinned suite to defaaf1571cd13b183e3f505c6a06e8db316e593. Three things came in with it, and all three needed engine work to stay green. Iterator.prototype.join (https://github.com/tc39/proposal-iterator-join) is the new feature, with 18 tests. Joining is the easy half; which failures close the receiver is the part worth reading the tests for. Coercing the separator closes it, and so does coercing a value the iterator produced. An abrupt `next` lookup, a throwing `next()`, and a protocol violation (a step result that is not an object) all propagate without closing, because IteratorStepValue marks the record done first. Exhaustion does not close either. The separator is coerced *before* `next` is read, so a receiver whose `next` getter throws never gets that far, and `next` is read exactly once however many steps follow. Nullish values format as the empty string, as they do in Array.prototype.join. Intl.Locale.prototype.getCollations picked up a normative change (https://github.com/tc39/ecma402/pull/1072): a tag matching no available Collator locale no longer falls back to the default locale — it reports the hardcoded root collations « "emoji", "eor" » — and the result is now sorted in lexicographic code unit order. Jint reported a single-element ["default"] for every locale, and "default" is Jint's internal placeholder for "no collation was requested", not an identifier a locale can report at all. It now reports the root collations plus whatever the language adds, sorted; an explicitly requested collation, however it was requested, is still the whole answer. The data comes from the table CollatorConstructor already kept, so nothing new was invented: "de" reports ["emoji","eor","phonebk"], "zh" its seven, "tr" and "und" and any private-use tag the bare root pair. import-defer grew IsModuleSCCEvaluated, and Jint needed it. A module that finished its own body is only really done once the strongly connected component it belongs to is: a member of an async cycle reaches EVALUATED as soon as its body returns, while the cycle root sits in EVALUATING-ASYNC awaiting a top-level await. Reading the member's own status is what GatherAsynchronousTransitiveDependencies and ReadyForSyncExecution were doing, so a deferred subgraph reaching into such a cycle looked free of async dependencies — and the deferring module ran its body, and took its deferred namespace, before the cycle had finished. The new test walks exactly that graph. ReadyForSyncExecution refuses an EVALUATED-but-not-SCC-evaluated module rather than asserting on it: the spec asserts LINKED at that point, having convinced itself the state is unreachable, and refusing is both the conservative answer and the one EvaluateSync already turns into the spec's TypeError. Co-Authored-By: Claude Opus 5 (1M context) --- .../Test262Harness.settings.json | 2 +- Jint.Tests/Runtime/IntlTests.cs | 33 +++++++ Jint.Tests/Runtime/IteratorHelpersTests.cs | 97 +++++++++++++++++++ Jint/Native/Intl/CollatorConstructor.cs | 41 ++++++++ Jint/Native/Intl/LocalePrototype.cs | 22 ++++- Jint/Native/Iterator/IteratorPrototype.cs | 87 +++++++++++++++++ Jint/Runtime/Modules/CyclicModule.cs | 28 +++++- README.md | 1 + 8 files changed, 303 insertions(+), 8 deletions(-) diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json index 3e9afe9f87..f234255b14 100644 --- a/Jint.Tests.Test262/Test262Harness.settings.json +++ b/Jint.Tests.Test262/Test262Harness.settings.json @@ -1,5 +1,5 @@ { - "SuiteGitSha": "5f1f06f0cacf5c15b9387760375e897d7ae3f6d5", + "SuiteGitSha": "defaaf1571cd13b183e3f505c6a06e8db316e593", //"SuiteDirectory": "//mnt/c/work/test262", "TargetPath": "./Generated", "Namespace": "Jint.Tests.Test262", diff --git a/Jint.Tests/Runtime/IntlTests.cs b/Jint.Tests/Runtime/IntlTests.cs index 7280c43866..3e623aa54f 100644 --- a/Jint.Tests/Runtime/IntlTests.cs +++ b/Jint.Tests/Runtime/IntlTests.cs @@ -741,4 +741,37 @@ public void NumberFormat_FormatRange_PrefixCurrencyCollapse_TightSeparator() ".formatRange(2.9, 3.1)"); result.AsString().Should().Be("+$2.90–3.10"); } + + [Theory] + // a tag matching no available locale falls back to the hardcoded root collations + [InlineData("und", """["emoji","eor"]""")] + [InlineData("und-Latn-US", """["emoji","eor"]""")] + [InlineData("qtz-CN", """["emoji","eor"]""")] + // a matched locale reports the root collations plus its own, in code unit order + [InlineData("tr", """["emoji","eor"]""")] + [InlineData("de", """["emoji","eor","phonebk"]""")] + [InlineData("ko", """["emoji","eor","searchjl","unihan"]""")] + // an explicitly requested collation is the whole answer, however it was requested + [InlineData("de-u-co-phonebk", """["phonebk"]""")] + [InlineData("und-u-co-pinyin", """["pinyin"]""")] + public void LocaleGetCollations(string tag, string expected) + { + var result = _engine.Evaluate($"JSON.stringify(new Intl.Locale('{tag}').getCollations())"); + result.AsString().Should().Be(expected); + } + + [Fact] + public void LocaleGetCollationsNeverReportsStandardOrSearch() + { + var result = _engine.Evaluate(""" + ['ar', 'de', 'en', 'ja', 'ko', 'sv', 'tr', 'zh', 'und'].every(tag => { + const collations = new Intl.Locale(tag).getCollations(); + return collations.length > 0 + && !collations.includes('standard') + && !collations.includes('search') + && collations.join() === [...collations].sort().join(); + }); + """); + result.AsBoolean().Should().BeTrue(); + } } diff --git a/Jint.Tests/Runtime/IteratorHelpersTests.cs b/Jint.Tests/Runtime/IteratorHelpersTests.cs index 70bdf5a630..d2d7af3584 100644 --- a/Jint.Tests/Runtime/IteratorHelpersTests.cs +++ b/Jint.Tests/Runtime/IteratorHelpersTests.cs @@ -35,4 +35,101 @@ public void ToArrayReturnsPlainArray() result.Should().BeTrue(); } + + [Fact] + public void JoinConcatenatesUsingSeparator() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + function* gen() { yield 'a'; yield 'b'; } + JSON.stringify([ + [].values().join(), + ['one'].values().join(), + ['one', 'two', 'three'].values().join(), + ['one', 'two', 'three'].values().join('&&'), + ['one', 'two', 'three'].values().join(''), + gen().join('-'), + [1, 2, 3, 4, 5].values().drop(1).take(3).map(x => x * 10).join('/') + ]); + """).AsString(); + + result.Should().Be("""["","one","one,two,three","one&&two&&three","onetwothree","a-b","20/30/40"]"""); + } + + [Fact] + public void JoinFormatsNullishValuesAsEmptyString() + { + var engine = new Engine(); + var result = engine.Evaluate("['one', null, 'two', undefined, 'three'].values().join()").AsString(); + + result.Should().Be("one,,two,,three"); + } + + [Fact] + public void JoinCoercesSeparatorBeforeReadingNext() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + const effects = []; + const separator = { toString() { effects.push('toString'); return '&&'; } }; + let n = 0; + const it = { + get next() { + effects.push('get next'); + return () => ++n <= 2 ? { done: false, value: n === 1 ? 'one' : 'two' } : { done: true }; + } + }; + Iterator.prototype.join.call(it, separator) + '|' + effects.join(','); + """).AsString(); + + result.Should().Be("one&&two|toString,get next"); + } + + [Fact] + public void JoinClosesIteratorWhenCoercionThrows() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + const throwy = { toString() { throw new Error('nope'); } }; + function makeIterator(value) { + return { + closed: false, + next() { return this.done ? { done: true } : (this.done = true, { done: false, value }); }, + return() { this.closed = true; } + }; + } + + const onSeparator = makeIterator('x'); + try { Iterator.prototype.join.call(onSeparator, throwy); } catch { } + + const onContents = makeIterator(throwy); + try { Iterator.prototype.join.call(onContents); } catch { } + + // an iterator that simply runs out must NOT be closed + const onExhaustion = makeIterator('x'); + Iterator.prototype.join.call(onExhaustion); + + JSON.stringify([onSeparator.closed, onContents.closed, onExhaustion.closed]); + """).AsString(); + + result.Should().Be("[true,true,false]"); + } + + [Fact] + public void JoinThrowsOnNonObjectReceiver() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + [undefined, null, false, 0, 0n, '', Symbol()].every(receiver => { + try { + Iterator.prototype.join.call(receiver); + return false; + } catch (e) { + return e instanceof TypeError; + } + }); + """).AsBoolean(); + + result.Should().BeTrue(); + } } diff --git a/Jint/Native/Intl/CollatorConstructor.cs b/Jint/Native/Intl/CollatorConstructor.cs index 86132544ca..7981bc90cb 100644 --- a/Jint/Native/Intl/CollatorConstructor.cs +++ b/Jint/Native/Intl/CollatorConstructor.cs @@ -330,6 +330,47 @@ private static string GetCollationOption(ObjectInstance options, string? unicode return "default"; } + // The collations CLDR's root locale contributes to every locale. "standard" and "search" are + // deliberately absent: ECMA-402 forbids either from appearing in a reported collation list. + private static readonly string[] RootCollations = ["emoji", "eor"]; + + /// + /// The collation identifiers reported for by + /// https://tc39.es/ecma402/#sec-collationsoflocale — the root collations plus whatever the + /// language adds, in lexicographic code unit order. A language carrying no data of its own — + /// including "und" and any tag matching no available Collator locale — gets exactly the root + /// list, which is what the spec hardcodes for the unmatched case. + /// + internal static string[] GetCollationsForLanguage(string? language) + { + if (language is null || !LocaleCollationSupport.TryGetValue(language, out var supported)) + { + return RootCollations; + } + + var list = new List(supported.Count + RootCollations.Length); + foreach (var collation in supported) + { + // "default" is Jint's placeholder for "no explicit collation was requested", not an + // identifier a locale can report. + if (!string.Equals(collation, "default", StringComparison.Ordinal)) + { + list.Add(collation); + } + } + + foreach (var rootCollation in RootCollations) + { + if (!list.Contains(rootCollation)) + { + list.Add(rootCollation); + } + } + + list.Sort(StringComparer.Ordinal); + return list.ToArray(); + } + private static bool IsCollationSupportedForLocale(string language, string collation) { if (string.Equals(collation, "default", StringComparison.Ordinal)) diff --git a/Jint/Native/Intl/LocalePrototype.cs b/Jint/Native/Intl/LocalePrototype.cs index 8491e27239..fd74564932 100644 --- a/Jint/Native/Intl/LocalePrototype.cs +++ b/Jint/Native/Intl/LocalePrototype.cs @@ -207,10 +207,24 @@ private JsArray GetCollations(JsValue thisObject) { var locale = ValidateLocale(thisObject); - // Return array of supported collations - var result = new JsArray(Engine, 1); - result.SetIndexValue(0, locale.Collation ?? "default", updateLength: true); - return result; + // 1. If loc.[[Collation]] is not undefined, return CreateArrayFromList(« loc.[[Collation]] »). + if (locale.Collation is not null) + { + var requested = new JsArray(Engine, 1); + requested.SetIndexValue(0, locale.Collation, updateLength: true); + return requested; + } + + // 2-6. Otherwise report the matched locale's collations, or the hardcoded root list when the + // tag matches no available Collator locale, sorted in lexicographic code unit order. + var collations = CollatorConstructor.GetCollationsForLanguage(locale.Language); + var values = new JsValue[collations.Length]; + for (var i = 0; i < collations.Length; i++) + { + values[i] = JsString.Create(collations[i]); + } + + return new JsArray(Engine, values); } /// diff --git a/Jint/Native/Iterator/IteratorPrototype.cs b/Jint/Native/Iterator/IteratorPrototype.cs index 48322a5292..2ea5ce6030 100644 --- a/Jint/Native/Iterator/IteratorPrototype.cs +++ b/Jint/Native/Iterator/IteratorPrototype.cs @@ -1,3 +1,4 @@ +using System.Text; using Jint.Native.Object; using Jint.Native.Symbol; using Jint.Pooling; @@ -640,6 +641,92 @@ private JsValue Find(JsValue thisObject, JsValue predicate) return Undefined; } + /// + /// https://tc39.es/proposal-iterator-join/#sec-iterator.prototype.join + /// + [JsFunction] + private JsValue Join(JsValue thisObject, JsValue separator) + { + // 1. Let O be the this value. + // 2. If O is not an Object, throw a TypeError exception. + if (thisObject is not ObjectInstance o) + { + Throw.TypeError(_realm, "Iterator.prototype.join called on non-object"); + return Undefined; + } + + string sep; + if (separator.IsUndefined()) + { + // 3. If separator is undefined, let sep be ",". + sep = ","; + } + else + { + // 4. Else, let sep be Completion(ToString(separator)), closing O if that is abrupt. + try + { + sep = TypeConverter.ToString(separator); + } + catch + { + IteratorClose(o, CompletionType.Throw); + throw; + } + } + + // 5. Let iterated be ? GetIteratorDirect(O). Reading "next" deliberately happens only after + // the separator has been coerced, and an abrupt lookup here does NOT close the receiver. + var iterated = GetIteratorDirect(o); + + // 6. Let R be the empty String. 7. Let first be true. + using var sb = new ValueStringBuilder(); + var first = true; + var iterations = 0; + + // 8. Repeat, + // a. Let next be ? IteratorStepValue(iterated). b. If next is DONE, return R. + // An abrupt step — a throwing next(), a non-object result, or a throwing "value" getter — + // propagates without closing, per IteratorStepValue marking the record done. + while (iterated.TryIteratorStep(out var iteratorResult)) + { + var value = iteratorResult.Get(CommonProperties.Value); + + // c. If first is false, set R to the string-concatenation of R and sep. + if (!first) + { + sb.Append(sep); + } + + // d. Set first to false. + first = false; + + try + { + // e. If next is neither undefined nor null, set R to the string-concatenation of R + // and ? ToString(next), closing the iterator if the coercion is abrupt. + if (!value.IsNullOrUndefined()) + { + sb.Append(TypeConverter.ToString(value)); + } + + // Check constraints periodically so a huge (or native-backed) iterator cannot run + // uninterrupted; the catch closes the iterator. + if (++iterations % Engine.ConstraintCheckInterval == 0) + { + _engine.Constraints.Check(); + } + } + catch + { + iterated.Close(CompletionType.Throw); + throw; + } + } + + return sb.Length == 0 ? JsString.Empty : JsString.Create(sb.ToString()); + } + [JsSymbolFunction("Iterator", Length = 0, Flags = PropertyFlag.NonEnumerable)] private static JsValue ToIterator(JsValue thisObject) => thisObject; diff --git a/Jint/Runtime/Modules/CyclicModule.cs b/Jint/Runtime/Modules/CyclicModule.cs index e7160ebb97..9c63db94aa 100644 --- a/Jint/Runtime/Modules/CyclicModule.cs +++ b/Jint/Runtime/Modules/CyclicModule.cs @@ -676,6 +676,25 @@ m._evalError is not null || } } + /// + /// https://tc39.es/proposal-defer-import-eval/#sec-IsModuleSCCEvaluated + /// A module that finished its own body is only really done once the strongly connected component + /// it belongs to is: a member of an async cycle reaches EVALUATED as soon as its body returns, + /// while the cycle root is still EVALUATING-ASYNC awaiting a top-level await. Reading the member's + /// own status alone would report that graph as settled and let a deferred dependency of it run — + /// or be declared synchronously runnable — before the cycle has actually finished. + /// + private static bool IsModuleSCCEvaluated(CyclicModule module) + { + var cycleRoot = module._cycleRoot; + if (cycleRoot is not null) + { + return cycleRoot.Status == ModuleStatus.Evaluated; + } + + return module.Status == ModuleStatus.Evaluated; + } + /// /// https://tc39.es/proposal-defer-import-eval/#sec-ReadyForSyncExecution /// @@ -692,12 +711,15 @@ internal static bool ReadyForSyncExecution(Module module, HashSet seen = return true; } - if (cyclicModule.Status == ModuleStatus.Evaluated) + if (IsModuleSCCEvaluated(cyclicModule)) { return true; } - if (cyclicModule.Status is ModuleStatus.Evaluating or ModuleStatus.EvaluatingAsync) + // The spec asserts LINKED here, having ruled out EVALUATING and EVALUATING-ASYNC. EVALUATED is + // reachable too — a member of an async cycle whose root has not settled — and such a module + // cannot be completed synchronously either, so it is refused rather than asserted on. + if (cyclicModule.Status is ModuleStatus.Evaluating or ModuleStatus.EvaluatingAsync or ModuleStatus.Evaluated) { return false; } @@ -766,7 +788,7 @@ internal static void GatherAsynchronousTransitiveDependencies( return; } - if (cyclicModule.Status is ModuleStatus.Evaluating or ModuleStatus.EvaluatingAsync or ModuleStatus.Evaluated) + if (cyclicModule.Status == ModuleStatus.Evaluating || IsModuleSCCEvaluated(cyclicModule)) { return; } diff --git a/README.md b/README.md index 71d7605391..2a21f4f479 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ and many more. - ✔ Explicit Resource Management (`using` and `await using`) - ✔ Immutable Arraybuffers - ✔ Import Bytes (`import x from './file' with { type: 'bytes' }`) +- ✔ Iterator Join (`Iterator.prototype.join`) - ✔ Iterator Sequencing - ✔ Joint Iteration - ✔ JSON.parse source text access