-
-
Notifications
You must be signed in to change notification settings - Fork 148
fix(string): re-pair split surrogates on += append (node parity, #6741)
#6742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
proggeramlug
merged 1 commit into
PerryTS:main
from
proggeramlug:fix/string-pluseq-surrogate-repair
Jul 22, 2026
+137
β2
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| #!/bin/bash | ||
| # Regression: `+=` string append must re-pair a split UTF-16 surrogate the same | ||
| # way expression concat and Node do. Found while testing pi #6728: pi's | ||
| # `visibleWidth` strips ANSI by rebuilding a string one code unit at a time | ||
| # (`stripped += clean[i]`), which splits an emoji's surrogate pair across two | ||
| # `+=` appends. perry's append path used to copy the two lone 3-byte WTF-8 | ||
| # surrogates verbatim instead of coalescing them into the astral char's 4-byte | ||
| # UTF-8 β so `[...s].length`/`codePointAt` disagreed with Node, and pi's TUI | ||
| # width invariant aborted on any emoji in a colored line. | ||
| # | ||
| # This is a differential test: perry's output must be byte-identical to Node's. | ||
|
|
||
| set -e | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
| PERRY="$SCRIPT_DIR/../target/release/perry" | ||
| [ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" | ||
| if [ ! -f "$PERRY" ]; then | ||
| echo "SKIP: perry binary not found (build with cargo build --release)" | ||
| exit 0 | ||
| fi | ||
| if ! command -v node >/dev/null 2>&1; then | ||
| echo "SKIP: node not found (differential test needs node)" | ||
| exit 0 | ||
| fi | ||
|
|
||
| TMPDIR=$(mktemp -d) | ||
| trap "rm -rf $TMPDIR" EXIT | ||
|
|
||
| COMPILE_ENV=() | ||
| if [ -f "$SCRIPT_DIR/../target/debug/libperry_runtime.a" ] || [ -f "$SCRIPT_DIR/../target/release/libperry_runtime.a" ]; then | ||
| COMPILE_ENV=(env PERRY_NO_AUTO_OPTIMIZE=1) | ||
| fi | ||
|
|
||
| cat > "$TMPDIR/main.ts" << 'EOF' | ||
| function show(label: string, s: string): void { | ||
| console.log(label + " len=" + s.length + " cp=" + [...s].length + " cp0=" + s.codePointAt(0) + " eq="); | ||
| } | ||
| const e = "aπb"; // real astral char (UTF-8 in source) | ||
| const hi = e[1], lo = e[2]; // lone high + low surrogate from indexing | ||
|
|
||
| // 1. The bug: incremental += of the two halves must re-pair. | ||
| let Y = ""; Y += hi; Y += lo; | ||
| show("pluseq", Y); | ||
| console.log("pluseq-eq", Y === "π", (hi + lo) === Y); | ||
|
|
||
| // 2. pi's ANSI-strip pattern: rebuild a string one code unit at a time. | ||
| let out = ""; | ||
| for (let i = 0; i < e.length; i++) out += e[i]; | ||
| console.log("rebuild", out === e, [...out].length); | ||
|
|
||
| // 3. Multiple emoji, some with a leading ASCII run (exercises both branches). | ||
| const t = "xπyπzπw"; | ||
| let r = ""; | ||
| for (let i = 0; i < t.length; i++) r += t[i]; | ||
| console.log("multi", r === t, [...r].length, r.codePointAt(1)); | ||
|
|
||
| // 4. A genuinely lone surrogate must STAY lone (no false merge). | ||
| let L = ""; L += hi; L += "Z"; | ||
| console.log("lone", [...L].length, L.codePointAt(0), L.length); | ||
|
|
||
| // 5. ASCII fast path is unaffected (and stays correct at scale). | ||
| let A = ""; | ||
| for (let i = 0; i < 500; i++) A += "aΓ©"; // 1 ascii + 1 two-byte utf8 | ||
| console.log("bulk", A.length, [...A].length); | ||
|
|
||
| // 6. Emoji halves split by an unrelated append in between (no false pair). | ||
| let M = ""; M += hi; M += "-"; M += lo; | ||
| console.log("split", [...M].length, M.codePointAt(0)); | ||
| EOF | ||
|
|
||
| cd "$TMPDIR" | ||
| "${COMPILE_ENV[@]}" "$PERRY" compile main.ts --output test_bin --no-cache >/dev/null 2>&1 | ||
| PERRY_OUT=$(./test_bin 2>&1) | ||
| NODE_OUT=$(node main.ts 2>&1) | ||
|
|
||
| if [ "$PERRY_OUT" = "$NODE_OUT" ]; then | ||
| echo "PASS" | ||
| exit 0 | ||
| fi | ||
|
|
||
| echo "FAIL: perry output diverged from node (surrogate re-pairing on +=)" | ||
| echo "--- node ---"; echo "$NODE_OUT" | ||
| echo "--- perry ---"; echo "$PERRY_OUT" | ||
| diff <(echo "$NODE_OUT") <(echo "$PERRY_OUT") || true | ||
| exit 1 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π― Functional Correctness | π‘ Minor | β‘ Quick win
Cover surrogate repair in the fresh-allocation path.
multirepairs pairs while capacity remains available;bulkreallocates but has no surrogate boundary. Add a boundary pair that forces allocation, or regressions in lines 156-159 will pass unnoticed.Proposed regression case
// 3. Multiple emoji, some with a leading ASCII run (exercises both branches). const t = "xπyπzπw"; let r = ""; for (let i = 0; i < t.length; i++) r += t[i]; console.log("multi", r === t, [...r].length, r.codePointAt(1)); +// 3b. Fill the 32-byte append buffer, then form a pair across a fresh allocation. +let fresh = ""; +for (let i = 0; i < 29; i++) fresh += "a"; +fresh += hi; // 29 ASCII bytes + 3-byte WTF-8 high surrogate. +fresh += lo; // Forces allocation and must re-pair at the boundary. +console.log("fresh", fresh.length, [...fresh].length, fresh.codePointAt(29)); + // 4. A genuinely lone surrogate must STAY lone (no false merge).π Committable suggestion
π€ Prompt for AI Agents