Files
bun.sh/test/js/bun/util/wrapAnsi.test.ts
SUZUKI Sosuke 44df912d37 Add Bun.wrapAnsi() for text wrapping with ANSI escape code preservation (#26061)
## Summary

Adds `Bun.wrapAnsi()`, a native implementation of the popular
[wrap-ansi](https://www.npmjs.com/package/wrap-ansi) npm package for
wrapping text with ANSI escape codes.

## API

```typescript
Bun.wrapAnsi(string: string, columns: number, options?: WrapAnsiOptions): string

interface WrapAnsiOptions {
  hard?: boolean;              // default: false - Break words longer than columns
  wordWrap?: boolean;          // default: true - Wrap at word boundaries
  trim?: boolean;              // default: true - Trim leading/trailing whitespace
  ambiguousIsNarrow?: boolean; // default: true - Treat ambiguous-width chars as narrow
}
```

## Features

- Wraps text to fit within specified column width
- Preserves ANSI escape codes (SGR colors/styles)
- Supports OSC 8 hyperlinks
- Respects Unicode display widths (full-width characters, emoji)
- Normalizes `\r\n` to `\n`

## Implementation Details

The implementation closes and reopens ANSI codes around line breaks for
robust terminal compatibility. This differs slightly from the npm
package in edge cases but produces visually equivalent output.

### Behavioral Differences from npm wrap-ansi

1. **ANSI code preservation**: Bun always maintains complete ANSI escape
sequences. The npm version can output malformed codes (missing ESC
character) in certain edge cases with `wordWrap: false, trim: false`.

2. **Newline ANSI handling**: Bun closes and reopens ANSI codes around
newlines for robustness. The npm version sometimes keeps them spanning
across newlines. The visual output is equivalent.

## Tests

- 27 custom tests covering basic functionality, ANSI codes, Unicode, and
options
- 23 tests ported from the npm package (MIT licensed, credited in file
header)
- All 50 tests pass

## Benchmark

<!-- Benchmark results will be added -->
```
$ cd /Users/sosuke/code/bun/bench && ../build/release/bun snippets/wrap-ansi.js
clk: ~3.82 GHz
cpu: Apple M4 Max
runtime: bun 1.3.7 (arm64-darwin)

benchmark                    avg (min … max) p75   p99    (min … top 1%)
-------------------------------------------- -------------------------------
Short text (45 chars) - npm    25.81 µs/iter  21.71 µs  █
                      (16.79 µs … 447.38 µs) 110.96 µs ▆█▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
Short text (45 chars) - Bun   685.55 ns/iter 667.00 ns    █
                       (459.00 ns … 2.16 ms)   1.42 µs ▁▁▁█▃▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁

summary
  Short text (45 chars) - Bun
   37.65x faster than Short text (45 chars) - npm

-------------------------------------------- -------------------------------
Medium text (810 chars) - npm 568.12 µs/iter 578.00 µs  ▄▅█▆▆▃
                     (525.25 µs … 944.71 µs) 700.75 µs ▄██████▆▅▄▃▃▂▂▂▁▁▁▁▁▁
Medium text (810 chars) - Bun  11.22 µs/iter  11.28 µs                     █
                       (11.04 µs … 11.46 µs)  11.33 µs █▁▁▁██▁█▁▁▁▁█▁█▁▁█▁▁█

summary
  Medium text (810 chars) - Bun
   50.62x faster than Medium text (810 chars) - npm

-------------------------------------------- -------------------------------
Long text (8100 chars) - npm    7.66 ms/iter   7.76 ms     ▂▂▅█   ▅
                         (7.31 ms … 8.10 ms)   8.06 ms ▃▃▄▃█████▇▇███▃▆▆▆▄▁▃
Long text (8100 chars) - Bun  112.14 µs/iter 113.50 µs        █
                     (102.50 µs … 146.04 µs) 124.92 µs ▁▁▁▁▁▁██▇▅█▃▂▂▂▂▁▁▁▁▁

summary
  Long text (8100 chars) - Bun
   68.27x faster than Long text (8100 chars) - npm

-------------------------------------------- -------------------------------
Colored short - npm            28.46 µs/iter  28.56 µs              █
                       (27.90 µs … 29.34 µs)  28.93 µs ▆▁▆▁▁▆▁▁▆▆▆▁▆█▁▁▁▁▁▁▆
Colored short - Bun           861.64 ns/iter 867.54 ns         ▂  ▇█▄▂
                     (839.68 ns … 891.12 ns) 882.04 ns ▃▅▄▅▆▆▇▆██▇████▆▃▅▅▅▂

summary
  Colored short - Bun
   33.03x faster than Colored short - npm

-------------------------------------------- -------------------------------
Colored medium - npm          557.84 µs/iter 562.63 µs      ▂▃█▄
                     (508.08 µs … 911.92 µs) 637.96 µs ▁▁▁▂▄█████▅▂▂▁▁▁▁▁▁▁▁
Colored medium - Bun           14.91 µs/iter  14.94 µs ██  ████ ██ █      ██
                       (14.77 µs … 15.17 µs)  15.06 µs ██▁▁████▁██▁█▁▁▁▁▁▁██

summary
  Colored medium - Bun
   37.41x faster than Colored medium - npm

-------------------------------------------- -------------------------------
Colored long - npm              7.84 ms/iter   7.90 ms       █  ▅
                         (7.53 ms … 8.38 ms)   8.19 ms ▂▂▂▄▃▆██▇██▇▃▂▃▃▃▄▆▂▂
Colored long - Bun            176.73 µs/iter 175.42 µs       █
                       (162.50 µs … 1.37 ms) 204.46 µs ▁▁▂▄▇██▅▂▂▂▁▁▁▁▁▁▁▁▁▁

summary
  Colored long - Bun
   44.37x faster than Colored long - npm

-------------------------------------------- -------------------------------
Hard wrap long - npm            8.05 ms/iter   8.12 ms       ▃ ▇█
                         (7.67 ms … 8.53 ms)   8.50 ms ▄▁▁▁▃▄█████▄▃▂▆▄▃▂▂▂▂
Hard wrap long - Bun          111.85 µs/iter 112.33 µs         ▇█
                     (101.42 µs … 145.42 µs) 123.88 µs ▁▁▁▁▁▁▁████▄▃▂▂▂▁▁▁▁▁

summary
  Hard wrap long - Bun
   72.01x faster than Hard wrap long - npm

-------------------------------------------- -------------------------------
Hard wrap colored - npm         8.82 ms/iter   8.92 ms   ▆ ██
                         (8.55 ms … 9.47 ms)   9.32 ms ▆▆████▆▆▄▆█▄▆▄▄▁▃▁▃▄▃
Hard wrap colored - Bun       174.38 µs/iter 175.54 µs   █ ▂
                     (165.75 µs … 210.25 µs) 199.50 µs ▁▃█▆███▃▂▃▂▂▂▂▂▁▁▁▁▁▁

summary
  Hard wrap colored - Bun
   50.56x faster than Hard wrap colored - npm

-------------------------------------------- -------------------------------
Japanese (full-width) - npm    51.00 µs/iter  52.67 µs    █▂   █▄
                      (40.71 µs … 344.88 µs)  66.13 µs ▁▁▃██▄▃▅██▇▄▃▄▃▂▂▁▁▁▁
Japanese (full-width) - Bun     7.46 µs/iter   7.46 µs       █
                        (6.50 µs … 34.92 µs)   9.38 µs ▁▁▁▁▁██▆▂▁▂▁▁▁▁▁▁▁▁▁▁

summary
  Japanese (full-width) - Bun
   6.84x faster than Japanese (full-width) - npm

-------------------------------------------- -------------------------------
Emoji text - npm              173.63 µs/iter 222.17 µs   █
                     (129.42 µs … 527.25 µs) 249.58 µs ▁▃█▆▃▃▃▁▁▁▁▁▁▁▂▄▆▄▂▂▁
Emoji text - Bun                9.42 µs/iter   9.47 µs           ██
                         (9.32 µs … 9.52 µs)   9.50 µs █▁▁███▁▁█▁██▁▁▁▁██▁▁█

summary
  Emoji text - Bun
   18.44x faster than Emoji text - npm

-------------------------------------------- -------------------------------
Hyperlink (OSC 8) - npm       208.00 µs/iter 254.25 µs   █
                     (169.58 µs … 542.17 µs) 281.00 µs ▁▇█▃▃▂▂▂▁▁▁▁▁▁▁▃▃▅▃▂▁
Hyperlink (OSC 8) - Bun         6.00 µs/iter   6.06 µs      █           ▄
                         (5.88 µs … 6.11 µs)   6.10 µs ▅▅▅▁▅█▅▁▅▁█▁▁▅▅▅▅█▅▁█

summary
  Hyperlink (OSC 8) - Bun
   34.69x faster than Hyperlink (OSC 8) - npm

-------------------------------------------- -------------------------------
No trim long - npm              8.32 ms/iter   8.38 ms  █▇
                        (7.61 ms … 13.67 ms)  11.74 ms ▃████▄▂▃▂▂▃▁▁▁▁▁▁▁▁▁▂
No trim long - Bun             93.92 µs/iter  94.42 µs           █▂
                      (82.75 µs … 162.38 µs) 103.83 µs ▁▁▁▁▁▁▁▁▄███▄▃▂▂▁▁▁▁▁

summary
  No trim long - Bun
   88.62x faster than No trim long - npm
```

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-16 16:12:23 -08:00

237 lines
8.0 KiB
TypeScript

import { describe, expect, test } from "bun:test";
describe("Bun.wrapAnsi", () => {
describe("basic wrapping", () => {
test("wraps text at word boundaries", () => {
expect(Bun.wrapAnsi("hello world", 5)).toBe("hello\nworld");
});
test("handles empty string", () => {
expect(Bun.wrapAnsi("", 10)).toBe("");
});
test("no wrapping needed", () => {
expect(Bun.wrapAnsi("hello", 10)).toBe("hello");
});
test("wraps multiple words", () => {
expect(Bun.wrapAnsi("one two three four", 8)).toBe("one two\nthree\nfour");
});
test("handles single long word", () => {
// Without hard mode, word stays on one line
expect(Bun.wrapAnsi("abcdefghij", 5)).toBe("abcdefghij");
});
test("handles columns = 0", () => {
// Edge case: should return original string
expect(Bun.wrapAnsi("hello", 0)).toBe("hello");
});
});
describe("hard wrap option", () => {
test("breaks long words in middle", () => {
expect(Bun.wrapAnsi("abcdefgh", 3, { hard: true })).toBe("abc\ndef\ngh");
});
test("breaks very long word", () => {
expect(Bun.wrapAnsi("abcdefghij", 4, { hard: true })).toBe("abcd\nefgh\nij");
});
});
describe("wordWrap option", () => {
test("wordWrap false disables wrapping", () => {
// Without wordWrap, only explicit newlines should cause breaks
const result = Bun.wrapAnsi("hello world", 5, { wordWrap: false });
// The behavior may vary - just check it doesn't crash
expect(typeof result).toBe("string");
});
});
describe("trim option", () => {
test("trims leading whitespace by default", () => {
expect(Bun.wrapAnsi(" hello", 10)).toBe("hello");
});
test("trim false preserves leading whitespace", () => {
expect(Bun.wrapAnsi(" hello", 10, { trim: false })).toBe(" hello");
});
});
describe("ANSI escape codes", () => {
test("preserves simple color code", () => {
const input = "\x1b[31mhello\x1b[0m";
const result = Bun.wrapAnsi(input, 10);
expect(result).toContain("\x1b[31m");
expect(result).toContain("hello");
});
test("preserves color across line break", () => {
const input = "\x1b[31mhello world\x1b[0m";
const result = Bun.wrapAnsi(input, 5);
// Should have close code (39) before newline and restore (31) after
expect(result).toContain("\x1b[39m\n");
expect(result).toContain("\n\x1b[31m");
});
test("handles multiple colors", () => {
const input = "\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m";
const result = Bun.wrapAnsi(input, 20);
expect(result).toContain("red");
expect(result).toContain("green");
});
test("handles bold and styles", () => {
const input = "\x1b[1mbold\x1b[0m";
const result = Bun.wrapAnsi(input, 10);
expect(result).toContain("\x1b[1m");
expect(result).toContain("bold");
});
test("ANSI codes don't count toward width", () => {
const input = "\x1b[31mab\x1b[0m";
// ANSI codes should not count toward width
// "ab" is 2 chars, should fit in width 2
expect(Bun.wrapAnsi(input, 2)).toBe(input);
});
});
describe("Unicode support", () => {
test("handles full-width characters", () => {
// 日本語 characters are 2 columns each
const input = "日本";
// "日本" is 4 columns (2 chars * 2 width each)
const result = Bun.wrapAnsi(input, 4);
expect(result).toBe("日本");
});
test("wraps full-width characters with hard", () => {
const input = "日本語";
// Each char is 2 columns, so "日本語" is 6 columns
// Width 4 means we can fit 2 chars per line (with hard wrap)
const result = Bun.wrapAnsi(input, 4, { hard: true });
expect(result).toContain("\n");
expect(result).toBe("日本\n語");
});
test("does not wrap full-width characters without hard", () => {
const input = "日本語";
// Without hard, long words are not broken
const result = Bun.wrapAnsi(input, 4);
expect(result).toBe("日本語");
});
test("handles emoji", () => {
const input = "hello 👋 world";
const result = Bun.wrapAnsi(input, 20);
expect(result).toContain("👋");
});
});
describe("existing newlines", () => {
test("preserves existing newlines", () => {
const input = "hello\nworld";
const result = Bun.wrapAnsi(input, 10);
expect(result).toBe("hello\nworld");
});
test("wraps within lines separated by newlines", () => {
const input = "hello world\nfoo bar";
const result = Bun.wrapAnsi(input, 5);
expect(result.split("\n").length).toBeGreaterThan(2);
});
});
describe("edge cases", () => {
test("handles tabs", () => {
const input = "a\tb";
const result = Bun.wrapAnsi(input, 10);
expect(typeof result).toBe("string");
});
test("handles Windows line endings", () => {
const input = "hello\r\nworld";
const result = Bun.wrapAnsi(input, 10);
expect(typeof result).toBe("string");
});
test("handles consecutive spaces", () => {
const input = "hello world";
const result = Bun.wrapAnsi(input, 10);
expect(typeof result).toBe("string");
});
});
describe("ambiguousIsNarrow option", () => {
test("default treats ambiguous as narrow", () => {
// By default, ambiguous width chars should be treated as width 1
const result1 = Bun.wrapAnsi("αβγ", 3);
// Greek letters are ambiguous width
expect(typeof result1).toBe("string");
});
test("ambiguousIsNarrow false treats as wide", () => {
const result = Bun.wrapAnsi("αβγ", 3, { ambiguousIsNarrow: false });
expect(typeof result).toBe("string");
});
});
describe("edge cases for columns", () => {
test("negative columns returns input unchanged", () => {
expect(Bun.wrapAnsi("hello world", -5)).toBe("hello world");
expect(Bun.wrapAnsi("hello world", -Infinity)).toBe("hello world");
});
test("Infinity columns returns input unchanged", () => {
expect(Bun.wrapAnsi("hello world", Infinity)).toBe("hello world");
});
test("NaN columns returns input unchanged", () => {
expect(Bun.wrapAnsi("hello world", NaN)).toBe("hello world");
});
});
describe("width tracking", () => {
test("width tracking after line wrap with full-width chars", () => {
// Each full-width character has width 2
const input = "あいうえお"; // 5 chars, total width 10
const result = Bun.wrapAnsi(input, 4, { hard: true });
// Width 4 allows 2 full-width chars per line: "あい"(4), "うえ"(4), "お"(2)
expect(result).toBe("あい\nうえ\nお");
});
test("width tracking with mixed width chars", () => {
// ASCII(width 1) and full-width(width 2) mixed
const input = "aあbい"; // widths: 1+2+1+2 = 6
const result = Bun.wrapAnsi(input, 3, { hard: true });
// "aあ"(3) on line 1, "bい"(3) on line 2
expect(result).toBe("aあ\nbい");
});
});
describe("extended SGR codes", () => {
test("256-color preserved across line wrap", () => {
const input = "\x1b[38;5;196mRed text here\x1b[0m";
const result = Bun.wrapAnsi(input, 5);
// 256-color sequences should not be closed/reopened at line breaks
expect(result).toBe("\x1b[38;5;196mRed\ntext\nhere\x1b[0m");
});
test("TrueColor preserved across line wrap", () => {
const input = "\x1b[38;2;255;128;0mOrange text\x1b[0m";
const result = Bun.wrapAnsi(input, 6);
// TrueColor sequences should not be closed/reopened at line breaks
expect(result).toBe("\x1b[38;2;255;128;0mOrange\ntext\x1b[0m");
});
test("multiple styles (bold + color) preserved", () => {
const input = "\x1b[1m\x1b[31mBold Red text here\x1b[0m";
const result = Bun.wrapAnsi(input, 5);
// Bold stays, color closes with 39 and reopens with 31
expect(result).toBe(
"\x1b[1m\x1b[31mBold\x1b[39m\n\x1b[31mRed\x1b[39m\n\x1b[31mtext\x1b[39m\n\x1b[31mhere\x1b[0m",
);
});
});
});