Skip to content

Bun.sliceAnsi: detect end cut when final wide cluster overflows on lazy path - #34393

Closed
robobun wants to merge 3 commits into
mainfrom
farm/57007c25/sliceansi-wide-eof-cut
Closed

robobun wants to merge 3 commits into
mainfrom
farm/57007c25/sliceansi-wide-eof-cut

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

Bun.sliceAnsi("ab\u6F22", 0, 3, { ellipsis: "\u2026" })   // "ab\u6F22"  (4 cols, no ellipsis)
Bun.sliceAnsi("abcd",     0, 3, { ellipsis: "\u2026" })   // "ab\u2026"  (ASCII fast path: correct)
Bun.sliceAnsi("ab\u6F22", 0, -1, { ellipsis: "\u2026" })  // "ab\u2026"  (cutEndKnown path: correct)

\u6F22 is width 2 so "ab\u6F22" is 4 columns. Slicing [0, 3) with an ellipsis should produce "ab\u2026" (3 columns) on every path, but the lazy cutEnd path (positive finite indices, no pre-scan) returns the uncut 4-column string.

Cause

emitSliceStreaming detects the end cut inside processVisibleCp when a grapheme break lands at position >= specEnd. When the wide cluster is the last visible thing in the input, EOF follows directly and there is no next break to observe the overflow. The post-walk finalization bumps position by the last cluster's width but never re-evaluates sawCutEnd, so the speculative zone is flushed as "no cut" and the ellipsis is dropped.

Fix

After finalizing the last cluster's width in the post-walk block, when ellipsisEndBudget > 0 and the final position strictly exceeds specEnd, set sawCutEnd = true and skip the trailing flushPending: the pending ANSI at EOF sits after spec-zone content that is being discarded, and activeStyles.emitCloseCodes already re-closes after the ellipsis so the ellipsis inherits the active style (the documented SGR-inheritance contract, matching the cutEndKnown path). Strictly > keeps exact-fit (position == specEnd) resolving as no cut, and the ellipsisEndBudget > 0 gate leaves the no-ellipsis path byte-identical.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/util/sliceAnsi.test.ts -t "wide char overflowing end at EOF"
(fail) ... Expected: "ab\u2026"  Received: "ab\u6F22"

$ bun bd test test/js/bun/util/sliceAnsi.test.ts
 155 pass  0 fail

The new test covers the overflow case at several column offsets, a multi-column ellipsis budget, start > 0 (both-ellipsis) and its exact-fit negative, the 8-bit LChar instantiation via an ambiguous-width Latin-1 codepoint, SGR inheritance with and without an explicit trailing close, the exact-fit negatives, and equivalence with the negative-index and not-EOF paths.

