Skip to content

Consolidate the ANSI escape grammar across stringWidth, stripANSI, sliceAnsi and wrapAnsi - #33488

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/566b3643/stringwidth-ansi-grammar
Jul 17, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/566b3643/stringwidth-ansi-grammar

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

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)

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)

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

fails on main (without fix)
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 (62b75ffb7)

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 (4f78471c4)

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)
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-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 (62b75ffb7)

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)
diff hotspot
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(-)

gate history · 3 passed · 1 rejected · iteration 5

evidence per changed file
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)

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 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: f7b5d3bf-878e-4ee0-9c65-e8863b41f093

📥 Commits

Reviewing files that changed from the base of the PR and between 2ca6ccb and 4f78471.

📒 Files selected for processing (1)
  • test/js/bun/util/stringWidth.test.ts

Walkthrough

Changes

The PR regenerates Unicode width tables, expands ANSI parsing for C1/UTF-8/control-string forms, propagates Latin-1 ambiguous-width handling, refactors slicing and wrapping state management, and adds regression coverage for width, escape, SGR, hyperlink, trimming, and ellipsis behavior.

Changes

String width and ANSI behavior

Layer / File(s) Summary
Unicode width table regeneration
scripts/generate-stringwidth-tables.mjs, src/jsc/bindings/stringWidthTables.h
Unicode UCD data now drives width and zero-width classification, with the packed lookup header regenerated from the resulting tables.
Width computation and contracts
src/jsc/bindings/stringWidth.*, src/jsc/bindings/highway_strings.cpp, src/bun_core/string/...
Latin-1 ambiguous-width handling, UTF-8/UTF-16 ANSI exclusion, grapheme widths, native FFI forwarding, and SIMD escape scanning are updated.
ANSI parsing, slicing, and stripping
src/jsc/bindings/ANSIHelpers.h, src/jsc/bindings/sliceAnsi.cpp, src/jsc/bindings/stripANSI.cpp, test/js/bun/util/{stringWidth,sliceAnsi,stripANSI}*
C1 controls, ST termination, abort behavior, SGR state, non-CSI escapes, speculative ellipsis state, and no-op stripping behavior are implemented and tested.
ANSI-aware wrapping
src/jsc/bindings/wrapAnsi.cpp, test/js/bun/util/wrapAnsi.test.ts, docs/runtime/utils.mdx, packages/bun-types/bun.d.ts
Wrapping, trimming, hyperlink/style preservation, newline handling, option defaults, long-input behavior, and ANSI-preservation documentation are updated.

Possibly related PRs

  • oven-sh/bun#34376: Updates the same ANSI row-joining and style-preservation flow in wrapAnsi.cpp.
  • oven-sh/bun#34377: Updates the same trailing-space trimming behavior for zero-width content.
  • oven-sh/bun#34379: Updates the same C1 CSI recognition and preservation path in wrapAnsi.cpp.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code and tests cover the linked ANSI grammar, width, wrapping, slicing, and trimming objectives for #34384, #34381, #34379, #34380, #34377, #34382, #34391, #34393, and #34394.
Out of Scope Changes check ✅ Passed The changes stay within the ANSI/string-width scope; the docs, table regeneration, and test updates all support the linked fixes.
Title check ✅ Passed The title clearly summarizes the main change: consolidating ANSI escape grammar across the four string APIs.
Description check ✅ Passed The description covers what changed and how it was verified, including the consolidated grammar, fixes, benchmarks, and test results.

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Jul 16th, 2026

@Jarred-Sumner, your commit 62b75ff is building: #74273

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Fix failing test in Bun.stringWidth #8329 - Directly about Bun.stringWidth being disabled due to a failing test; this PR fixes the ANSI escape recognition that was causing incorrect width calculations.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #8329

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Found 1 issue this PR may fix: #8329

Checked, and this PR doesn't fix it — #8329 is stale. It was filed about a test.todo("leading non-ansi characters in UTF-16 string seems to fail") block, which no longer exists in stringWidth.test.ts, and its three cases already match string-width on released Bun:

