diff --git a/changelog.d/10080-string-for-of-code-points.md b/changelog.d/10080-string-for-of-code-points.md new file mode 100644 index 0000000000..35d51b9b86 --- /dev/null +++ b/changelog.d/10080-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"); +});