Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
892d216399 fix(truncateAnsi): match cli-truncate behavior for SGR tracking, wide chars, and edge cases
- Fix SGR state tracking: close codes (39, 22, 24, etc.) now properly
  remove active styles, preventing duplicate close code emission
- Fix wide char boundary: chars starting before beginCol are excluded
  even if they extend past it (matching slice-ansi behavior)
- Fix columns=1 with space option: return base truncation char without
  space padding (matching cli-truncate's early return)
- Fix SGR close ordering: use ordered Vector instead of HashMap to
  preserve insertion order (matching cli-truncate's Map behavior)
- Fix middle position with empty right slice: scan full string for
  final SGR state when right half has no visible content
- Fuzz test: 96.5% match rate against cli-truncate (4826/5000),
  remaining differences are in preferTruncationOnSpace edge cases

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 21:40:50 +00:00
Claude Bot
6244815694 feat: implement Bun.truncateAnsi() — drop-in replacement for cli-truncate
Adds Bun.truncateAnsi(text, columns, options?) for ANSI-aware string
truncation with support for end/start/middle positions, space padding,
preferTruncationOnSpace, and custom truncation characters. SGR style
inheritance ensures the truncation indicator inherits surrounding colors.

Shared utilities (decodeUTF16, codepointWidth, stringWidth, charLength)
moved from wrapAnsi.cpp into ANSIHelpers.h to avoid duplication.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 08:40:40 +00:00
7 changed files with 877 additions and 39 deletions

View File

@@ -610,6 +610,68 @@ declare module "bun" {
*/
function stripANSI(input: string): string;
interface TruncateAnsiOptions {
/**
* Where to place the truncation indicator.
* @default "end"
*/
position?: "end" | "start" | "middle";
/**
* Add a space between the text and the truncation character.
* @default false
*/
space?: boolean;
/**
* When `true`, prefer breaking at a whitespace character (within 3
* positions of the break point) instead of mid-word.
* @default false
*/
preferTruncationOnSpace?: boolean;
/**
* Custom string to use as the truncation indicator instead of `…`.
* @default "…"
*/
truncationCharacter?: string;
}
/**
* Truncate a string to fit within the specified column width, preserving
* ANSI escape codes. A drop-in replacement for the `cli-truncate` package.
*
* The truncation character inherits the ANSI style at the truncation point
* for `"end"` and `"start"` positions.
*
* @category Utilities
*
* @param input The string to truncate
* @param columns The maximum number of terminal columns to occupy
* @param options Position string or options object
* @returns The truncated string, or the original string if it already fits
*
* @example
* ```ts
* import { truncateAnsi } from "bun";
*
* truncateAnsi("unicorn", 4); // "uni…"
* truncateAnsi("unicorn", 4, "start"); // "…orn"
* truncateAnsi("unicorn", 5, "middle"); // "un…rn"
*
* // ANSI codes are preserved
* truncateAnsi("\u001b[31municorn\u001b[39m", 4); // "\u001b[31muni…\u001b[39m"
*
* // Options object
* truncateAnsi("unicorns", 5, { position: "end", space: true }); // "uni …"
* ```
*/
function truncateAnsi(
input: string,
columns: number,
options?: "end" | "start" | "middle" | TruncateAnsiOptions,
): string;
interface WrapAnsiOptions {
/**
* If `true`, break words in the middle if they don't fit on a line.

View File

@@ -3,6 +3,11 @@
#include "root.h"
#include <wtf/SIMDHelpers.h>
// Zig exports for visible width calculation
extern "C" size_t Bun__visibleWidthExcludeANSI_utf16(const uint16_t* ptr, size_t len, bool ambiguous_as_wide);
extern "C" size_t Bun__visibleWidthExcludeANSI_latin1(const uint8_t* ptr, size_t len);
extern "C" uint8_t Bun__codepointWidth(uint32_t cp, bool ambiguous_as_wide);
namespace Bun {
namespace ANSI {
@@ -186,5 +191,69 @@ static const Char* consumeANSI(const Char* start, const Char* end)
return end;
}
// ============================================================================
// Shared character decoding and width utilities
// ============================================================================
// Decode a single UTF-16 code unit (or surrogate pair) into a codepoint.
static inline char32_t decodeUTF16(const UChar* ptr, size_t available, size_t& outLen)
{
UChar c = ptr[0];
if (c >= 0xD800 && c <= 0xDBFF && available >= 2) {
UChar c2 = ptr[1];
if (c2 >= 0xDC00 && c2 <= 0xDFFF) {
outLen = 2;
return 0x10000 + (((c - 0xD800) << 10) | (c2 - 0xDC00));
}
}
outLen = 1;
return static_cast<char32_t>(c);
}
// Get the terminal display width of a single codepoint.
static inline uint8_t codepointWidth(char32_t cp, bool ambiguousAsWide)
{
return Bun__codepointWidth(cp, ambiguousAsWide);
}
// Get the visible width of a string, excluding ANSI escape codes.
template<typename Char>
static size_t stringWidth(const Char* start, size_t len, bool ambiguousAsWide = false)
{
if (len == 0)
return 0;
if constexpr (sizeof(Char) == 1) {
(void)ambiguousAsWide;
return Bun__visibleWidthExcludeANSI_latin1(reinterpret_cast<const uint8_t*>(start), len);
} else {
return Bun__visibleWidthExcludeANSI_utf16(reinterpret_cast<const uint16_t*>(start), len, ambiguousAsWide);
}
}
// Advance past one character (handling surrogate pairs for UTF-16).
template<typename Char>
static inline size_t charLength(const Char* it, const Char* end)
{
if constexpr (sizeof(Char) == 1) {
return 1;
} else {
if (*it >= 0xD800 && *it <= 0xDBFF && (end - it) >= 2 && it[1] >= 0xDC00 && it[1] <= 0xDFFF)
return 2;
return 1;
}
}
// Decode a character and get its codepoint + length.
template<typename Char>
static inline char32_t decodeChar(const Char* it, const Char* end, size_t& outLen)
{
if constexpr (sizeof(Char) == 1) {
outLen = 1;
return static_cast<char32_t>(static_cast<uint8_t>(*it));
} else {
return decodeUTF16(it, end - it, outLen);
}
}
} // namespace ANSI
} // namespace Bun

View File

@@ -79,6 +79,7 @@ BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv5);
namespace Bun {
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunTruncateAnsi);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunWrapAnsi);
}
@@ -999,6 +1000,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
truncateAnsi jsFunctionBunTruncateAnsi DontDelete|Function 3
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
which BunObject_callback_which DontDelete|Function 1

View File

@@ -0,0 +1,549 @@
#include "root.h"
#include "truncateAnsi.h"
#include "ANSIHelpers.h"
#include <wtf/text/WTFString.h>
#include <wtf/text/StringBuilder.h>
#include <JavaScriptCore/JSObject.h>
namespace Bun {
using namespace WTF;
// ============================================================================
// Options
// ============================================================================
enum class TruncatePosition { End, Start, Middle };
struct TruncateOptions {
TruncatePosition position = TruncatePosition::End;
bool space = false;
bool preferTruncationOnSpace = false;
WTF::String truncationCharacter;
};
// ============================================================================
// Visible width of a WTF::String
// ============================================================================
static size_t wtfStringWidth(const WTF::String& str)
{
if (str.isNull() || str.isEmpty())
return 0;
if (str.is8Bit())
return ANSI::stringWidth(str.span8().data(), str.length());
return ANSI::stringWidth(str.span16().data(), str.length());
}
// ============================================================================
// ANSI-aware slicing by visible column range [beginCol, endCol).
// All ANSI escape sequences are always passed through.
// ============================================================================
// Map an SGR code to its close code. Returns the close code for open codes,
// or the code itself if it IS a close code. Returns 0 for unknown/reset.
static uint32_t sgrCloseCode(uint32_t code)
{
if (code == 0) return 0; // reset
if (code == 1 || code == 2) return 22;
if (code == 3) return 23;
if (code == 4) return 24;
if (code == 7) return 27;
if (code == 8) return 28;
if (code == 9) return 29;
if ((code >= 30 && code <= 38) || (code >= 90 && code <= 97)) return 39;
if ((code >= 40 && code <= 48) || (code >= 100 && code <= 107)) return 49;
// Close codes map to themselves
if (code == 22 || code == 23 || code == 24 || code == 27 || code == 28 || code == 29 || code == 39 || code == 49)
return code;
return 0;
}
// Parse a simple SGR code: \e[<digits>m → returns the number, or -1.
template<typename Char>
static int32_t parseSingleSgr(const Char* start, const Char* seqEnd)
{
// Must be ESC [ <digits> m
size_t len = seqEnd - start;
if (len < 4) return -1;
if (start[0] != 0x1b || start[1] != '[') return -1;
if (start[len - 1] != 'm') return -1;
int32_t val = 0;
for (size_t i = 2; i < len - 1; i++) {
Char c = start[i];
if (c >= '0' && c <= '9') val = val * 10 + (c - '0');
else return -1; // semicolons / compound - skip tracking
}
return val;
}
// Tracks active SGR styles as an ordered list of (closeCode, openSequence) pairs.
// Preserves insertion order to match cli-truncate's Map behavior.
struct SgrEntry {
uint32_t closeCode;
WTF::String openSeq;
};
using SgrMap = Vector<SgrEntry>;
// Process a block of possibly-chained ANSI sequences, updating SGR state for each.
template<typename Char>
static void updateSgrState(SgrMap& active, const Char* start, const Char* blockEnd)
{
// consumeANSI may chain multiple sequences. Parse each ESC[...m individually.
const Char* p = start;
while (p < blockEnd) {
// Find next ESC [ ... m sequence
if (*p == 0x1b && p + 1 < blockEnd && p[1] == '[') {
const Char* seqStart = p;
p += 2; // skip ESC [
while (p < blockEnd && ((*p >= '0' && *p <= '9') || *p == ';'))
p++;
if (p < blockEnd && *p == 'm') {
p++; // skip 'm'
// Parse this individual SGR: seqStart to p
int32_t code = parseSingleSgr(seqStart, p);
if (code >= 0) {
if (code == 0) {
active.clear();
} else {
uint32_t closeCode = sgrCloseCode(static_cast<uint32_t>(code));
if (closeCode != 0) {
// Remove existing entry with this closeCode
active.removeAllMatching([closeCode](const SgrEntry& e) { return e.closeCode == closeCode; });
// If this is an open code (not a close), re-add at end
if (static_cast<uint32_t>(code) != closeCode) {
size_t len = p - seqStart;
WTF::String seq;
if constexpr (sizeof(Char) == 1)
seq = WTF::String(std::span<const Latin1Character>(reinterpret_cast<const Latin1Character*>(seqStart), len));
else
seq = WTF::String(std::span<const UChar>(reinterpret_cast<const UChar*>(seqStart), len));
active.append(SgrEntry { closeCode, std::move(seq) });
}
}
}
}
continue;
}
}
p++;
}
}
static void emitSgrCode(StringBuilder& out, uint32_t code)
{
UChar buf[8];
buf[0] = 0x1b; buf[1] = '[';
size_t pos = 2;
uint32_t c = code;
if (c >= 100) { buf[pos++] = '0' + (c / 100); c %= 100; buf[pos++] = '0' + (c / 10); c %= 10; }
else if (c >= 10) { buf[pos++] = '0' + (c / 10); c %= 10; }
buf[pos++] = '0' + c;
buf[pos++] = 'm';
out.append(std::span<const UChar>(buf, pos));
}
static void emitSgrCloses(SgrMap& active, StringBuilder& out)
{
// Emit close codes in reverse insertion order (matching cli-truncate's [...keys].reverse())
for (size_t i = active.size(); i > 0; i--)
emitSgrCode(out, active[i - 1].closeCode);
}
static void emitSgrOpens(SgrMap& active, StringBuilder& out)
{
for (auto& entry : active) out.append(entry.openSeq);
}
template<typename Char>
static void sliceAnsi(const Char* input, size_t inputLen,
size_t beginCol, size_t endCol, StringBuilder& out)
{
if (beginCol >= endCol)
return;
const Char* it = input;
const Char* end = input + inputLen;
size_t col = 0;
bool include = false;
SgrMap activeStyles;
while (it < end) {
// ANSI escape sequences: always track SGR state
if (ANSI::isEscapeCharacter(*it)) {
const Char* seqEnd = ANSI::consumeANSI(it, end);
updateSgrState(activeStyles, it, seqEnd);
if (include)
out.append(std::span { it, seqEnd });
it = seqEnd;
continue;
}
size_t charLen;
char32_t cp = ANSI::decodeChar(it, end, charLen);
uint8_t w = ANSI::codepointWidth(cp, false);
// Zero-width: include if currently including
if (w == 0) {
if (include)
out.append(std::span { it, it + charLen });
it += charLen;
continue;
}
// Past end: stop (don't track SGR past this point)
if (col >= endCol)
break;
// Entering range
if (!include && col >= beginCol) {
include = true;
emitSgrOpens(activeStyles, out);
}
if (include)
out.append(std::span { it, it + charLen });
col += w;
it += charLen;
if (col >= endCol)
break;
}
// Emit close codes for any still-active styles
if (include)
emitSgrCloses(activeStyles, out);
}
// ============================================================================
// SGR style-inheritance helpers
// ============================================================================
static inline bool isSgrParam(UChar c) { return (c >= '0' && c <= '9') || c == ';'; }
// Index of first byte after leading SGR spans (\e[...m sequences).
static size_t leadingSgrEnd(const StringView& sv)
{
size_t i = 0, len = sv.length();
while (i + 2 < len && sv[i] == 0x1b && sv[i + 1] == '[') {
size_t j = i + 2;
while (j < len && isSgrParam(sv[j])) j++;
if (j < len && sv[j] == 'm') { i = j + 1; continue; }
break;
}
return i;
}
// Index of first byte of trailing SGR spans.
static size_t trailingSgrStart(const StringView& sv)
{
size_t start = sv.length();
while (start > 1 && sv[start - 1] == 'm') {
size_t j = start - 2;
while (j > 0 && isSgrParam(sv[j])) j--;
if (j >= 1 && sv[j - 1] == 0x1b && sv[j] == '[') { start = j - 1; continue; }
break;
}
return start;
}
static void appendSub(StringBuilder& out, const WTF::String& s, size_t a, size_t b)
{
if (a >= b) return;
if (s.is8Bit()) { auto sp = s.span8(); out.append(std::span { sp.data() + a, sp.data() + b }); }
else { auto sp = s.span16(); out.append(std::span { sp.data() + a, sp.data() + b }); }
}
// Insert suffix before trailing SGR (style inheritance for 'end').
static WTF::String appendWithInheritedStyle(const WTF::String& vis, const WTF::String& suffix)
{
StringView sv = vis.isNull() ? StringView() : StringView(vis);
size_t sgr = trailingSgrStart(sv);
StringBuilder r;
r.reserveCapacity(vis.length() + suffix.length());
if (sgr < sv.length()) { appendSub(r, vis, 0, sgr); r.append(suffix); appendSub(r, vis, sgr, sv.length()); }
else { r.append(vis); r.append(suffix); }
return r.toString();
}
// Insert prefix after leading SGR (style inheritance for 'start').
static WTF::String prependWithInheritedStyle(const WTF::String& prefix, const WTF::String& vis)
{
StringView sv = vis.isNull() ? StringView() : StringView(vis);
size_t sgr = leadingSgrEnd(sv);
StringBuilder r;
r.reserveCapacity(vis.length() + prefix.length());
if (sgr > 0) { appendSub(r, vis, 0, sgr); r.append(prefix); appendSub(r, vis, sgr, sv.length()); }
else { r.append(prefix); r.append(vis); }
return r.toString();
}
// ============================================================================
// preferTruncationOnSpace: find nearest space within 3 visible cols
// ============================================================================
template<typename Char>
static UChar visibleCharAt(const Char* input, size_t inputLen, size_t visIdx)
{
const Char* it = input;
const Char* end = input + inputLen;
size_t col = 0;
while (it < end) {
if (ANSI::isEscapeCharacter(*it)) { it = ANSI::consumeANSI(it, end); continue; }
size_t cLen;
char32_t cp = ANSI::decodeChar(it, end, cLen);
uint8_t w = ANSI::codepointWidth(cp, false);
if (w == 0) { it += cLen; continue; }
if (col == visIdx) return static_cast<UChar>(*it);
col += w;
it += cLen;
}
return 0;
}
template<typename Char>
static size_t nearestSpace(const Char* input, size_t inputLen, size_t idx, bool searchRight)
{
if (visibleCharAt(input, inputLen, idx) == ' ') return idx;
int dir = searchRight ? 1 : -1;
for (int i = 0; i <= 3; i++) {
int fi = static_cast<int>(idx) + i * dir;
if (fi < 0) continue;
if (visibleCharAt(input, inputLen, static_cast<size_t>(fi)) == ' ')
return static_cast<size_t>(fi);
}
return idx;
}
// ============================================================================
// Build effective truncation string (applying `space` option)
// ============================================================================
static WTF::String buildTruncChar(const TruncateOptions& opts)
{
static constexpr UChar ellipsis = 0x2026;
WTF::String base = opts.truncationCharacter.isNull()
? WTF::String(std::span<const UChar>(&ellipsis, 1))
: opts.truncationCharacter;
if (!opts.space) return base;
StringBuilder sb;
switch (opts.position) {
case TruncatePosition::End: sb.append(' '); sb.append(base); break;
case TruncatePosition::Start: sb.append(base); sb.append(' '); break;
case TruncatePosition::Middle: sb.append(' '); sb.append(base); sb.append(' '); break;
}
return sb.toString();
}
// ============================================================================
// Truncation by position
// ============================================================================
template<typename Char>
static WTF::String truncEnd(const Char* in, size_t inLen, size_t totalW,
size_t cols, const TruncateOptions& opts, const WTF::String& tc, size_t tcW)
{
if (opts.preferTruncationOnSpace) {
size_t sp = nearestSpace(in, inLen, cols - 1, false);
StringBuilder buf; sliceAnsi(in, inLen, 0, sp, buf);
return appendWithInheritedStyle(buf.toString(), tc);
}
StringBuilder buf; sliceAnsi(in, inLen, 0, cols - tcW, buf);
return appendWithInheritedStyle(buf.toString(), tc);
}
template<typename Char>
static WTF::String truncStart(const Char* in, size_t inLen, size_t totalW,
size_t cols, const TruncateOptions& opts, const WTF::String& tc, size_t tcW)
{
if (opts.preferTruncationOnSpace) {
size_t sp = nearestSpace(in, inLen, totalW - cols + 1, true);
StringBuilder buf; sliceAnsi(in, inLen, sp, totalW, buf);
// Trim leading visible whitespace
auto s = buf.toString();
auto sv = StringView(s);
size_t trim = 0;
for (size_t i = 0; i < sv.length(); i++) {
UChar c = sv[i];
if (c == 0x1b) { /* skip ANSI in trim scan */ break; }
if (c == ' ' || c == '\t') { trim++; continue; }
break;
}
if (trim > 0) {
StringBuilder trimmed;
appendSub(trimmed, s, trim, sv.length());
return prependWithInheritedStyle(tc, trimmed.toString());
}
return prependWithInheritedStyle(tc, s);
}
StringBuilder buf; sliceAnsi(in, inLen, totalW - cols + tcW, totalW, buf);
return prependWithInheritedStyle(tc, buf.toString());
}
template<typename Char>
static WTF::String truncMiddle(const Char* in, size_t inLen, size_t totalW,
size_t cols, const TruncateOptions& opts, const WTF::String& tc, size_t tcW)
{
size_t half = cols / 2;
if (opts.preferTruncationOnSpace) {
size_t sp1 = nearestSpace(in, inLen, half, false);
size_t sp2 = nearestSpace(in, inLen, totalW - (cols - half) + 1, true);
StringBuilder left; sliceAnsi(in, inLen, 0, sp1, left);
StringBuilder right; sliceAnsi(in, inLen, sp2, totalW, right);
// Trim leading whitespace from right
auto rs = right.toString(); auto rv = StringView(rs);
size_t trim = 0;
while (trim < rv.length() && (rv[trim] == ' ' || rv[trim] == '\t')) trim++;
StringBuilder r; r.append(left); r.append(tc);
if (trim > 0) appendSub(r, rs, trim, rv.length());
else r.append(rs);
return r.toString();
}
StringBuilder left; sliceAnsi(in, inLen, 0, half, left);
StringBuilder right; sliceAnsi(in, inLen, totalW - (cols - half) + tcW, totalW, right);
StringBuilder r; r.append(left); r.append(tc); r.append(right);
// For middle position, cli-truncate emits close codes for styles active at the
// end of the full string. The right slice already does this if non-empty, but
// when it's empty (cols is very small), we need to scan the full string.
if (right.isEmpty()) {
// Scan full string for final SGR state
const Char* it = in;
const Char* end = in + inLen;
SgrMap finalStyles;
while (it < end) {
if (ANSI::isEscapeCharacter(*it)) {
const Char* seqEnd = ANSI::consumeANSI(it, end);
updateSgrState(finalStyles, it, seqEnd);
it = seqEnd;
} else {
it += ANSI::charLength(it, end);
}
}
if (!finalStyles.isEmpty())
emitSgrCloses(finalStyles, r);
}
return r.toString();
}
// ============================================================================
// Entry point
// ============================================================================
template<typename Char>
static WTF::String truncateAnsiImpl(const Char* input, size_t inputLen,
size_t columns, const TruncateOptions& opts)
{
size_t totalWidth = ANSI::stringWidth(input, inputLen);
if (totalWidth <= columns) return WTF::String(); // null = no truncation
if (columns == 1) {
// columns=1: return just the base truncation character (no space applied)
static constexpr UChar ellipsis = 0x2026;
return opts.truncationCharacter.isNull()
? WTF::String(std::span<const UChar>(&ellipsis, 1))
: opts.truncationCharacter;
}
WTF::String tc = buildTruncChar(opts);
size_t tcW = wtfStringWidth(tc);
switch (opts.position) {
case TruncatePosition::End: return truncEnd(input, inputLen, totalWidth, columns, opts, tc, tcW);
case TruncatePosition::Start: return truncStart(input, inputLen, totalWidth, columns, opts, tc, tcW);
case TruncatePosition::Middle: return truncMiddle(input, inputLen, totalWidth, columns, opts, tc, tcW);
}
RELEASE_ASSERT_NOT_REACHED();
}
// ============================================================================
// JSC Host Function
// ============================================================================
static TruncatePosition parsePosition(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC::JSValue val)
{
if (!val.isString()) return TruncatePosition::End;
auto scope = DECLARE_THROW_SCOPE(vm);
const auto view = val.toString(globalObject)->view(globalObject);
RETURN_IF_EXCEPTION(scope, TruncatePosition::End);
if (view->length() == 0) return TruncatePosition::End;
UChar c = view->is8Bit() ? view->span8()[0] : view->span16()[0];
if (c == 's' || c == 'S') return TruncatePosition::Start;
if (c == 'm' || c == 'M') return TruncatePosition::Middle;
return TruncatePosition::End;
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunTruncateAnsi, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
// arg 0: text
JSC::JSString* jsString = callFrame->argument(0).toString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
// arg 1: columns
JSC::JSValue colVal = callFrame->argument(1);
if (!colVal.isNumber()) {
throwTypeError(globalObject, scope, "Expected columns to be a number"_s);
return {};
}
int32_t columns = colVal.toInt32(globalObject);
RETURN_IF_EXCEPTION(scope, {});
if (columns < 1)
return JSC::JSValue::encode(JSC::jsEmptyString(vm));
// arg 2: position string or options object
TruncateOptions opts;
JSC::JSValue arg2 = callFrame->argument(2);
if (arg2.isString()) {
opts.position = parsePosition(globalObject, vm, arg2);
RETURN_IF_EXCEPTION(scope, {});
} else if (arg2.isObject()) {
JSC::JSObject* obj = arg2.getObject();
opts.position = parsePosition(globalObject, vm,
obj->get(globalObject, JSC::Identifier::fromString(vm, "position"_s)));
RETURN_IF_EXCEPTION(scope, {});
JSC::JSValue v = obj->get(globalObject, JSC::Identifier::fromString(vm, "space"_s));
RETURN_IF_EXCEPTION(scope, {});
if (v.isBoolean()) opts.space = v.asBoolean();
v = obj->get(globalObject, JSC::Identifier::fromString(vm, "preferTruncationOnSpace"_s));
RETURN_IF_EXCEPTION(scope, {});
if (v.isBoolean()) opts.preferTruncationOnSpace = v.asBoolean();
v = obj->get(globalObject, JSC::Identifier::fromString(vm, "truncationCharacter"_s));
RETURN_IF_EXCEPTION(scope, {});
if (v.isString()) {
const auto tcView = v.toString(globalObject)->view(globalObject);
RETURN_IF_EXCEPTION(scope, {});
opts.truncationCharacter = tcView->toString();
}
}
const auto view = jsString->view(globalObject);
RETURN_IF_EXCEPTION(scope, {});
if (view->isEmpty())
return JSC::JSValue::encode(JSC::jsEmptyString(vm));
WTF::String result;
if (view->is8Bit())
result = truncateAnsiImpl(view->span8().data(), view->length(), static_cast<size_t>(columns), opts);
else
result = truncateAnsiImpl(view->span16().data(), view->length(), static_cast<size_t>(columns), opts);
if (result.isNull())
return JSC::JSValue::encode(jsString);
return JSC::JSValue::encode(JSC::jsString(vm, result));
}
} // namespace Bun

