Skip to content

sliceAnsi: keep the truncated string within the range when a wide cluster straddles the ellipsis cut - #42551

Open
robobun wants to merge 3 commits into
mainfrom
robobun/20c43cf0/slice-ansi-ellipsis-budget
Open

robobun wants to merge 3 commits into
mainfrom
robobun/20c43cf0/slice-ansi-ellipsis-budget

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • sliceAnsi("安宁哈", 0, 4, "…") is "安宁…", 5 columns for a range of 4. bun.d.ts counts the ellipsis against the budget.
  • Cause: emitSliceStreaming (src/jsc/bindings/sliceAnsi.cpp) keeps a cluster when its START column is before end - width(ellipsis), the slice-ansi 8 / cli-truncate 5 rule. Upstream got the same report (Truncation exceeds maxWidth for East Asian wide characters sindresorhus/cli-truncate#28) and changed the rule in slice-ansi 9.0.0. cli-truncate 6.1.1 returns "安…".

Fix

  • With an ellipsis in the output, a cluster that extends past the content end becomes tentative output from its own start. A confirmed cut removes it. No cut keeps it: sliceAnsi("安宁", 0, 4, "…") is still "安宁".
  • A plain slice does not change. Of 180,000 generated cases against 1.4.3-canary, 9,970 changed. All were over budget before, 0 are now.
  • One existing expectation encoded the overflow: sliceAnsi(red + "ab漢" + reset + "xy", 0, 4, "…") expected 5 columns. It moved to (0, 5).
  • Verified: test/js/bun/util/sliceAnsi.test.ts and sliceAnsi-fuzz.test.ts (1.4.3-canary fails 3 tests). Release A/B against main on 30 rows: no measurable change (Notes). Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • A grapheme cluster (CJK character, emoji sequence, Indic conjunct) is atomic and can be 2 or more columns wide.
  • The walk is one pass. position is the start column of the open cluster. Its width is added when the next cluster starts, because a joiner can still change it.
  • The walk does not know up front if the string is cut. It writes columns [end - ew, end) as tentative output (the speculative zone) and records a mark. A cut shrinks the result to the mark and appends the ellipsis. No cut keeps the zone.
Notes

Before and after (W is Bun.stringWidth of the result):

call 1.4.3-canary this PR
sliceAnsi("安宁哈", 0, 4, "…") "安宁…" W 5 "安…" W 3
sliceAnsi("安宁哈", 0, 2, "…") "安…" W 3 "…" W 1
sliceAnsi("a安宁哈", 0, 3, "…") "a安…" W 4 "a…" W 2
sliceAnsi("🙂🙂🙂", 0, 4, "…") "🙂🙂…" W 5 "🙂…" W 3
sliceAnsi("安宁哈", 0, -2, "…") "安宁…" W 5 "安…" W 3
sliceAnsi("क्ष्म्य" + "xyz", 0, 2, "…") "क्ष्म्य…" W 5 "…" W 1
sliceAnsi("安宁", 0, 4, "…") "安宁" "安宁"
sliceAnsi("安宁哈", 0, 3) (no ellipsis) "安宁" "安宁"

Where the old rule came from. The +1 was not a slip. sliceAnsi follows slice-ansi 8 and cli-truncate 5.1.1 (the pair in bench/package.json), where the end of a slice keeps a wide character that starts before the cut, and sliceAnsi-fuzz.test.ts recorded that as a +1 tolerance for the ellipsis case too. cli-truncate 5 had the same overflow and got it as a bug report: sindresorhus/cli-truncate#28. slice-ansi 9.0.0 fixed it as a breaking change ("Make endSlice exclude partial wide graphemes"), and cli-truncate 6 uses it. This PR applies that end rule only when an ellipsis is in the output, because that is where bun.d.ts promises a width budget and names cli-truncate.

Not changed: the plain slice rule. Without an ellipsis a cluster that starts before end is kept whole, so sliceAnsi("安宁哈", 0, 3) is "安宁". That has its own tests. slice-ansi 9 returns "安". A move of the plain path to the slice-ansi 9 rule is a separate, breaking decision for a maintainer. The check added here (checkClusterFit, clusterMark) is what that change would use.

Not changed: an ellipsis that does not fit the range. sliceAnsi("abcdef", 0, 2, "...") is still "...". The bun.d.ts sentence says "if the ellipsis itself fits the range".

Paths covered. The lazy path (non-negative end, cut detected during the walk), the known-cut path (negative indices, one width pre-pass), and a range that has room only for a start ellipsis all run the same check (checkClusterFit()). The mark also snapshots the active SGR and OSC 8 hyperlink state, so the ellipsis and the close codes match what stays in the result.

Clusters wider than 2. An Indic conjunct is one cluster and can be 3 or more columns. The check uses the real cluster width, not a fixed window before the cut.

Slack after a cluster dropped at start. When a wide cluster straddles start it is dropped whole, so the first kept cluster starts one column late. The content may end that much later and still fit. contentEnd = end + (firstKeptColumn - start) keeps those results as they were: sliceAnsi("abc🙂漢,🙂", 1, 9, "...") stays "...漢..." (8 columns for a range of 8).

ANSI inside the dropped cluster. ANSI does not break a cluster, so 👩 ESC[31m ZWJ 💻 is one cluster with an SGR inside. The zone needs the style state from the start of the cluster. The walk takes that snapshot only when ANSI is flushed inside an open cluster, which is rare.

Nothing fits between two ellipses. sliceAnsi("xy安宁哈", 1, 4, "…") is now "……" (2 columns for a range of 3). Before it was "…安…" (4 columns). The lazy path already returned two ellipses when the first candidate cluster starts at the content end.

Performance. The fit check is one compare per cluster, position > contentEnd, right after the cluster width is added. contentEnd stays SIZE_MAX unless an ellipsis is in the output and a cluster was kept, so a plain slice never takes the branch. The only other per-cluster cost is one result.length() store. A first version ran a five-term condition and two stores for every cluster and was 5 to 15% slower on CJK and emoji rows. Release builds of main and of this branch, same toolchain, one changed translation unit, interleaved runs pinned with taskset, 6 rounds, best-of-round minimum and median p10 in ns per call. Rows and fixtures are the Bun side of bench/snippets/slice-ansi.mjs plus longer CJK, emoji and Latin-1 rows:

row main (ns) this PR (ns) delta min delta p10
ascii-short [0,20) 52 52 +0.2% -1.9%
ascii-long [0,1000) 208 217 +4.4% +12.2%
ansi-short [0,30) 1333 1350 +1.3% -4.7%
ansi-medium [10,200) 4140 4147 +0.2% -0.5%
ansi-long [0,2000) 41000 41135 +0.3% +1.3%
ansi-dense [0,100) 18770 18633 -0.7% -0.6%
cjk [0,100) 1245 1243 -0.2% -1.2%
cjk+ansi [0,100) 1857 1867 +0.5% -1.1%
emoji [0,100) 2016 1998 -0.9% -3.1%
zwj-family [0,100) 2631 2547 -3.2% -4.8%
skin-tone [0,100) 2160 2183 +1.1% -2.6%
combining-marks [0,100) 2653 2637 -0.6% -1.8%
hyperlinks [0,100) 1595 1589 -0.4% -3.8%
truncate-end ascii-short 91 89 -2.1% -0.9%
truncate-end ansi-long 4695 4662 -0.7% -1.0%
truncate-start ansi-long 136842 134229 -1.9% -2.8%
truncate-end emoji 1111 1125 +1.3% +2.5%
truncate no-cut (fits) 47 45 -6.2% +4.2%
ink-clip (80-col) 1415 1406 -0.6% +0.9%
cjk-long [0,800) plain 8429 8371 -0.7% -1.6%
emoji-long [0,400) plain 7647 7735 +1.2% -1.3%
cjk-long trunc-end 800 8503 8428 -0.9% -1.8%
latin1 [0,200) plain 3467 3564 +2.8% -0.6%
latin1 trunc-end 200 3730 3800 +1.9% +0.2%
emoji-long trunc-end 400 7713 7798 +1.1% -0.7%
zwj trunc-end 100 2699 2601 -3.6% -3.6%
skin-tone trunc-end 100 2273 2241 -1.4% -0.3%
combining trunc-end 100 2711 2684 -1.0% +0.1%
cjk+ansi trunc-end 100 1939 1934 -0.3% -0.2%
cjk trunc-start 100 9207 9079 -1.4% -6.8%

ascii-short, ascii-long and truncate no-cut take the ASCII fast path, which this PR does not touch, so their deltas (up to +4.4% and -6.2%) show the noise floor of a shared machine. The largest delta on a row that runs the changed code is +2.8% (latin1 plain), inside that floor. The ZWJ and combining rows are a little faster because the join branch no longer calls flushPending when nothing is pending.

Differential check. A script generated 180,000 (string, start, end, ellipsis, ambiguousIsNarrow) cases from ASCII, CJK, emoji, ZWJ sequences, flags, conjuncts of width 2 and 3, combining marks, variation selectors, Prepend, zero-width characters, SGR and OSC 8 codes, with non-negative and negative indices. It ran each case on 1.4.3-canary and on this branch. Results: 9,970 changed, all of them over budget before. 0 over budget after. 0 plain-slice results changed. The kept text of each changed result is a prefix of the old kept text. The script skipped 2,198 inputs where Bun.stringWidth and sliceAnsi disagree on the width of a Prepend-led cluster that carries a variation selector (stringWidth("\u0600\u53c0\ufe0f") is 2, sliceAnsi counts 0). That mismatch exists on 1.4.3 and is in the area #41525 reworks. The final version in this PR produces byte-identical output to the first version on all 180,000 cases.

Tests. The new unit test holds the ledger cases, the no-cut cases, negative indices, a width-4 conjunct, both edges cut, start ellipsis only, the slack case, 8-bit input, the bulk ASCII path, and SGR and OSC 8 inside or after the dropped cluster. It ends with an exhaustive width law over 5 strings, 3 ellipses, every range, both index forms. Mutants of the fit condition (drop the slack term, drop "was kept", drop "starts before the content end", drop "an ellipsis is in the output") each fail at least one test. sliceAnsi-fuzz.test.ts loses the +1 for ellipsis cases and gains a property over wide-dense random strings.

Self-review. Concerns: per-cluster cost on the plain path (restructured, table above), the upstream lineage missing from the text (added), the bun.d.ts sentence and stale test comments (qualified and updated). One suggestion not taken: a tracking issue for the plain slice rule. It is stated above as an open decision instead.

Suites run on the debug build: sliceAnsi.test.ts, sliceAnsi-fuzz.test.ts, stringWidth.test.ts, wrapAnsi.test.ts, stripANSI.test.ts: 933 pass.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/util/sliceAnsi-fuzz.test.ts test/js/bun/util/sliceAnsi.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/66] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/66] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/66] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/run
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (6a92015fc)