$ bun -e 'for (const s of ["\x1b[31mhshh🌎","a\x1b[31mhshh🌎","a\x1b[31mhshh🌎a"]) console.log(JSON.stringify(s), Bun.stringWidth(s))'
"\u001b[31mhshh🌎" 6
"a\u001b[31mhshh🌎" 7
"a\u001b[31mhshh🌎a" 8

(string-width@7.2.0 gives 6 / 7 / 8.) So the underlying bug was fixed a while back and the issue just never got closed. Not adding Fixes #8329.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmarked the Latin-1 kernel I'd replaced, and the first cut was 2.5-3.6x slower on escape-heavy input, which is exactly what the single-pass kernel exists to prevent. Pushed a second commit that keeps it.

What changed: CSI and OSC still come straight off the chunk bitmasks. Only the forms the kernel didn't know about (two-byte, nF, ST-terminated control strings, an ESC that re-introduces a sequence) go through a scalar mirror of the recognizer. The escape block moved out of line — inlining it into the chunk loop costs the no-escape path ~40% of its throughput.

Along the way the fuzz cross-check against the scalar reference caught a real bug: ESC ] ESC BEL used to terminate the OSC on the BEL, but consumeANSI swallows the byte after an ESC that isn't followed by a backslash. Both now agree.

Bun.stringWidth throughput

Both from-scratch release builds, min of 6 runs × 12 batches (the box is noisy; min is the robust estimator):

case base this PR
no-ANSI 16KB latin1 0.336 us 0.352 us same
no-ANSI 16KB latin1 (é) 0.336 us 0.353 us same
no-ANSI utf16 ascii 16K 0.326 us 0.347 us same
no-ANSI utf16 CJK 8K 1.288 us 1.289 us same
short line (typical TUI) 0.055 us 0.058 us same
dense SGR 16KB 8.839 us 10.168 us 15% slower
truecolor SGR 16KB 5.902 us 7.429 us 26% slower
OSC-8 hyperlinks 16KB 3.509 us 4.474 us 28% slower
utf16 CJK + SGR 40.278 us 32.905 us 18% faster

Every realistic call shape is at parity. The escape-dense 16KB rows pay one out-of-line call per chunk that contains an escape; the UTF-16 path gets faster because the old per-code-unit escape state machine is gone.

benchmark script
const latin1 = (s, n) => Buffer.alloc(s.length * n, s, "latin1").toString("latin1");
const utf16 = (s, n) => { let o = ""; for (let i = 0; i < n; i++) o += s; return ("\u{1F600}" + o).slice(2); };
const cases = [
  ["no-ANSI 16KB latin1", latin1("a", 16384), 2000],
  ["dense SGR (bash prompt)", latin1("\x1b[31mword\x1b[0m \x1b[32mword\x1b[0m \x1b[33mword\x1b[0m", 400), 2000],
  ["truecolor SGR", latin1("\x1b[38;2;255;128;64mhello\x1b[39m", 800), 2000],
  ["OSC-8 hyperlinks", latin1("\x1b]8;;https://example.com/xyz\x07click here\x1b]8;;\x07 ", 400), 2000],
  ["short line (typical TUI)", "\x1b[1;32mok\x1b[0m  \x1b[2mpackages installed\x1b[0m", 200000],
  ["utf16 CJK + SGR", utf16("\x1b[31m安宁\x1b[0m hello ", 500), 2000],
];
for (const [name, s, iters] of cases) {
  for (let i = 0; i < 500; i++) Bun.stringWidth(s);
  let best = Infinity;
  for (let r = 0; r < 12; r++) {
    const t0 = Bun.nanoseconds();
    for (let i = 0; i < iters; i++) Bun.stringWidth(s);
    best = Math.min(best, (Bun.nanoseconds() - t0) / iters);
  }
  console.log(name.padEnd(26), (best / 1000).toFixed(3) + " us");
}

@robobun
robobun force-pushed the farm/566b3643/stringwidth-ansi-grammar branch from 2a5a5ce to 7ed5a6d Compare July 6, 2026 17:41
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI caught a real one: verify-baseline-static failed the x64 baseline build.

bun::N_AVX2::ProcessEscapeChunk<hwy::N_AVX2::Simd<unsigned char,32,0>,...>  [AVX, AVX2, BMI1, BMI2]
bun::N_AVX3::ProcessEscapeChunk<...>                                        [AVX, AVX512BW, AVX512F, BMI1, BMI2]
...5 targets