Related: #34391 addresses the zero-width false positive for sawCutEnd (trailing LF at the boundary is not a cut). This PR is the converse false negative (overflowing wide cluster at EOF is a cut). The two changes touch adjacent but non-overlapping lines.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/sliceAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (07d9603ac)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [3.06ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [2.21ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [2.14ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty [1.29ms]
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [1.85ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [1.31ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [2.08ms]
(pass) Bun.sliceAnsi > plain strings > negative start [2.46ms]
(pass) Bun.sliceAns
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [0.09ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [0.03ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [0.04ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty [0.02ms]
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [0.02ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [0.02ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [0.03ms]
(pass) Bun.sliceAnsi > plain strings > negative start [0.04ms]
(pass) Bun.sliceAnsi > plain strings > negative end [0.03ms]
(pass) Bun.sliceAnsi > plain strings > both negative [0.02ms]
(pass) Bun.sliceAnsi > plain strings > single character slice [0.03ms]
(pass) Bun.sliceAnsi > ANSI color codes > slices colored text, preserving ANSI codes at start [0.06ms]
(pass) Bun.sliceAnsi > ANSI color codes > preserves active styles at slice start [0.02ms]
(pass) Bun.sliceAnsi > ANSI color codes > multipl
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/sliceAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (07d9603ac)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [3.83ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [2.43ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [1.91ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty [1.78ms]
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [1.89ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [1.29ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [2.14ms]
(pass) Bun.sliceAnsi > plain strings > negative start [2.17ms]
(pass) Bun.sliceAns
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     07d9603acb
  features     (none)

22 deps, 106 codegen, 1168 objects in 1080ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [49.00ms]
[2/1231] fetch zlib
[zlib] up to date
[3/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1231] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1231] gen .bind.ts → GeneratedBindings.cpp
[6/1231] gen ErrorCode+*.h
[7/1231] gen bindgenv2
[8/1231] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings
... (truncated)
diff hotspot
src/jsc/bindings/sliceAnsi.cpp     | 21 +++++++++++++-------
 test/js/bun/util/sliceAnsi.test.ts | 39 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 53 insertions(+), 7 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                reads  edits  tests
src/jsc/bindings/sliceAnsi.cpp          6      2      0
test/js/bun/util/sliceAnsi.test.ts      3      3      0

…zy path

When the last visible cluster in the input starts before the speculative
end boundary (specEnd) and its width extends past it, there is no
following grapheme break for the in-walk check to observe the overflow.
The post-walk finalization advanced position by the last cluster's width
but never re-evaluated sawCutEnd, so the speculative zone was flushed as
if the string fit and no ellipsis was emitted.

  Bun.sliceAnsi('ab\u6F22', 0, 3, {ellipsis:'\u2026'})  // 'ab\u6F22' (4 cols)
  Bun.sliceAnsi('abcd',     0, 3, {ellipsis:'\u2026'})  // 'ab\u2026' (ASCII fast path)
  Bun.sliceAnsi('ab\u6F22', 0, -1, {ellipsis:'\u2026'}) // 'ab\u2026' (cutEndKnown path)

Set sawCutEnd in the post-walk block whenever the finalized position
strictly exceeds specEnd, matching the ASCII fast path and the
negative-index (cutEndKnown) path.
@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 PM PT - Jul 16th, 2026

❌ @autofix-ci[bot], your commit 07d9603 has 1 failures in Build #74044 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34393

That installs a local version of the PR into your bun-34393 executable, so you can run:

bun-34393 --bun

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 577cecd7-41a2-4895-a317-bee2686fca21

📥 Commits

Reviewing files that changed from the base of the PR and between 6ad467f and 07d9603.

📒 Files selected for processing (2)
  • src/jsc/bindings/sliceAnsi.cpp
  • test/js/bun/util/sliceAnsi.test.ts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — one-line fix that correctly re-evaluates sawCutEnd after the final cluster's width is committed; the strict > and !endUnbounded guards keep exact-fit and unbounded-end behavior unchanged. The two inline notes are pre-existing spec-zone divergences, not introduced here.

Extended reasoning...

Overview

Single functional line added to emitSliceStreaming in src/jsc/bindings/sliceAnsi.cpp: after the post-walk position += gs.width() finalization, set sawCutEnd = true when !endUnbounded && position > specEnd. Accompanied by a 15-assertion test in test/js/bun/util/sliceAnsi.test.ts covering the overflow case, exact-fit negatives, multi-col ellipsis, SGR inheritance, and equivalence with the negative-index / not-at-EOF sibling paths.

Security risks

None. Pure display-width string formatting; no new external input surface, allocation sizing, or FFI boundary.

Level of scrutiny

Low-to-medium. Bun.sliceAnsi is a non-critical utility API (terminal output truncation). The change is a one-line boolean assignment in a post-walk finalization block whose only downstream consumer is the if (ellipsisEndBudget > 0) speculative-zone resolver — so on every path where ellipsisEndBudget == 0 (no ellipsis, cutEndKnown, degenerate budget) the new assignment is a no-op. The !endUnbounded guard mirrors the in-walk check at :969, and strict > matches the ASCII fast path's cutEnd semantics (end < totalW). I traced the exact-fit case (position == specEnd → sawCutEnd stays false → specZone flushed, no ellipsis) and the trailing-ANSI interaction (trailingPastEnd already uses >=, so flushPending filtering is unchanged when the new line fires).

Other factors

The two inline findings are both explicitly pre-existing (flushPending targeting result from inside the spec zone; the degenerate ellipsisWidth >= end - start fallback gated on cutEndKnown) — cosmetic edge-case divergences worth a follow-up but not caused or worsened by this change. Test coverage is thorough, includes negative cases, and the PR verified fail-before/pass-after with USE_SYSTEM_BUN=1 vs bun bd on the full 155-test file.

Comment thread src/jsc/bindings/sliceAnsi.cpp Outdated
Comment thread src/jsc/bindings/sliceAnsi.cpp Outdated
robobun and others added 2 commits July 16, 2026 19:24
…nd LChar

When the post-walk overflow check confirms the end cut, drop the trailing
pending ANSI instead of flushing it to result before the ellipsis;
activeStyles.emitCloseCodes re-closes after the ellipsis so it inherits
the active style, matching the cutEndKnown path and the existing 'SGR
style inheritance' contract. The no-ellipsis path (ellipsisEndBudget==0)
takes the else branch and is byte-identical to before.

Tests now also cover start>0 (both-ellipsis lazy overflow and its exact-
fit negative), the LChar template instantiation via an ambiguous Latin-1
codepoint, and lazy==negative equivalence with an explicit trailing close.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted fix aligning the lazy-cutEnd path with its siblings, with thorough variant coverage.

Checked: the ellipsisEndBudget > 0 gate implies !endUnbounded (only set on that branch), so dropping the old guard is safe and the no-ellipsis path is byte-identical. Strict > keeps exact-fit as no-cut (falls through to the original trailingPastEnd flush). Skipping flushPending on the new cut is right — trailing pending sits after discarded spec-zone content and emitCloseCodes re-closes for the ellipsis. Traced the SGR, start>0, and LChar test cases through the walk; all resolve as asserted. The two pre-existing divergences noted last pass are acknowledged out-of-scope.

Extended reasoning...

Overview

7-line logic change in emitSliceStreaming's post-walk EOF finalization block (src/jsc/bindings/sliceAnsi.cpp): after adding the last cluster's width to position, if the lazy speculative-ellipsis path is active (ellipsisEndBudget > 0) and position > specEnd, set sawCutEnd = true and skip the trailing flushPending; otherwise run the original code unchanged. Plus a 39-line test block covering the overflow at several offsets, multi-col ellipsis, start > 0 (both-ellipsis + its exact-fit negative), the 8-bit LChar instantiation via an ambiguous-width Latin-1 codepoint, SGR inheritance with and without an explicit trailing close, exact-fit negatives, and equivalence with the negative-index and not-EOF paths.

Since my previous pass (on fbba1c1), 796db3e tightened the guard from !endUnbounded to ellipsisEndBudget > 0 and moved flushPending into the else branch so it's skipped when the post-walk cut fires — both correct refinements.

Security risks

None. Bun.sliceAnsi is a pure string-in/string-out display utility. No I/O, no allocation-size arithmetic driven by the changed lines, no user callbacks, no JSC exception surface touched. The added condition reads locals only.

Level of scrutiny

Low-medium. Small, self-contained bug fix in a non-critical utility API; the else branch preserves the prior code verbatim so the no-ellipsis path is provably unchanged. The PR includes fails-without-fix / passes-with-fix evidence for both ASAN-debug and release, and the test suite (155 pass) exercises the surrounding code. I hand-traced the primary repro, the red + "ab漢" + reset SGR case, the start>0 case, its exact-fit negative, and the LChar case through the state machine — each resolves to the asserted output.

Other factors

  • ellipsisEndBudget is only ever set inside the !cutEndKnown && !endUnbounded branch, so ellipsisEndBudget > 0 ⇒ !endUnbounded; the guard change is a strict narrowing.
  • Skipping flushPending when the cut fires is correct: any pending ANSI at EOF sits after spec-zone content that is being discarded, and activeStyles.emitCloseCodes (which runs unconditionally after the ellipsis) re-closes so the ellipsis inherits style — matching the cutEndKnown sibling path and covered by the two SGR assertions in the new test.
  • The two 🟣 pre-existing notes from my earlier pass (flushPending routing to result vs specZone; degenerate ellipsisWidth >= end-start on the lazy path) were resolved as out-of-scope / tracked in #34391 — neither is introduced or worsened here.
  • No CODEOWNERS entry for these paths; no unaddressed human review comments.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI: 285/286 jobs passed. The one red lane (debian 13 x64-asan) is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js hitting a JSC getOwnPropertyDescriptor exception-scope assertion, unrelated to this change and being handled separately. sliceAnsi.test.ts and sliceAnsi-fuzz.test.ts are green on every lane. Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Folded into #33488 as a subset of the #34391 change (same EOF wide-cluster cut detection); this PR's SGR-styled-ellipsis and parity assertions were kept as tests.

Jarred-Sumner added a commit that referenced this pull request Jul 17, 2026
…iceAnsi and wrapAnsi (#33488)

`Bun.stringWidth`, `Bun.stripANSI`, `Bun.sliceAnsi` and `Bun.wrapAnsi`
each carried their own escape recognizer, and the four disagreed on what
a terminal would display. This consolidates them onto the shared grammar
in `ANSIHelpers.h` and fixes the width / SGR / wrapping bugs that a
terminal UI hits.

Closes #34384, closes #34381, closes #34379, closes #34380, closes
#34377, closes #34382, closes #34391, closes #34393, closes #34394.

## Escape grammar (all four APIs)

| Form | before | after |
|---|---|---|
| CSI `ESC [` … `<final 0x40–0x7E>` | ✓ (width only) | ✓ |
| C1 CSI `0x9B` … | width counted `31m` as text | zero-width |
| OSC `ESC ]` / C1 `0x9D` … (BEL / `ESC \` / `0x9C`) | `0x9D`
unrecognized | ✓ |
| DCS/SOS/PM/APC `ESC P/X/^/_`, C1 `0x90/0x98/0x9E/0x9F` … ST | payload
counted as visible | zero-width |
| nF `ESC <0x20–2F> <byte>` (`ESC ( B`), Fe/Fs `ESC <0x30–7E>` (`ESC 7`,
`ESC c`) | `(B0`, `7hi8` visible | zero-width |
| ESC / CAN / SUB / C1 ST *inside* a sequence | swallowed following text
(`stripANSI("\x1b]0;title\x1b[31mtext")` → `""`) | aborts the sequence
(VT500): `"text"` |

`stringWidth(s) === stringWidth(stripANSI(s))` and agreement with
`sliceAnsi`/`wrapAnsi` now hold on all of the above, on Latin-1, UTF-16
and (via `inspect.table` / the markdown renderer) UTF-8 strings —
previously a JS string could measure differently before and after a wide
character forced it to UTF-16.

## Repro (release, before → after)

```js
Bun.stringWidth("\x9B31mhi\x9B39m")                    // 8 → 2
Bun.stringWidth("a\x1bP+q544e\x1b\\b")                 // 10 → 2
Bun.stringWidth("hi\x1b7\x1b8")                        // 4 → 2
Bun.stripANSI("text\x1b[3\x1b[0mmore")                 // "text0mmore" → "textmore"
Bun.stripANSI("\x1b]0;title\x1b[31mtext")              // "" → "text"
Bun.sliceAnsi("\x1b[1m\x1b[2mLoading deps\x1b[22m", 0, 10)   // bold lost → "\x1b[1m\x1b[2mLoading de\x1b[22m"
Bun.sliceAnsi("\x1b[4m\x1b[58;5;196mERROR\x1b[59m\x1b[24m", 0, 5)   // injected blink (SGR 5) → underline-color preserved
Bun.sliceAnsi("ab漢", 0, 3, { ellipsis: "…" })          // "ab漢" (4 cols) → "ab…"
Bun.sliceAnsi("ЖЗИ", 1, 4, { ellipsis: ">>" })         // "" → ">>"
Bun.sliceAnsi("\x1b[31mabcd\x1b[39m", 0, 4, { ellipsis: "…" })   // reordered ANSI / spurious ellipsis → "\x1b[31mabcd\x1b[39m"
Bun.wrapAnsi("x\x1b(0lqqqqqqqqqqk\x1b(B", 6, { hard: true })       // one width-13 row → wraps at 6
Bun.wrapAnsi("\x1b]8;;http://x/" + " word".repeat(1000), 40)      // quadratic output growth → linear
Bun.stringWidth("السَّلَامُ عَلَيْكُمْ")                      // 21 → 12 (Mn marks zero-width)
Bun.stringWidth("한국어.txt".normalize("NFD"))          // 15 → 10
Bun.stringWidth("café", { ambiguousIsNarrow: false }) // 4 → 5 (Latin-1 path honored the flag only after a UTF-16 force)
Bun.stringWidth("🫩")                                   // 1 → 2 (Unicode 16/17 EAW)
```

## Fix

| Layer | change |
|---|---|
| `ANSIHelpers.h` `consumeANSI()` | full grammar + VT500
anywhere-transitions (ESC re-introduces, CAN/SUB/ST abort); a `Utf8`
mode where C1 = `0xC2 0x9x` and ST = `0xC2 0x9C` (a bare `0x9C` is a
continuation byte); `sgrCloseCode`/`isSgrEndCode` gain
20/21/51/52/58/73/74 |
| `stringWidth.cpp` | UTF-8 and UTF-16 paths drive `consumeANSI`;
Latin-1 `ambiguousIsNarrow: false` path; VS16 widens only Emoji-property
bases |
| `highway_strings.cpp` Latin-1 kernel | `origin/main`'s single-pass
loop kept; the fast-path gate also fails on `0x7F–0x9F`; a chunk
containing any non-CSI/OSC introducer exits to the scalar recognizer (a
call inside the loop spilled the caller-saved vector registers onto the
fast path) |
| `stringWidthTables.h` | regenerated from Unicode 17.0 UCD (was EAW
15.1 + emoji 17.0 + a hand-picked Mn whitelist): all Mn/Me zero-width,
jamo V/T zero-width, new emoji wide |
| `sliceAnsi.cpp` SGR state | keyed by attribute slot (bold+dim,
italic+fraktur, single+double underline share closes but stack), 58/59
as an extended color, empty param = 0, abort bytes match the recognizer
|
| `sliceAnsi.cpp` ellipsis | spec-zone content written in place with an
SGR/hyperlink snapshot restored on discard (keeps ANSI ordered);
wide-cluster EOF overflow is a cut; trailing zero-width clusters are
not; a range admitting no visible content returns just the ellipsis; the
ellipsis inherits the active style |
| `wrapAnsi.cpp` | tokenizer walks with `consumeANSI` (no more "inside
escape until an `m`"), OSC-8 body never split (ST terminator + `id=`
params, re-opened per row), `trim`/`wordWrap` honor `!== false` only,
zero-width tail chars kept when trimming; a bare `\r` still breaks a
line as before ( `wrap-text` maps wrapped output back onto the source by
relying on it) |
| `stripANSI.cpp` | no-op inputs ending in a stray `0x9C` keep zero-copy
identity |

## Performance

Agent shaped input (the workload these exist for: 21 real TUI lines,
3–139 code units, per-call, `origin/main` vs this branch, local release,
min of 3):

| call | main | this PR |
|---|---|---|
| `stringWidth(s)` | 175 ns | **159 ns** |
| `sliceAnsi(s, 0, 40)` | 398 ns | 412 ns |
| `wrapAnsi(s, 80)` | 997 ns | **789 ns** |
| `wrapAnsi(s, 40, {hard:true})` | 1076 ns | 964 ns |
| `stripANSI(s)` | 37 ns | 37 ns |
| `stringWidth(s, {countAnsiEscapeCodes:true})` | 194 ns | 193 ns |

Long single strings (4 KB–64 KB per call), where the SIMD kernels
dominate:

| shape | main | this PR |
|---|---|---|
| ASCII, no escapes, ANSI kernel | 20.6 B/ns | 18.4 B/ns |
| dense SGR (11 KB) | 3.28 µs | 4.12 µs |
| truecolor lines (9.4 KB) | 1.30 µs | 1.72 µs |
| OSC-8 links (12.6 KB) | 3.40 µs | 3.97 µs |
| UTF-16 SGR every 15 units (7.2 KB) | 7.86 µs | 9.38 µs |
| UTF-16 dense SGR + one wide char (11 KB) | 18.7 µs | 21.8 µs |

The long-string escape shapes are 15–30 % behind `origin/main`: the
per-chunk cold path re-classifies the C1 range and the grammar-complete
terminator sets. Growth is linear in every case (per-line cost on a
1600-line colored UTF-16 log: 0.024 µs/line, same as main). Short
strings, which is nearly all real callers, are faster than `main`.

## Verification

```
$ bun bd test test/js/bun/util/{stringWidth,stripANSI,sliceAnsi,sliceAnsi-fuzz,wrapAnsi}.test.ts \
              test/js/bun/console/{bun-inspect-table,console-table}.test.ts
 997 pass  0 fail        # was 918 before this PR's test additions
$ bun bd test test/js/bun/md
 1065 pass  0 fail
$ USE_SYSTEM_BUN=1 bun test test/js/bun/util/<file>.test.ts   # canary, without the fix
 stringWidth 33 fail · wrapAnsi 39 fail · stripANSI 10 fail · sliceAnsi 14 fail   (96 total)
```

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 5 · 18 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 94 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/sliceAnsi-fuzz.test.ts test/js/bun/util/sliceAnsi.test.ts test/js/bun/util/stringWidth.test.ts test/js/bun/util/stripANSI.test.ts test/js/bun/util/wrapAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (62b75ff)

test/js/bun/util/sliceAnsi-fuzz.test.ts:
(pass) sliceAnsi invariants > output width never exceeds requested range [983.76ms]
(pass) sliceAnsi invariants > slice of stripped equals stripped slice (for 1-width chars) [676.01ms]
(pass) sliceAnsi invariants > adjacent slices cover full visible string [342.56ms]
(pass) sliceAnsi invariants > output is always well-formed UTF-16 [764.16ms]
(pass) sliceAnsi invariants > full slice preserves visible content [272.68ms]
(pass) sliceAnsi invariants > slicing a slice is idempotent on visible content [314.49ms]
(pass) sliceAnsi invariants >
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (4f78471)

test/js/bun/util/sliceAnsi-fuzz.test.ts:
(pass) sliceAnsi invariants > output width never exceeds requested range [5.43ms]
(pass) sliceAnsi invariants > slice of stripped equals stripped slice (for 1-width chars) [2.84ms]
(pass) sliceAnsi invariants > adjacent slices cover full visible string [1.52ms]
(pass) sliceAnsi invariants > output is always well-formed UTF-16 [3.87ms]
(pass) sliceAnsi invariants > full slice preserves visible content [1.47ms]
(pass) sliceAnsi invariants > slicing a slice is idempotent on visible content [1.55ms]
(pass) sliceAnsi invariants > ellipsis output width respects budget [2.53ms]
(pass) sliceAnsi adversarial > inputs near SIMD stride boundaries [0.21ms]
(pass) sliceAnsi adversarial > C1 ST at SIMD boundary positions [0.07ms]
(pass) sliceAnsi adversarial > unterminated CSI sequences don't hang or overread [0.09ms]
(pass) sliceAnsi adversarial > many SGR codes don't overflow or quadratic-slow [66.02ms]
(pass) sliceAnsi adversarial > huge SGR params don't overflow uint32 [0.08ms]
(pass) sliceAnsi adversarial > SGR with many parameters [0.07ms]
(pass) sliceAnsi adversarial > string of only zero-width 
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/sliceAnsi-fuzz.test.ts test/js/bun/util/sliceAnsi.test.ts test/js/bun/util/stringWidth.test.ts test/js/bun/util/stripANSI.test.ts test/js/bun/util/wrapAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (62b75ff)

test/js/bun/util/sliceAnsi-fuzz.test.ts:
(pass) sliceAnsi invariants > output width never exceeds requested range [869.07ms]
(pass) sliceAnsi invariants > slice of stripped equals stripped slice (for 1-width chars) [580.96ms]
(pass) sliceAnsi invariants > adjacent slices cover full visible string [333.91ms]
(pass) sliceAnsi invariants > output is always well-formed UTF-16 [739.83ms]
(pass) sliceAnsi invariants > full slice preserves visible content [268.37ms]
(pass) sliceAnsi invariants > slicing a slice is idempotent on visible content [307.68ms]
(pass) sliceAnsi invariants >
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 753ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/9] gen cpp.rs (cppbind)
[2/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/wor
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
docs/runtime/utils.mdx                   |   10 +-
 packages/bun-types/bun.d.ts              |    3 +-
 scripts/generate-stringwidth-tables.mjs  |  185 ++--
 src/bun_core/string/immutable/visible.rs |   16 +-
 src/bun_core/string/mod.rs               |    2 +-
 src/jsc/bindings/ANSIHelpers.h           |  224 +++--
 src/jsc/bindings/highway_strings.cpp     |  395 +++++----
 src/jsc/bindings/sliceAnsi.cpp           |  358 +++++---
 src/jsc/bindings/stringWidth.cpp         |  568 ++++++------
 src/jsc/bindings/stringWidth.h           |    4 +
 src/jsc/bindings/stringWidthTables.h     | 1387 ++++++++----------------------
 src/jsc/bindings/stripANSI.cpp           |    5 +
 src/jsc/bindings/wrapAnsi.cpp            |  631 +++++++-------
 test/js/bun/util/sliceAnsi-fuzz.test.ts  |   43 +-
 test/js/bun/util/sliceAnsi.test.ts       |  316 ++++++-
 test/js/bun/util/stringWidth.test.ts     |  711 +++++++++++++--
 test/js/bun/util/stripANSI.test.ts       |   52 +-
 test/js/bun/util/wrapAnsi.test.ts        |  254 ++++++
 18 files changed, 2963 insertions(+), 2201 deletions(-)
```

</details>

**gate history** · 3 passed · 1 rejected · iteration 5

<details><summary>evidence per changed file</summary>

```
file                                      reads  edits  tests
docs/runtime/utils.mdx                        1      2      0
packages/bun-types/bun.d.ts                   1      1      0
scripts/generate-stringwidth-tables.mjs       1      2      0
src/bun_core/string/immutable/visible.rs      0      0      0
src/bun_core/string/mod.rs                    0      0      0
src/jsc/bindings/ANSIHelpers.h                4      6      0
src/jsc/bindings/highway_strings.cpp         12      7      0
src/jsc/bindings/sliceAnsi.cpp                3      2      0
src/jsc/bindings/stringWidth.cpp              6     22      0
src/jsc/bindings/stringWidth.h                0      0      0
src/jsc/bindings/stringWidthTables.h          0      0      0
src/jsc/bindings/stripANSI.cpp                1      0      0
src/jsc/bindings/wrapAnsi.cpp                 1      1      0
test/js/bun/util/sliceAnsi-fuzz.test.ts       1      2      0
test/js/bun/util/sliceAnsi.test.ts            1      2      0
test/js/bun/util/stringWidth.test.ts          2     16      0
(+ 2 more files)
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants