From 71e118be7be32bd09c7d3a935155b3b8765e1086 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 21:42:23 -0500 Subject: [PATCH] Count loops bounded by a string's length in integers A counted for loop needs a stable limit to run its counter in integer storage. Array lengths qualified when the loop provably keeps them, but a string's length did not, so `for (let i = 0; i < s.length; i++)` kept a double counter and evaluated the length, through the UTF-16 mapping of a non-ASCII string, on every iteration. A string's length cannot change, so it is now stable whenever the string's binding is not rebound in the loop or boxed. --- .../compiler/src/ir/integer-loops.test.ts | 39 +++++++++++++++++++ packages/compiler/src/ir/integer-loops.ts | 7 ++++ 2 files changed, 46 insertions(+) diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 75968b262..aada3b053 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -209,6 +209,45 @@ test("a string read at the counter is an integer consumer", () => { expect(matchIntegerCountedForLoop(s, withText)).toBeNull(); }); +test("a string length bounds a counted loop while its binding is unchanged", () => { + const withText = new Map(locals); + withText.set("s", { id: "s", name: "s", type: STRING, mutable: true }); + const text = (): IrExpr => ({ kind: "varRef", localId: "s", type: STRING, loc }); + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: text(), + args: [], + type: F64, + loc, + }; + const s = counted(length); + s.body = [ + { + kind: "exprStmt", + expr: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver: text(), + args: [ref("i")], + type: F64, + loc, + }, + loc, + }, + ]; + expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i"); + // Rebinding the string in the body changes the length the condition reads. + const rebinding = { + ...s, + body: [...s.body, { kind: "assign", localId: "s", value: text(), loc } as IrStmt], + }; + expect(matchIntegerCountedForLoop(rebinding, withText)).toBeNull(); + const boxed = new Map(withText); + boxed.set("s", { ...withText.get("s")!, boxed: true }); + expect(matchIntegerCountedForLoop(s, boxed)).toBeNull(); +}); + test("a stable array length bounds a counted loop; a resizing body keeps it a condition", () => { const ARR = arrayOf(F64); const array = (localId: string): IrExpr => ({ kind: "varRef", localId, type: ARR, loc }); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 3ba186376..9b4675685 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -83,6 +83,13 @@ function stableLoopValue( stableLoopValue(value.left, locals, body, invariant, counter, stableLength) && stableLoopValue(value.right, locals, body, invariant, counter, stableLength) ); + case "strIntrinsic": + // A string's length is fixed: stable while its binding is. + return ( + value.method === "length" && + value.receiver.kind === "varRef" && + stableLoopValue(value.receiver, locals, body, invariant, counter) + ); case "bytesIntrinsic": case "arrIntrinsic": return (