test/js/bun/util/sliceAnsi-fuzz.test.ts:
(pass) sliceAnsi invariants > output width never exceeds requested range [4.90ms]
(pass) sliceAnsi invariants > slice of stripped equals stripped slice (for 1-width chars) [2.62ms]
(pass) sliceAnsi invariants > adjacent slices cover full visible string [1.48ms]
(pass) sliceAnsi invariants > output is always well-formed UTF-16 [3.37ms]
(pass) sliceAnsi invariants > full slice preserves visible content [1.58ms]
(pass) sliceAnsi invariants > slicing a slice is idempotent on visible content [1.59ms]
141 |       // not fit beside it is dropped, so there is no +1 here. An empty
142 |       // ellipsis is a plain slice and keeps the +1 from above. If the
143 |       // ellipsis itself is wider than n (degenerate), it's returned as-is.
144 |       const ew = visibleWidth(e);
145 |       const tolerance = ew === 0 ? 1 : Math.max(0, ew - n);
146 |       expect(visibleWidth(out)).toBeLessThanOrEqual(n + tolerance);
                                      ^
error: expect(received).toBeLessThanOrEqual(expected)

Expected: <= 31
Received: 32

      at <anonymous> (/workspace/bun/test/js/bun/util/sliceAns
... (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/pr_gate.xml" test/js/bun/util/sliceAnsi-fuzz.test.ts test/js/bun/util/sliceAnsi.test.ts
bun test v1.4.3 (6a92015fc)

test/js/bun/util/sliceAnsi-fuzz.test.ts:
(pass) sliceAnsi invariants > output width never exceeds requested range [987.30ms]
(pass) sliceAnsi invariants > slice of stripped equals stripped slice (for 1-width chars) [684.28ms]
(pass) sliceAnsi invariants > adjacent slices cover full visible string [359.76ms]
(pass) sliceAnsi invariants > output is always well-formed UTF-16 [888.51ms]
(pass) sliceAnsi invariants > full slice preserves visible content [296.25ms]
(pass) sliceAnsi invariants > slicing a slice is idempotent on visible content [352.06ms]
(pass) sliceAnsi invariants > ellipsis output width respects budget [619.07ms]
(pass) sliceAnsi invariants > ellipsis output width respects budget for any range [416.53ms]
(pass) sliceAnsi adversarial > inputs near SIMD stride boundaries [19.65ms]
(pass) sliceAnsi adversarial > C1 ST at SIMD boundary positions [8.47ms]
(pass) sliceAnsi adversarial > unterminated CSI sequences don't hang or o
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 725ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/60] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/60] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/60] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2
... (truncated)
diff hotspot
packages/bun-types/bun.d.ts             |   5 +-
 src/jsc/bindings/sliceAnsi.cpp          |  69 +++++++++++++++------
 test/js/bun/util/sliceAnsi-fuzz.test.ts |  70 +++++++++++++++++----
 test/js/bun/util/sliceAnsi.test.ts      | 106 +++++++++++++++++++++++++++++++-
 4 files changed, 216 insertions(+), 34 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                     reads  edits  tests
