From 6c222f2af9497cdb9432e83eb1b8de714504b064 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 16:28:44 +0200 Subject: [PATCH 1/2] fix(hir): preserve code points in typed string for-of --- .../10062-string-for-of-code-points.md | 10 ++ crates/perry-hir/src/lower/stmt_loops.rs | 15 +- crates/perry-hir/src/lower_decl/body_stmt.rs | 13 +- .../test_gap_string_for_of_code_points.ts | 134 ++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 changelog.d/10062-string-for-of-code-points.md create mode 100644 test-files/test_gap_string_for_of_code_points.ts diff --git a/changelog.d/10062-string-for-of-code-points.md b/changelog.d/10062-string-for-of-code-points.md new file mode 100644 index 0000000000..35d51b9b86 --- /dev/null +++ b/changelog.d/10062-string-for-of-code-points.md @@ -0,0 +1,10 @@ +Fix typed string `for...of` loops splitting astral characters into separate +surrogate iterations. Both function-body and module-initialization lowering +now convert strings to code-point arrays using the runtime string iterator's +existing WTF-8 conversion, preserving lone surrogates and the code-unit +semantics of bracket indexing and `charCodeAt`. + +Regression coverage checks every yielded code unit for typed and dynamic +strings, local and module-level loops, adjacent astral characters, lone +surrogates, empty/ASCII strings, assignment heads, `break`, `continue`, +`return`, and `for await...of`. diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index 8d498c4056..c0c43c4635 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -1387,8 +1387,8 @@ pub(super) fn lower_stmt_for_of_inner( // routes through the runtime default-iterator (`js_for_of_to_array`). // // We deliberately DON'T wrap the statically-resolved kinds handled - // above (Map/Set/typed-array via their own materializers, strings via - // the string index-loop, Headers/URLSearchParams via their entries + // above (Map/Set/typed-array via their own paths, strings via + // code-point materialization, Headers/URLSearchParams via their entries // rewrite) nor proven arrays — those keep their existing fast paths. let proven_array = match &iterable_type { Some(Type::Array(_)) => true, @@ -1472,6 +1472,11 @@ pub(super) fn lower_stmt_for_of_inner( } else if use_lazy_iter { // GetIterator(obj): obj[Symbol.iterator](). Drives the lazy loop below. Expr::GetIterator(Box::new(arr_expr)) + } else if is_string_iter { + // #10062: string indexing yields UTF-16 code units, while for-of + // yields code points. Materialize with the same WTF-8 conversion as + // the runtime string iterator, then index the resulting array. + Expr::ForOfToArray(Box::new(arr_expr)) } else { arr_expr }; @@ -1503,11 +1508,11 @@ pub(super) fn lower_stmt_for_of_inner( _ => Type::Any, } }; - // The __arr holder's type: String for string iteration, Map for + // The __arr holder's type: Array for materialized strings, Map for // the Map-fast-path so `__m.size` resolves through `is_map_expr`, // Array otherwise. - let arr_type = if is_string_iter { - Type::String + let arr_type = if is_string_iter && !use_lazy_iter { + Type::Array(Box::new(Type::String)) } else if map_kv_fastpath { Type::Generic { base: "Map".to_string(), diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 2441bd7651..5c675a03a3 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1646,12 +1646,17 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result holder so codegen + // indexes whole code points rather than UTF-16 code units. // For an identifier iterable like `for (const word of words)` where // `words: string[]`, extract the element type from the local's // declared Array so the loop variable gets the right type. @@ -1678,8 +1683,8 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { + let out = ""; + for await (const ch of s) { + if (ch === "\u00e4") continue; + out += describe(ch); + } + return out; +} +asyncTyped(mixed).then((out: string) => { + equal(out, "1:20013,2:55357:56832,1:214,"); + console.log("async string for-of code points: ok"); +}); From 066b226d24e158a755ea2c0d3df846c4f5a37387 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 16:29:08 +0200 Subject: [PATCH 2/2] chore: key string iteration changelog to PR 10080 --- ...g-for-of-code-points.md => 10080-string-for-of-code-points.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10062-string-for-of-code-points.md => 10080-string-for-of-code-points.md} (100%) diff --git a/changelog.d/10062-string-for-of-code-points.md b/changelog.d/10080-string-for-of-code-points.md similarity index 100% rename from changelog.d/10062-string-for-of-code-points.md rename to changelog.d/10080-string-for-of-code-points.md