The escape block is pure scalar bit juggling, but I'd marked it HWY_NOINLINE inside the Highway per-target region, so it got compiled once per SIMD target and inherited AVX/BMI from the target attributes. Those are new symbols, not in the runtime-dispatch allowlist.

Rather than allowlist five near-identical functions that have no business containing vector instructions, it moves above HWY_BEFORE_NAMESPACE behind an include guard, so one copy compiles with the TU's own flags. The per-target loop classifies the chunk, extracts the four lane bitmasks it already extracted before, and hands those over. No new per-target symbol, nothing to allowlist. VisibleLatin1WidthExcludeANSIScalar stays in-target so the short-input and chunk-tail paths still inline it.

Also rebased onto 48ff9eb, which picks up #33425 — that's the cookie-map Expires failure on 2019 x64-baseline, not mine. The bun-create failure on Windows aarch64 is a git-identity/lockfile flake, also unrelated.

Corrected throughput

Superseding my earlier table — that measured the shape CI rejected. Same protocol (both from-scratch release builds, min of 6 runs × 12 batches):

case base this PR
no-ANSI 16KB latin1 0.334 us 0.349 us same
no-ANSI 16KB latin1 (é) 0.334 us 0.351 us same
no-ANSI utf16 ascii 16K 0.326 us 0.346 us same
no-ANSI utf16 CJK 8K 1.289 us 1.289 us same
short line (typical TUI) 0.055 us 0.067 us 22% slower
dense SGR 16KB 8.840 us 11.057 us 25% slower
truecolor SGR 16KB 5.909 us 8.072 us 37% slower
OSC-8 hyperlinks 16KB 3.513 us 4.930 us 40% slower
utf16 CJK + SGR 40.122 us 32.726 us 18% faster

Strings with no escapes — the overwhelmingly common shape — are at parity, and the UTF-16 path gets faster because the old per-code-unit escape state machine is gone. The escape-containing rows pay one out-of-line call per chunk that holds an escape; in absolute terms that's +12ns on a typical TUI line.

That call is the price of keeping the block off the SIMD targets. If you'd rather have it back inline and allowlist the five symbols, say the word and I'll do that instead — it's a small change either way. Caveat on the numbers: the box was at load average 60+, so treat sub-10% deltas as noise.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on 2bf0add (build 69217): the diff is green. 284 jobs passed, including all six verify-baseline jobs (the :windows: x64-baseline - verify-baseline that the scalar-half fix targeted), and the :darwin: 26 aarch64 - test-bun shards that flaked last build now pass.

The build is marked failed only because two :darwin: 14 aarch64 - test-bun shards are in state expired — macOS 14 aarch64 agents never became available, so those shards never ran. No test failed; nothing touches this diff.

The previous build's failures were the same class (a buildkite-agent artifact download timed out on darwin aarch64) plus the cookie-map Expires mismatch that #33425 fixed, which the rebase onto 48ff9eb cleared. I've used my one re-roll on that; I'd rather not push empty commits to chase macOS agent availability.

The 2bf0add commit is an empty ci: retrigger and can be dropped on squash-merge. Flagging for a maintainer: the diff is ready, and the red is CI infra (darwin agent queue), not the change.