packages/bun-types/bun.d.ts                  1      2     28
src/jsc/bindings/sliceAnsi.cpp               9     18     29
test/js/bun/util/sliceAnsi-fuzz.test.ts      3      6     21
test/js/bun/util/sliceAnsi.test.ts           3      7     26

…dles the ellipsis cut

Bun.sliceAnsi follows slice-ansi 8 and cli-truncate 5: the end of a
slice keeps a wide cluster that starts before the cut. With an ellipsis
the content end moves to end - width(ellipsis), so that rule made the
result one column wider than the range, and more for a cluster that is
wider than 2 columns. Upstream changed the rule in slice-ansi 9.0.0
after the same report against cli-truncate. This change applies the new
rule to the ellipsis path only. A plain slice keeps the old rule.

The width of a cluster is known only when the cluster ends. The fit is
checked after the width is added to position, and only once position
reaches end, so the walk before that point is unchanged. A cluster that
started before the content end and extends past it opens the
speculative zone at its own start. It is discarded with the cut and
kept when nothing is cut. The negative-index path and a range with only
a start ellipsis use the same check.
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3-canary.1 (6a92015) and on main (b993710):

const W = Bun.stringWidth;
for (const [s, max] of [["安宁哈", 4], ["安宁哈", 2], ["a安宁哈", 3], ["🙂🙂🙂", 4]]) {
  const o = Bun.sliceAnsi(s, 0, max, "…");
  console.log(JSON.stringify(o), "width", W(o), "max", max);
}