View File

@@ -0,0 +1,9 @@
#pragma once
#include "root.h"
namespace Bun {
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunTruncateAnsi);
}

View File

@@ -7,38 +7,20 @@
#include <wtf/Vector.h>
#include <cmath>
// Zig exports for visible width calculation
extern "C" size_t Bun__visibleWidthExcludeANSI_utf16(const uint16_t* ptr, size_t len, bool ambiguous_as_wide);
extern "C" size_t Bun__visibleWidthExcludeANSI_latin1(const uint8_t* ptr, size_t len);
extern "C" uint8_t Bun__codepointWidth(uint32_t cp, bool ambiguous_as_wide);
namespace Bun {
using namespace WTF;
// ============================================================================
// UTF-16 Decoding Utilities (needed for hard wrap with surrogate pairs)
// ============================================================================
// Use shared utilities from ANSIHelpers.h:
// ANSI::decodeUTF16, ANSI::codepointWidth, ANSI::stringWidth, ANSI::charLength
static char32_t decodeUTF16(const UChar* ptr, size_t available, size_t& outLen)
static inline char32_t decodeUTF16(const UChar* ptr, size_t available, size_t& outLen)
{
UChar c = ptr[0];
// Check for surrogate pair
if (c >= 0xD800 && c <= 0xDBFF && available >= 2) {
UChar c2 = ptr[1];
if (c2 >= 0xDC00 && c2 <= 0xDFFF) {
outLen = 2;
return 0x10000 + (((c - 0xD800) << 10) | (c2 - 0xDC00));
}
}
outLen = 1;
return static_cast<char32_t>(c);
return ANSI::decodeUTF16(ptr, available, outLen);
}
static inline uint8_t getVisibleWidth(char32_t cp, bool ambiguousIsWide)
{
return Bun__codepointWidth(cp, ambiguousIsWide);
return ANSI::codepointWidth(cp, ambiguousIsWide);
}
// Options for wrapping
@@ -49,25 +31,10 @@ struct WrapAnsiOptions {
bool ambiguousIsNarrow = true;
};
// ============================================================================
// String Width Calculation (using Zig implementation)
// ============================================================================
template<typename Char>
static size_t stringWidth(const Char* start, const Char* end, bool ambiguousIsNarrow)
{
size_t len = end - start;
if (len == 0)
return 0;
if constexpr (sizeof(Char) == 1) {
// 8-bit JSC strings are Latin1, not UTF-8
// Note: Latin1 doesn't have ambiguous width characters (all are in U+0000-U+00FF)
(void)ambiguousIsNarrow;
return Bun__visibleWidthExcludeANSI_latin1(reinterpret_cast<const uint8_t*>(start), len);
} else {
return Bun__visibleWidthExcludeANSI_utf16(reinterpret_cast<const uint16_t*>(start), len, !ambiguousIsNarrow);
}
return ANSI::stringWidth(start, end - start, !ambiguousIsNarrow);
}
// ============================================================================

View File

@@ -0,0 +1,180 @@
import { describe, expect, test } from "bun:test";
const truncateAnsi = Bun.truncateAnsi;
describe("Bun.truncateAnsi", () => {
test("main", () => {
expect(truncateAnsi("unicorn", 4)).toBe("uni\u2026");
expect(truncateAnsi("unicorn", 4, { position: "end" })).toBe("uni\u2026");
expect(truncateAnsi("unicorn", 1)).toBe("\u2026");
expect(truncateAnsi("unicorn", 0)).toBe("");
expect(truncateAnsi("unicorn", -4)).toBe("");
expect(truncateAnsi("unicorn", 20)).toBe("unicorn");
expect(truncateAnsi("unicorn", 7)).toBe("unicorn");
expect(truncateAnsi("unicorn", 6)).toBe("unico\u2026");
expect(truncateAnsi("\u001B[31municorn\u001B[39m", 7)).toBe("\u001B[31municorn\u001B[39m");
expect(truncateAnsi("\u001B[31municorn\u001B[39m", 1)).toBe("\u2026");
expect(truncateAnsi("\u001B[31municorn\u001B[39m", 4)).toBe("\u001B[31muni\u2026\u001B[39m");
expect(truncateAnsi("a\uD83C\uDE00b\uD83C\uDE00c", 5)).toBe("a\uD83C\uDE00b\u2026");
expect(truncateAnsi("\u5B89\u5B81\u54C8\u4E16\u754C", 3)).toBe("\u5B89\u2026");
expect(truncateAnsi("unicorn", 5, { position: "start" })).toBe("\u2026corn");
expect(truncateAnsi("unicorn", 6, { position: "start" })).toBe("\u2026icorn");
expect(truncateAnsi("unicorn", 5, { position: "middle" })).toBe("un\u2026rn");
expect(truncateAnsi("unicorns", 6, { position: "middle" })).toBe("uni\u2026ns");
expect(truncateAnsi("u", 1)).toBe("u");
});
test("space option", () => {
expect(truncateAnsi("unicorns", 5, { position: "end", space: true })).toBe("uni \u2026");
expect(truncateAnsi("unicorns", 6, { position: "start", space: true })).toBe("\u2026 orns");
expect(truncateAnsi("unicorns", 7, { position: "middle", space: true })).toBe("uni \u2026 s");
expect(truncateAnsi("unicorns", 5, { position: "end", space: false })).toBe("unic\u2026");
expect(truncateAnsi("\u001B[31municorn\u001B[39m", 6, { space: true })).toBe("\u001B[31munic \u2026\u001B[39m");
expect(truncateAnsi("Plant a tree every day.", 14, { space: true })).toBe("Plant a tree \u2026");
expect(truncateAnsi("\u5B89\u5B81\u54C8\u4E16\u754C", 4, { space: true })).toBe("\u5B89 \u2026");
expect(truncateAnsi("\u001B[31municorn\u001B[39m", 6, { position: "start", space: true })).toBe(
"\u001B[31m\u2026 corn\u001B[39m",
);
expect(truncateAnsi("\u001B[31municornsareawesome\u001B[39m", 10, { position: "middle", space: true })).toBe(
"\u001B[31munico\u001B[39m \u2026 \u001B[31mme\u001B[39m",
);
expect(truncateAnsi("Plant a tree every day.", 14, { position: "middle", space: true })).toBe(
"Plant a \u2026 day.",
);
expect(truncateAnsi("\u5B89\u5B81\u54C8\u4E16\u754C", 4, { position: "start", space: true })).toBe("\u2026 \u754C");
});
test("preferTruncationOnSpace option", () => {
expect(truncateAnsi("unicorns are awesome", 15, { position: "start", preferTruncationOnSpace: true })).toBe(
"\u2026are awesome",
);
expect(truncateAnsi("dragons are awesome", 15, { position: "end", preferTruncationOnSpace: true })).toBe(
"dragons are\u2026",
);
expect(truncateAnsi("unicorns rainbow dragons", 6, { position: "start", preferTruncationOnSpace: true })).toBe(
"\u2026agons",
);
expect(truncateAnsi("unicorns rainbow dragons", 6, { position: "end", preferTruncationOnSpace: true })).toBe(
"unico\u2026",
);
expect(
truncateAnsi("unicorns rainbow dragons", 6, {
position: "middle",
preferTruncationOnSpace: true,
}),
).toBe("uni\u2026ns");
expect(
truncateAnsi("unicorns partying with dragons", 20, {
position: "middle",
preferTruncationOnSpace: true,
}),
).toBe("unicorns\u2026dragons");
});
test("truncationCharacter option", () => {
expect(truncateAnsi("unicorns", 5, { position: "end", truncationCharacter: "." })).toBe("unic.");
expect(truncateAnsi("unicorns", 5, { position: "start", truncationCharacter: "." })).toBe(".orns");
expect(truncateAnsi("unicorns", 5, { position: "middle", truncationCharacter: "." })).toBe("un.ns");
expect(truncateAnsi("unicorns", 5, { position: "end", truncationCharacter: ".", space: true })).toBe("uni .");
expect(truncateAnsi("unicorns", 5, { position: "end", truncationCharacter: " ." })).toBe("uni .");
expect(
truncateAnsi("unicorns partying with dragons", 20, {
position: "middle",
truncationCharacter: ".",
preferTruncationOnSpace: true,
}),
).toBe("unicorns.dragons");
expect(
truncateAnsi("\u5B89\u5B81\u54C8\u4E16\u754C", 4, {
position: "start",
space: true,
truncationCharacter: ".",
}),
).toBe(". \u754C");
expect(
truncateAnsi("\u001B[31municornsareawesome\u001B[39m", 10, {
position: "middle",
space: true,
truncationCharacter: ".",
}),
).toBe("\u001B[31munico\u001B[39m . \u001B[31mme\u001B[39m");
});
test("custom truncation character inherits style (end/start)", () => {
const red = "\u001B[31m";
const reset = "\u001B[39m";
const text = `${red}unicorns${reset}`;
const endOut = truncateAnsi(text, 5, { truncationCharacter: "." });
const startOut = truncateAnsi(text, 5, { position: "start", truncationCharacter: "." });
expect(endOut.startsWith(red)).toBe(true);
expect(endOut.includes(".")).toBe(true);
expect(endOut.endsWith(reset)).toBe(true);
expect(startOut.startsWith(red)).toBe(true);
expect(startOut.includes(".")).toBe(true);
expect(startOut.endsWith(reset)).toBe(true);
});
test("styled truncation character inherits for start and end", () => {
const red = "\u001B[31m";
const cyan = "\u001B[36m";
const reset = "\u001B[39m";
// Test end position
const endText = `${red}unicorns${reset}`;
const endOut = truncateAnsi(endText, 5);
expect(endOut).toBe(`${red}unic\u2026${reset}`);
// Test start position
const startText = `hello ${cyan}unicorns${reset}`;
const startOut = truncateAnsi(startText, 5, { position: "start" });
expect(startOut.startsWith(cyan)).toBe(true);
expect(startOut.includes("\u2026")).toBe(true);
expect(startOut.endsWith(reset)).toBe(true);
});
test("edge cases", () => {
// Empty string
expect(truncateAnsi("", 5)).toBe("");
// Whitespace only
expect(truncateAnsi(" ", 3)).toBe(" \u2026");
// Multiple ANSI codes
const multiAnsi = "\u001B[31m\u001B[1municorns\u001B[22m\u001B[39m";
expect(truncateAnsi(multiAnsi, 5)).toBe("\u001B[31m\u001B[1munic\u2026\u001B[22m\u001B[39m");
// Columns = 2
expect(truncateAnsi("test", 2)).toBe("t\u2026");
// Very long truncation character
expect(truncateAnsi("unicorns", 5, { truncationCharacter: "..." })).toBe("un...");
});
test("preserves ANSI escape codes at the end - issue #24", () => {
const red = "\u001B[31m";
const reset = "\u001B[39m";
// Text with ANSI codes at the end
const text = `Hello ${red}World${reset}`;
// When not truncated, preserve everything
expect(truncateAnsi(text, 11)).toBe(`Hello ${red}World${reset}`);
// When truncated at the end, ellipsis should inherit the style
expect(truncateAnsi(text, 8)).toBe(`Hello ${red}W\u2026${reset}`);
// When truncated at start
expect(truncateAnsi(text, 8, { position: "start" })).toBe(`\u2026o ${red}World${reset}`);
// Text ending with reset only
const textEndingWithReset = `Hello World${reset}`;
expect(truncateAnsi(textEndingWithReset, 11)).toBe(`Hello World${reset}`);
expect(truncateAnsi(textEndingWithReset, 8)).toBe("Hello W\u2026");
});
test("position as string shorthand", () => {
expect(truncateAnsi("unicorn", 5, "start")).toBe("\u2026corn");
expect(truncateAnsi("unicorn", 5, "middle")).toBe("un\u2026rn");
expect(truncateAnsi("unicorn", 4, "end")).toBe("uni\u2026");
});
});