@Jarred-Sumner
Jarred-Sumner force-pushed the farm/566b3643/stringwidth-ansi-grammar branch from 2bf0add to 0bbc39a Compare July 17, 2026 00:25
@Jarred-Sumner Jarred-Sumner changed the title Bun.stringWidth: recognize the full ANSI escape grammar Consolidate the ANSI escape grammar across stringWidth, stripANSI, sliceAnsi and wrapAnsi Jul 17, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/generate-stringwidth-tables.mjs`:
- Around line 78-79: Update the Unicode range generation in
scripts/generate-stringwidth-tables.mjs so isZeroWidth() includes all
General_Category Cf (format control) code points, including the listed ranges,
alongside the existing nonspacing and enclosing mark handling. Ensure the
generated zero-width table and lookup path consume this Cf range without
removing current Mn/Me coverage.

In `@src/jsc/bindings/sliceAnsi.cpp`:
- Around line 490-539: Update parseEscapeSequence to recognize CAN (0x18), SUB
(0x1A), and C1 ST (0x9C) immediately after a bare ESC as abort-to-ground
sequences. Consume these bytes together with the ESC, matching
ANSI::consumeANSI() behavior and preventing the literal ESC from reaching
sliceAnsi output.

In `@src/jsc/bindings/wrapAnsi.cpp`:
- Around line 230-250: Update wrapWord in src/jsc/bindings/wrapAnsi.cpp at lines
230-250 to iterate over complete grapheme clusters, keeping combining marks and
ZWJ emoji together, and only create a new row when the current row contains
visible content so an oversized first cluster does not produce an empty row.
Update the expectations in test/js/bun/util/wrapAnsi.test.ts at lines 103-107 to
require family emoji remain indivisible and add coverage for a base character
with a combining mark.

In `@test/js/bun/util/sliceAnsi-fuzz.test.ts`:
- Line 5: Update the workload-scaling condition in the sliceAnsi fuzz test to
use isDebug || isASAN, ensuring both debug and ASAN lanes select the reduced
iteration count while preserving the existing release workload.

In `@test/js/bun/util/wrapAnsi.test.ts`:
- Around line 57-59: Align the public contract for Bun.wrapAnsi with the
character-by-character behavior established by the wordWrap false test: update
the wordWrap false documentation in the wrapAnsi documentation to state that
text also wraps at the configured width, not only at explicit newlines. Preserve
the existing behavior and clarify the contract rather than changing the
implementation.
- Line 232: Add a hard-wrap test case in the wrapAnsi coverage alongside the
existing C1 CSI cases using a non-SGR final byte such as K (for example,
"\x9b2Kabcdef"). Assert that the complete zero-width CSI sequence remains intact
and K is not split during wrapping, covering the required 0x40–0x7E final-byte
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a04d0e30-286c-4543-9b91-2fc8478b898d

📥 Commits

Reviewing files that changed from the base of the PR and between 8df2aa5 and 0bbc39a.

📒 Files selected for processing (17)
  • docs/runtime/utils.mdx
  • scripts/generate-stringwidth-tables.mjs
  • src/bun_core/string/immutable/visible.rs
  • src/bun_core/string/mod.rs
  • src/jsc/bindings/ANSIHelpers.h
  • src/jsc/bindings/highway_strings.cpp
  • src/jsc/bindings/sliceAnsi.cpp
  • src/jsc/bindings/stringWidth.cpp
  • src/jsc/bindings/stringWidth.h
  • src/jsc/bindings/stringWidthTables.h
  • src/jsc/bindings/stripANSI.cpp
  • src/jsc/bindings/wrapAnsi.cpp
  • 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

Comment thread scripts/generate-stringwidth-tables.mjs
Comment thread src/jsc/bindings/sliceAnsi.cpp
Comment thread src/jsc/bindings/wrapAnsi.cpp
Comment thread test/js/bun/util/sliceAnsi-fuzz.test.ts Outdated
Comment thread test/js/bun/util/wrapAnsi.test.ts
Comment thread test/js/bun/util/wrapAnsi.test.ts
Bun.stringWidth, Bun.stripANSI, Bun.sliceAnsi and Bun.wrapAnsi each
carried their own escape recognizer and disagreed on what a terminal
displays. Drive all four through the shared grammar in ANSIHelpers.h:
CSI (incl. 0x9B), OSC (incl. 0x9D), the DCS/SOS/PM/APC control strings
(incl. 0x90/0x98/0x9E/0x9F), nF and Fe/Fs escapes, with VT500 abort
semantics — an ESC, CAN, SUB or C1 ST inside a sequence ends it instead
of swallowing the following text.

Width classification is regenerated from Unicode 17.0 (nonspacing marks
zero-width across all scripts, conjoining Hangul jamo, new emoji), the
Latin-1 path honors ambiguousIsNarrow: false, and a variation selector
no longer widens a zero-width or non-emoji base.

sliceAnsi tracks SGR state per attribute slot (bold+dim, single+double
underline stack instead of evicting), parses SGR 58/59 underline color
as an extended color, and treats an empty parameter as 0. wrapAnsi
tokenizes with the shared recognizer, keeps OSC-8 hyperlinks intact
across row breaks (ST terminator and id= params included), does not
break on a bare CR, and stops coercing falsy non-false options.

The UTF-16 width accumulator scans each visible run only up to the
next escape, removing an O(escapes x remainder) rescan on multi-line
colored text.
Under an ellipsis, keep escape sequences ordered with the
speculative-zone content by writing the zone in place and restoring an
SGR/hyperlink snapshot when it is discarded, so a fitting styled string
is not reordered and a discarded zone drops its trailing ANSI with it.

Detect a cut when the final wide cluster overflows the end budget at
EOF, stop treating trailing zero-width clusters as an end cut, and add
the degenerate fallback the fast path already had: a range that admits
no visible content returns just the ellipsis. The ellipsis inherits the
active style, consistent with the known-cut and negative-index paths.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/sliceAnsi.cpp (1)

289-302: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not track overflowed SGR parameters using only the first code.

Line 291 converts overflow into the colon path, which records the entire sequence as the style represented by params[0]. For a valid long sequence such as 31;0;...m, this incorrectly leaves red active even though a later reset cleared it. Return without updating state when params.overflow is set.

Proposed fix
-    if (params.overflow) hasColon = true;
+    if (params.overflow)
+        return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/sliceAnsi.cpp` around lines 289 - 302, In the SGR parsing
flow, update the params.overflow handling before the hasColon branch to return
immediately without calling state.applyStart or otherwise updating tracked style
state. Remove the conversion of overflow to hasColon, while preserving existing
handling for non-overflowed colon sequences.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/sliceAnsi.cpp`:
- Around line 289-302: In the SGR parsing flow, update the params.overflow
handling before the hasColon branch to return immediately without calling
state.applyStart or otherwise updating tracked style state. Remove the
conversion of overflow to hasColon, while preserving existing handling for
non-overflowed colon sequences.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 503f1266-493b-4c5c-9ccd-e4c0de0aa396

📥 Commits

Reviewing files that changed from the base of the PR and between 0bbc39a and 87158c2.

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

- sliceAnsi-fuzz: scale the O(n) time bound on ASAN too, not only debug
  (the release ASAN lane took the 1000-iteration path and hit the 5s bound).
- stripANSI: the heapStats string count can drop between the two reads when
  GC collects an unrelated string, so the new "standalone C1 ST is not
  stripped" check is one-sided (<=) with a full GC before the baseline.
- docs/bun.d.ts: `wordWrap: false` breaks every line at the column width,
  not "only at explicit newlines" -- that is what Bun and npm wrap-ansi
  both do and what the new test asserts.
- wrapAnsi.test: cover C1 CSI hard wrap with a non-SGR final byte (`K`).

No src/ changes. Rebased onto main for #32498 / #34418 / #34423.
@robobun
robobun force-pushed the farm/566b3643/stringwidth-ansi-grammar branch from 87158c2 to 2ca6ccb Compare July 17, 2026 00:56
@robobun
robobun requested a review from alii as a code owner July 17, 2026 00:56

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/wrapAnsi.cpp (1)

577-613: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use seam-aware width before deciding to wrap.

Lines 592–603 compare the additive rowLength + wordLen before checking whether the separator and word fuse into one grapheme. For example, "aa \u20E3bb" has visible width 6, but at 6 columns this code calculates 7 and wraps it unnecessarily. Compute the projected combined width before the overflow branches, not only after appending.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/wrapAnsi.cpp` around lines 577 - 613, Update the wrapping
logic around the overflow checks in the visible word-processing routine to
compute the projected seam-aware width using the same separator/word fusion
rules as the final lastRowWidth update. Use that projected width, rather than
rowLength + wordLen, for both overflow branches so fused graphemes such as a
space followed by a combining mark do not wrap unnecessarily; preserve the
existing append and lastRowWidthDirty behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/util/stringWidth.test.ts`:
- Around line 1103-1129: Update the alphabet used by the randomized consistency
invariant to include the C1 control bytes for CSI, OSC, DCS, SOS, PM, and APC,
and revise the adjacent comment to state that these introducers are covered.
Keep the existing ESC and printable/control character coverage unchanged so the
cross-check exercises both grammars through consume-to-EOF behavior.

In `@test/js/bun/util/stripANSI.test.ts`:
- Around line 546-553: Update this zero-copy test to run garbage collection
before capturing the baseline string count, then require the post-call count to
equal the baseline instead of allowing a decrease. Preserve the existing result
identity assertion and follow the exact heap-count assertion pattern used by the
nearby large-input test.

---

Outside diff comments:
In `@src/jsc/bindings/wrapAnsi.cpp`:
- Around line 577-613: Update the wrapping logic around the overflow checks in
the visible word-processing routine to compute the projected seam-aware width
using the same separator/word fusion rules as the final lastRowWidth update. Use
that projected width, rather than rowLength + wordLen, for both overflow
branches so fused graphemes such as a space followed by a combining mark do not
wrap unnecessarily; preserve the existing append and lastRowWidthDirty behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 159ada9f-cd58-4abf-80bf-4411c726ec44

📥 Commits

Reviewing files that changed from the base of the PR and between 87158c2 and 2ca6ccb.

📒 Files selected for processing (18)
  • docs/runtime/utils.mdx
  • packages/bun-types/bun.d.ts
  • scripts/generate-stringwidth-tables.mjs
  • src/bun_core/string/immutable/visible.rs
  • src/bun_core/string/mod.rs
  • src/jsc/bindings/ANSIHelpers.h
  • src/jsc/bindings/highway_strings.cpp
  • src/jsc/bindings/sliceAnsi.cpp
  • src/jsc/bindings/stringWidth.cpp
  • src/jsc/bindings/stringWidth.h
  • src/jsc/bindings/stringWidthTables.h
  • src/jsc/bindings/stripANSI.cpp
  • src/jsc/bindings/wrapAnsi.cpp
  • 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
👮 Files not reviewed due to content moderation or server errors (10)
  • scripts/generate-stringwidth-tables.mjs
  • src/jsc/bindings/stringWidthTables.h
  • src/jsc/bindings/stringWidth.h
  • src/jsc/bindings/stringWidth.cpp
  • src/bun_core/string/immutable/visible.rs
  • src/bun_core/string/mod.rs
  • src/jsc/bindings/ANSIHelpers.h
  • src/jsc/bindings/highway_strings.cpp
  • src/jsc/bindings/sliceAnsi.cpp
  • src/jsc/bindings/stripANSI.cpp

Comment thread test/js/bun/util/stringWidth.test.ts Outdated
Comment thread test/js/bun/util/stripANSI.test.ts
The comment was stale: the consolidated recognizer treats C1 CSI/OSC/DCS/
SOS/PM/APC/ST the same across stringWidth and stripANSI now, so the
randomized consistency invariant can cover them.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re the outside-diff comment on wrapAnsi.cpp:577-613 (seam-aware width before the wrap decision): verified real and a divergence from npm.

input "aa \u20E3bb"
  Bun.stringWidth           -> 6
  Bun.wrapAnsi(_, 6)        -> "aa\n⃣bb"       (wraps)
  npm wrap-ansi@9(_, 6)     -> "aa ⃣bb"        (does not wrap)

The space + U+20E3 fuse into one grapheme cluster, so "aa " + "⃣bb" is only 6 columns, but the additive check sees 3 + 4 = 7 and wraps. Leaving for the next wrapAnsi.cpp iteration along with the SGR params.overflow note; neither is in the follow-ups commit.

Restore origin/main's normalization of a lone carriage return into a
line break (\n, bare \r and a \r\n pair each produce one break).
Claude Code's wrap-text maps wrapped output back onto the original
string by relying on every \r becoming a break; leaving bare \r as
ordinary content shifted that mapping by one per line.
@Jarred-Sumner
Jarred-Sumner merged commit b69e3ca into main Jul 17, 2026
76 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/566b3643/stringwidth-ansi-grammar branch July 17, 2026 02:25
Jarred-Sumner pushed a commit that referenced this pull request Jul 17, 2026
…-width clusters (#34482)

Follow-up to #33488 (which fixed #2515 for visible content). The
start-side degenerate fallback still missed ranges whose plain-slice
content is entirely zero-width.

## Repro

```js
Bun.sliceAnsi("a\x1b[31m\t", 1, 3, {ellipsis: "…"});  // "" (expected "…")
Bun.sliceAnsi("a\x1b[31m\t", 1, 3);                   // "\x1b[31m\t\x1b[39m" (plain slice keeps the tab)
Bun.sliceAnsi("ЖЗИ", 1, 4, {ellipsis: ">>"});         // ">>" (already fixed by #33488)
```

## Cause

The bare-ellipsis fallback at `walkDone` tests `position >
startBeforeBudget` to decide whether the requested range held content.
`position` is the column *after* the last cluster, so a zero-width
cluster (tab/LF/ZWSP) sitting exactly at `startBeforeBudget` leaves
`position == startBeforeBudget` and the arm evaluates false. The
start-ellipsis budget had pushed `start` past that cluster, so `include`
never flipped, and the next line returns `emptyString()`.

## Fix

Capture `position` (the last cluster's start column) before the EOF
width finalize, and extend the start-cut arm with `!include && hasPrev
&& lastClusterCol >= startBeforeBudget`. This catches "only zero-width
clusters reached the original start" without touching the hot walk loop.
The `!include` guard scopes the change to the reported case: ranges
where the start budget consumed the whole range.

## Verification

`bun bd test test/js/bun/util/sliceAnsi.test.ts` → 166 pass;
`sliceAnsi-fuzz.test.ts` → 48 pass. Fail-before verified by stashing
`src/`.

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

---

**[stamp-90s]** gate passed · iteration 1 · 2 files touched

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

```console
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 (1ed30a6)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [3.13ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [2.02ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [1.62ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty [1.49ms]
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [1.33ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [1.50ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [1.67ms]
(pass) Bun.sliceAnsi > plain strings > negative start [2.36ms]
(pass) Bun.sliceAns
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (75dce26)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [0.04ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [0.01ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [0.01ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [0.01ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [0.03ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [0.01ms]
(pass) Bun.sliceAnsi > plain strings > negative start [0.02ms]
(pass) Bun.sliceAnsi > plain strings > negative end [0.03ms]
(pass) Bun.sliceAnsi > plain strings > both negative
(pass) Bun.sliceAnsi > plain strings > negative start keeps trailing zero-width and ANSI content [0.17ms]
(pass) Bun.sliceAnsi > plain strings > negative end strictly before total width still cuts [0.02ms]
(pass) Bun.sliceAnsi > plain strings > single character slice [0.02ms]
(pass) Bun.sliceAnsi > ANSI color codes > slices color
... (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.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 (1ed30a6)

test/js/bun/util/sliceAnsi.test.ts:
(pass) Bun.sliceAnsi > plain strings > slices ASCII string like String.prototype.slice [3.21ms]
(pass) Bun.sliceAnsi > plain strings > returns empty string for empty input [2.00ms]
(pass) Bun.sliceAnsi > plain strings > returns full string with no arguments beyond first [1.61ms]
(pass) Bun.sliceAnsi > plain strings > start=0, end=0 returns empty [1.48ms]
(pass) Bun.sliceAnsi > plain strings > start > end returns empty [1.29ms]
(pass) Bun.sliceAnsi > plain strings > start beyond string length returns empty [1.57ms]
(pass) Bun.sliceAnsi > plain strings > end beyond string length returns remainder [1.72ms]
(pass) Bun.sliceAnsi > plain strings > negative start [2.35ms]
(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) in 681ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[2/7] gen cpp.rs (cppbind)
[2/7] 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    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/sr
... (truncated)
```

</details>

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

```
src/jsc/bindings/sliceAnsi.cpp     |  8 ++++++--
 test/js/bun/util/sliceAnsi.test.ts | 24 ++++++++++++++++++++++++
 2 files changed, 30 insertions(+), 2 deletions(-)
```

</details>

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

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

```
file                                reads  edits  tests
src/jsc/bindings/sliceAnsi.cpp          6      3      0
test/js/bun/util/sliceAnsi.test.ts      3      3      0
```

</details>

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.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