Each result is one column wider than max ("安宁…" is width 5 for max 4). On this branch each result fits ("安…").

  • USE_SYSTEM_BUN=1 bun test test/js/bun/util/sliceAnsi.test.ts test/js/bun/util/sliceAnsi-fuzz.test.ts: 3 tests fail.
  • bun bd test with the same two files on this branch: 216 pass.

CI on 6a25562 (build 115003: 179 jobs passed, 2 failed). The diff is green. sliceAnsi.test.ts and sliceAnsi-fuzz.test.ts pass on every lane. The red checks do not come from this diff:

  • test/cli/install/bun-patch.test.ts on Windows 2019 x64 (failed to resolve cache dir for "bar": EBADF). The same test fails or needs a retry in 20 of the last 30 builds, on unrelated branches (hard failures in builds 114994, 114987 and 114974).
  • test/js/web/fetch/fetch-backpressure.test.ts on Windows 11 aarch64. It fails or needs a retry in 22 of the last 30 builds (hard failure on another branch in build 114999).
  • TypeScript types: the bun-types job fails on every PR that touches packages/bun-types since the @types/node releases of 2026-09-09 (Cannot find name 'ConnectionOptions' in overrides.d.ts). The only bun-types change here is one JSDoc sentence in bun.d.ts.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c9236e39-8304-40a5-89d7-d2fa758a5665

📥 Commits

Reviewing files that changed from the base of the PR and between 3c51fd6 and 6a25562.

📒 Files selected for processing (1)
  • src/jsc/bindings/sliceAnsi.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The change updates sliceAnsi ellipsis handling for wide grapheme clusters, ANSI state, and OSC 8 hyperlinks. It adds strict width-budget tests and documents the resulting behavior.

Changes

sliceAnsi ellipsis handling

Layer / File(s) Summary
Ellipsis boundary resolution
src/jsc/bindings/sliceAnsi.cpp, packages/bun-types/bun.d.ts
emitSliceStreaming checks completed grapheme clusters against the ellipsis width budget across scalar and SIMD paths. It snapshots and restores ANSI and OSC 8 state when resolving speculative content. The API documentation describes hyperlink placement, width budgeting, and wide-character handling.
Width-budget and grapheme coverage
test/js/bun/util/sliceAnsi.test.ts, test/js/bun/util/sliceAnsi-fuzz.test.ts
Tests cover wide graphemes, negative ranges, conjuncts, ambiguous-width characters, ANSI and OSC 8 sequences, both-edge truncation, randomized wide strings, and strict ellipsis width limits.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 6a255

The ellipsis handling retains formatting correctly and keeps truncated output within its width budget in the covered paths. No unresolved merge risk is identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing ellipsis truncation from exceeding the requested range when a wide grapheme cluster crosses the cut.
Description check ✅ Passed The description explains the problem, implementation, scope, compatibility decisions, performance impact, and verification results. It does not use the template headings exactly, but it provides the r…

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

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:32 PM PT - Sep 12th, 2026

❌ @robobun, your commit 6a25562 has 2 failures in Build #115003 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42551

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

bun-42551 --bun

Comment thread src/jsc/bindings/sliceAnsi.cpp Outdated
Comment thread src/jsc/bindings/sliceAnsi.cpp Outdated
Comment thread src/jsc/bindings/sliceAnsi.cpp Outdated
… structure

The fit check is now `position > contentEnd` right after a cluster's
width is added. contentEnd stays SIZE_MAX unless an ellipsis is in the
output and a cluster was kept, so a plain slice never takes the branch.
The cut detection block and the zone resolution keep their old shape.

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

I reviewed the latest push and didn't find any bugs. Because this reworks the speculative-rollback state machine in emitSliceStreaming and changes Bun.sliceAnsi's output for the ellipsis case, a human look is still worthwhile.

What was reviewed:

  • checkClusterFit is invoked at all three width-accumulation sites (per-cp break, ASCII-bulk finalize, EOF finalize); the position - gs.width() >= end guard correctly defers to the existing spec-zone entry for clusters that start at/past end.
  • The lazy style snapshot (stylesSavedForCluster) captures activeStyles before flushPending runs on ANSI inside a cluster, and checkClusterFit's fallback snapshot is taken before the next break's flush — both restore the state at clusterMark.
  • The ellipsisEndBudget > 0 || inSpecZone widening plus the new ASSERT hold: when ellipsisEndBudget == 0, contentEnd >= specEnd, so position > contentEnd implies sawCutEnd on every path that opens the zone.
  • The one modified pre-existing assertion (end 4→5) encoded the old overflow; the same input at end=4 is re-asserted under the new rule in the added test block.
Extended reasoning...

Overview

This PR changes Bun.sliceAnsi's ellipsis mode so a wide grapheme cluster that straddles the content boundary (end - width(ellipsis)) is dropped rather than kept whole, guaranteeing the truncated result never exceeds the requested column budget. The implementation in src/jsc/bindings/sliceAnsi.cpp adds a clusterMustFit mode with contentEnd/clusterMark/stylesSavedForCluster state, a checkClusterFit helper called at each of the three points where cluster width is committed, a beginInclude helper deduplicating two include-entry blocks, and a widened post-walk discard condition (|| inSpecZone) with an ASSERT on the no-cut branch. Tests add a large targeted block (CJK/emoji/ZWJ/conjunct, both edges, negative indices, 8-bit ambiguous, ASCII bulk path, SGR and OSC-8 inside the dropped cluster, and an exhaustive start×end×ellipsis law loop), tighten three fuzz invariants by removing the +1 tolerance, and add a wide-cluster-dense fuzz generator plus a new range property test. bun.d.ts documents the new width guarantee.

Security risks

None identified. sliceAnsi is a pure string-width utility over in-memory strings with no I/O, allocation of user-sized buffers, auth, crypto, or filesystem/network reach. The added state is stack-local scalars and existing StringBuilder/String members; result.shrink(specZoneMark) uses a mark taken from result.length() earlier in the same walk. No new JS re-entry points or exception-throwing calls were added.

Level of scrutiny

Medium-high. The change is a focused bug fix aligning with upstream slice-ansi 9 / cli-truncate 6 and the existing bun.d.ts promise, but it threads new state through a subtle single-pass streaming state machine with speculative rollback, and it is a semantic change to a public API (~5.5% of generated cases change output per the PR's differential). The interaction between checkClusterFit, the pre-existing enterSpecZone, the lazy style snapshot, and the three code paths that commit cluster width warrants a maintainer's read, as does the decision to apply the new rule only when an ellipsis is present (leaving the plain-slice rule at slice-ansi 8 semantics).

Other factors

Test coverage is unusually thorough: the exhaustive law loop and the tightened fuzz invariants would catch regressions across the variant matrix REVIEW.md asks for (both edges, negative indices, 8-bit vs 16-bit, ASCII fast path vs streaming, SGR/OSC-8 inside the dropped cluster). The one modified pre-existing assertion is explained (it encoded the overflow) and the same input under the new rule is re-asserted nearby, satisfying the "never silently weaken a test" rule. Prior inline findings from this system were followed by commit 6a25562, which restructured the fit check to a single compare per cluster; this run found no issues in that revision. Given the state-machine subtlety and the API-visible behavior change, deferring to a human is the safer call over auto-approval.

This branch has not been deployed

No deployments
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