mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
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>
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"tinycolor2": "^1.6.0",
|
"tinycolor2": "^1.6.0",
|
||||||
|
"wrap-ansi": "^9.0.0",
|
||||||
"zx": "^7.2.3",
|
"zx": "^7.2.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -169,7 +170,7 @@
|
|||||||
|
|
||||||
"ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
"ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"ansi-styles": ["ansi-styles@6.2.3", "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
@@ -493,6 +494,8 @@
|
|||||||
|
|
||||||
"which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="],
|
"which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@9.0.2", "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.3.4", "", {}, "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA=="],
|
"yaml": ["yaml@2.3.4", "", {}, "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA=="],
|
||||||
@@ -503,8 +506,6 @@
|
|||||||
|
|
||||||
"@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
"@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||||
|
|
||||||
"ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
|
||||||
|
|
||||||
"avvio/fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
"avvio/fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
@@ -517,6 +518,10 @@
|
|||||||
|
|
||||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||||
|
|
||||||
"ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
"@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
|
|
||||||
|
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
|
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"string-width": "7.1.0",
|
"string-width": "7.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"tinycolor2": "^1.6.0",
|
"tinycolor2": "^1.6.0",
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export function run(opts = {}) {
|
|||||||
|
|
||||||
export const bench = Mitata.bench;
|
export const bench = Mitata.bench;
|
||||||
export const group = Mitata.group;
|
export const group = Mitata.group;
|
||||||
|
export const summary = Mitata.summary;
|
||||||
|
|||||||
103
bench/snippets/wrap-ansi.js
Normal file
103
bench/snippets/wrap-ansi.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import wrapAnsi from "wrap-ansi";
|
||||||
|
import { bench, run, summary } from "../runner.mjs";
|
||||||
|
|
||||||
|
// Test fixtures
|
||||||
|
const shortText = "The quick brown fox jumped over the lazy dog.";
|
||||||
|
const mediumText = "The quick brown fox jumped over the lazy dog and then ran away with the unicorn. ".repeat(10);
|
||||||
|
const longText = "The quick brown fox jumped over the lazy dog and then ran away with the unicorn. ".repeat(100);
|
||||||
|
|
||||||
|
// ANSI colored text
|
||||||
|
const red = s => `\u001B[31m${s}\u001B[39m`;
|
||||||
|
const green = s => `\u001B[32m${s}\u001B[39m`;
|
||||||
|
const blue = s => `\u001B[34m${s}\u001B[39m`;
|
||||||
|
|
||||||
|
const coloredShort = `The quick ${red("brown fox")} jumped over the ${green("lazy dog")}.`;
|
||||||
|
const coloredMedium =
|
||||||
|
`The quick ${red("brown fox jumped over")} the ${green("lazy dog and then ran away")} with the ${blue("unicorn")}. `.repeat(
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const coloredLong =
|
||||||
|
`The quick ${red("brown fox jumped over")} the ${green("lazy dog and then ran away")} with the ${blue("unicorn")}. `.repeat(
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full-width characters (Japanese)
|
||||||
|
const japaneseText = "日本語のテキストを折り返すテストです。全角文字は幅2としてカウントされます。".repeat(5);
|
||||||
|
|
||||||
|
// Emoji text
|
||||||
|
const emojiText = "Hello 👋 World 🌍! Let's test 🧪 some emoji 😀 wrapping 📦!".repeat(5);
|
||||||
|
|
||||||
|
// Hyperlink text
|
||||||
|
const hyperlinkText = "Check out \u001B]8;;https://bun.sh\u0007Bun\u001B]8;;\u0007, it's fast! ".repeat(10);
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const hardOpts = { hard: true };
|
||||||
|
const noTrimOpts = { trim: false };
|
||||||
|
|
||||||
|
// Basic text benchmarks
|
||||||
|
summary(() => {
|
||||||
|
bench("Short text (45 chars) - npm", () => wrapAnsi(shortText, 20));
|
||||||
|
bench("Short text (45 chars) - Bun", () => Bun.wrapAnsi(shortText, 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Medium text (810 chars) - npm", () => wrapAnsi(mediumText, 40));
|
||||||
|
bench("Medium text (810 chars) - Bun", () => Bun.wrapAnsi(mediumText, 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Long text (8100 chars) - npm", () => wrapAnsi(longText, 80));
|
||||||
|
bench("Long text (8100 chars) - Bun", () => Bun.wrapAnsi(longText, 80));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ANSI colored text benchmarks
|
||||||
|
summary(() => {
|
||||||
|
bench("Colored short - npm", () => wrapAnsi(coloredShort, 20));
|
||||||
|
bench("Colored short - Bun", () => Bun.wrapAnsi(coloredShort, 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Colored medium - npm", () => wrapAnsi(coloredMedium, 40));
|
||||||
|
bench("Colored medium - Bun", () => Bun.wrapAnsi(coloredMedium, 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Colored long - npm", () => wrapAnsi(coloredLong, 80));
|
||||||
|
bench("Colored long - Bun", () => Bun.wrapAnsi(coloredLong, 80));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hard wrap benchmarks
|
||||||
|
summary(() => {
|
||||||
|
bench("Hard wrap long - npm", () => wrapAnsi(longText, 80, hardOpts));
|
||||||
|
bench("Hard wrap long - Bun", () => Bun.wrapAnsi(longText, 80, hardOpts));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Hard wrap colored - npm", () => wrapAnsi(coloredLong, 80, hardOpts));
|
||||||
|
bench("Hard wrap colored - Bun", () => Bun.wrapAnsi(coloredLong, 80, hardOpts));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unicode benchmarks
|
||||||
|
summary(() => {
|
||||||
|
bench("Japanese (full-width) - npm", () => wrapAnsi(japaneseText, 40));
|
||||||
|
bench("Japanese (full-width) - Bun", () => Bun.wrapAnsi(japaneseText, 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
summary(() => {
|
||||||
|
bench("Emoji text - npm", () => wrapAnsi(emojiText, 30));
|
||||||
|
bench("Emoji text - Bun", () => Bun.wrapAnsi(emojiText, 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hyperlink benchmarks
|
||||||
|
summary(() => {
|
||||||
|
bench("Hyperlink (OSC 8) - npm", () => wrapAnsi(hyperlinkText, 40));
|
||||||
|
bench("Hyperlink (OSC 8) - Bun", () => Bun.wrapAnsi(hyperlinkText, 40));
|
||||||
|
});
|
||||||
|
|
||||||
|
// No trim option
|
||||||
|
summary(() => {
|
||||||
|
bench("No trim long - npm", () => wrapAnsi(longText, 80, noTrimOpts));
|
||||||
|
bench("No trim long - Bun", () => Bun.wrapAnsi(longText, 80, noTrimOpts));
|
||||||
|
});
|
||||||
|
|
||||||
|
await run();
|
||||||
91
packages/bun-types/bun.d.ts
vendored
91
packages/bun-types/bun.d.ts
vendored
@@ -610,6 +610,97 @@ declare module "bun" {
|
|||||||
*/
|
*/
|
||||||
function stripANSI(input: string): string;
|
function stripANSI(input: string): string;
|
||||||
|
|
||||||
|
interface WrapAnsiOptions {
|
||||||
|
/**
|
||||||
|
* If `true`, break words in the middle if they don't fit on a line.
|
||||||
|
* If `false`, only break at word boundaries.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
hard?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `true`, wrap at word boundaries when possible.
|
||||||
|
* If `false`, don't perform word wrapping (only wrap at explicit newlines).
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
wordWrap?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `true`, trim leading and trailing whitespace from each line.
|
||||||
|
* If `false`, preserve whitespace.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
trim?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When it's ambiguous and `true`, count ambiguous width characters as 1 character wide.
|
||||||
|
* If `false`, count them as 2 characters wide.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
ambiguousIsNarrow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a string to fit within the specified column width, preserving ANSI escape codes.
|
||||||
|
*
|
||||||
|
* This function is designed to be compatible with the popular "wrap-ansi" NPM package.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Preserves ANSI escape codes (colors, styles) across line breaks
|
||||||
|
* - Supports SGR codes (colors, bold, italic, etc.) and OSC 8 hyperlinks
|
||||||
|
* - Respects Unicode display widths (full-width characters, emoji)
|
||||||
|
* - Word wrapping at word boundaries (configurable)
|
||||||
|
*
|
||||||
|
* @category Utilities
|
||||||
|
*
|
||||||
|
* @param input The string to wrap
|
||||||
|
* @param columns The maximum column width
|
||||||
|
* @param options Wrapping options
|
||||||
|
* @returns The wrapped string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { wrapAnsi } from "bun";
|
||||||
|
*
|
||||||
|
* console.log(wrapAnsi("hello world", 5));
|
||||||
|
* // Output:
|
||||||
|
* // hello
|
||||||
|
* // world
|
||||||
|
*
|
||||||
|
* // Preserves ANSI colors across line breaks
|
||||||
|
* console.log(wrapAnsi("\u001b[31mhello world\u001b[0m", 5));
|
||||||
|
* // Output:
|
||||||
|
* // \u001b[31mhello\u001b[0m
|
||||||
|
* // \u001b[31mworld\u001b[0m
|
||||||
|
*
|
||||||
|
* // Hard wrap long words
|
||||||
|
* console.log(wrapAnsi("abcdefghij", 3, { hard: true }));
|
||||||
|
* // Output:
|
||||||
|
* // abc
|
||||||
|
* // def
|
||||||
|
* // ghi
|
||||||
|
* // j
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function wrapAnsi(
|
||||||
|
/**
|
||||||
|
* The string to wrap
|
||||||
|
*/
|
||||||
|
input: string,
|
||||||
|
/**
|
||||||
|
* The maximum column width
|
||||||
|
*/
|
||||||
|
columns: number,
|
||||||
|
/**
|
||||||
|
* Wrapping options
|
||||||
|
*/
|
||||||
|
options?: WrapAnsiOptions,
|
||||||
|
): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOML related APIs
|
* TOML related APIs
|
||||||
*/
|
*/
|
||||||
|
|||||||
190
src/bun.js/bindings/ANSIHelpers.h
Normal file
190
src/bun.js/bindings/ANSIHelpers.h
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "root.h"
|
||||||
|
#include <wtf/SIMDHelpers.h>
|
||||||
|
|
||||||
|
namespace Bun {
|
||||||
|
namespace ANSI {
|
||||||
|
|
||||||
|
// Check if a character is an ANSI escape sequence introducer
|
||||||
|
template<typename Char>
|
||||||
|
static inline bool isEscapeCharacter(Char c)
|
||||||
|
{
|
||||||
|
switch (c) {
|
||||||
|
case 0x1b: // ESC - escape
|
||||||
|
case 0x9b: // CSI - control sequence introducer
|
||||||
|
case 0x9d: // OSC - operating system command
|
||||||
|
case 0x90: // DCS - device control string
|
||||||
|
case 0x98: // SOS - start of string
|
||||||
|
case 0x9e: // PM - privacy message
|
||||||
|
case 0x9f: // APC - application program command
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first escape character in a string using SIMD
|
||||||
|
template<typename Char>
|
||||||
|
static const Char* findEscapeCharacter(const Char* start, const Char* end)
|
||||||
|
{
|
||||||
|
static_assert(sizeof(Char) == 1 || sizeof(Char) == 2);
|
||||||
|
using SIMDType = std::conditional_t<sizeof(Char) == 1, uint8_t, uint16_t>;
|
||||||
|
|
||||||
|
constexpr size_t stride = SIMD::stride<SIMDType>;
|
||||||
|
// Matches 0x10-0x1f and 0x90-0x9f. These characters have a high
|
||||||
|
// probability of being escape characters.
|
||||||
|
constexpr auto escMask = SIMD::splat<SIMDType>(static_cast<SIMDType>(~0b10001111U));
|
||||||
|
constexpr auto escVector = SIMD::splat<SIMDType>(0b00010000);
|
||||||
|
|
||||||
|
auto it = start;
|
||||||
|
// Search for escape sequences using SIMD
|
||||||
|
for (; end - it >= static_cast<ptrdiff_t>(stride); it += stride) {
|
||||||
|
const auto chunk = SIMD::load(reinterpret_cast<const SIMDType*>(it));
|
||||||
|
const auto chunkMasked = SIMD::bitAnd(chunk, escMask);
|
||||||
|
const auto chunkIsEsc = SIMD::equal(chunkMasked, escVector);
|
||||||
|
if (const auto index = SIMD::findFirstNonZeroIndex(chunkIsEsc))
|
||||||
|
return it + *index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check remaining characters
|
||||||
|
for (; it != end; ++it) {
|
||||||
|
if (isEscapeCharacter(*it))
|
||||||
|
return it;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an ANSI escape sequence that starts at `start`. Returns a pointer to
|
||||||
|
// the first byte immediately following the escape sequence.
|
||||||
|
//
|
||||||
|
// If the ANSI escape sequence is immediately followed by another escape
|
||||||
|
// sequence, this function will consume that one as well, and so on.
|
||||||
|
template<typename Char>
|
||||||
|
static const Char* consumeANSI(const Char* start, const Char* end)
|
||||||
|
{
|
||||||
|
enum class State {
|
||||||
|
start,
|
||||||
|
gotEsc,
|
||||||
|
ignoreNextChar,
|
||||||
|
inCsi,
|
||||||
|
inOsc,
|
||||||
|
inOscGotEsc,
|
||||||
|
needSt,
|
||||||
|
needStGotEsc,
|
||||||
|
};
|
||||||
|
|
||||||
|
auto state = State::start;
|
||||||
|
for (auto it = start; it != end; ++it) {
|
||||||
|
const auto c = *it;
|
||||||
|
switch (state) {
|
||||||
|
case State::start:
|
||||||
|
switch (c) {
|
||||||
|
case 0x1b:
|
||||||
|
state = State::gotEsc;
|
||||||
|
break;
|
||||||
|
case 0x9b:
|
||||||
|
state = State::inCsi;
|
||||||
|
break;
|
||||||
|
case 0x9d:
|
||||||
|
state = State::inOsc;
|
||||||
|
break;
|
||||||
|
// Other sequences terminated by ST, from ECMA-48, 5th ed.
|
||||||
|
case 0x90: // device control string
|
||||||
|
case 0x98: // start of string
|
||||||
|
case 0x9e: // privacy message
|
||||||
|
case 0x9f: // application program command
|
||||||
|
state = State::needSt;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return it;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::gotEsc:
|
||||||
|
switch (c) {
|
||||||
|
case '[':
|
||||||
|
state = State::inCsi;
|
||||||
|
break;
|
||||||
|
// Two-byte XTerm sequences
|
||||||
|
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
||||||
|
case ' ':
|
||||||
|
case '#':
|
||||||
|
case '%':
|
||||||
|
case '(':
|
||||||
|
case ')':
|
||||||
|
case '*':
|
||||||
|
case '+':
|
||||||
|
case '.':
|
||||||
|
case '/':
|
||||||
|
state = State::ignoreNextChar;
|
||||||
|
break;
|
||||||
|
case ']':
|
||||||
|
state = State::inOsc;
|
||||||
|
break;
|
||||||
|
// Other sequences terminated by ST, from ECMA-48, 5th ed.
|
||||||
|
case 'P': // device control string
|
||||||
|
case 'X': // start of string
|
||||||
|
case '^': // privacy message
|
||||||
|
case '_': // application program command
|
||||||
|
state = State::needSt;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Otherwise, assume this is a one-byte sequence
|
||||||
|
state = State::start;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::ignoreNextChar:
|
||||||
|
state = State::start;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::inCsi:
|
||||||
|
// ECMA-48, 5th ed. §5.4 d)
|
||||||
|
if (c >= 0x40 && c <= 0x7e)
|
||||||
|
state = State::start;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::inOsc:
|
||||||
|
switch (c) {
|
||||||
|
case 0x1b:
|
||||||
|
state = State::inOscGotEsc;
|
||||||
|
break;
|
||||||
|
case 0x9c: // ST
|
||||||
|
case 0x07: // XTerm can also end OSC with 0x07
|
||||||
|
state = State::start;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::inOscGotEsc:
|
||||||
|
if (c == '\\')
|
||||||
|
state = State::start;
|
||||||
|
else
|
||||||
|
state = State::inOsc;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::needSt:
|
||||||
|
switch (c) {
|
||||||
|
case 0x1b:
|
||||||
|
state = State::needStGotEsc;
|
||||||
|
break;
|
||||||
|
case 0x9c:
|
||||||
|
state = State::start;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::needStGotEsc:
|
||||||
|
if (c == '\\')
|
||||||
|
state = State::start;
|
||||||
|
else
|
||||||
|
state = State::needSt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ANSI
|
||||||
|
} // namespace Bun
|
||||||
@@ -77,6 +77,7 @@ BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv5);
|
|||||||
|
|
||||||
namespace Bun {
|
namespace Bun {
|
||||||
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI);
|
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI);
|
||||||
|
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunWrapAnsi);
|
||||||
}
|
}
|
||||||
|
|
||||||
using namespace JSC;
|
using namespace JSC;
|
||||||
@@ -802,6 +803,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
|
|||||||
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
|
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
|
||||||
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
|
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
|
||||||
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
|
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
|
||||||
|
wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3
|
||||||
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
|
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
|
||||||
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
|
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
|
||||||
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
|
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
|
||||||
|
|||||||
@@ -1,194 +1,13 @@
|
|||||||
#include "root.h"
|
#include "root.h"
|
||||||
#include "stripANSI.h"
|
#include "stripANSI.h"
|
||||||
|
#include "ANSIHelpers.h"
|
||||||
|
|
||||||
#include <wtf/text/WTFString.h>
|
#include <wtf/text/WTFString.h>
|
||||||
#include <wtf/text/StringBuilder.h>
|
#include <wtf/text/StringBuilder.h>
|
||||||
#include <wtf/SIMDHelpers.h>
|
|
||||||
|
|
||||||
namespace Bun {
|
namespace Bun {
|
||||||
using namespace WTF;
|
using namespace WTF;
|
||||||
|
|
||||||
template<typename Char>
|
|
||||||
static inline bool isEscapeCharacter(const Char c)
|
|
||||||
{
|
|
||||||
switch (c) {
|
|
||||||
case 0x1b: // escape
|
|
||||||
case 0x9b: // control sequence introducer
|
|
||||||
case 0x9d: // operating system command
|
|
||||||
case 0x90: // device control string
|
|
||||||
case 0x98: // start of string
|
|
||||||
case 0x9e: // privacy message
|
|
||||||
case 0x9f: // application program command
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename Char>
|
|
||||||
static const Char* findEscapeCharacter(const Char* const start, const Char* const end)
|
|
||||||
{
|
|
||||||
static_assert(sizeof(Char) == 1 || sizeof(Char) == 2);
|
|
||||||
using SIMDType = std::conditional_t<sizeof(Char) == 1, uint8_t, uint16_t>;
|
|
||||||
|
|
||||||
constexpr size_t stride = SIMD::stride<SIMDType>;
|
|
||||||
// Matches 0x10-0x1f and 0x90-0x9f. These characters have a high
|
|
||||||
// probability of being escape characters.
|
|
||||||
constexpr auto escMask = SIMD::splat<SIMDType>(static_cast<SIMDType>(~0b10001111U));
|
|
||||||
constexpr auto escVector = SIMD::splat<SIMDType>(0b00010000);
|
|
||||||
|
|
||||||
auto it = start;
|
|
||||||
// Search for escape sequences using SIMD
|
|
||||||
// [Implementation note: aligning `it` did not improve performance]
|
|
||||||
for (; end - it >= stride; it += stride) {
|
|
||||||
const auto chunk = SIMD::load(reinterpret_cast<const SIMDType*>(it));
|
|
||||||
const auto chunkMasked = SIMD::bitAnd(chunk, escMask);
|
|
||||||
const auto chunkIsEsc = SIMD::equal(chunkMasked, escVector);
|
|
||||||
if (const auto index = SIMD::findFirstNonZeroIndex(chunkIsEsc)) {
|
|
||||||
return it + *index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check remaining characters
|
|
||||||
for (; it != end; ++it) {
|
|
||||||
if (isEscapeCharacter(*it)) return it;
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume an ANSI escape sequence that starts at `start`. Returns a pointer to
|
|
||||||
// the first byte immediately following the escape sequence.
|
|
||||||
//
|
|
||||||
// If the ANSI escape sequence is immediately followed by another escape
|
|
||||||
// sequence, this function will consume that one as well, and so on.
|
|
||||||
template<typename Char>
|
|
||||||
static const Char* consumeANSI(const Char* const start, const Char* const end)
|
|
||||||
{
|
|
||||||
enum class State {
|
|
||||||
start,
|
|
||||||
gotEsc,
|
|
||||||
ignoreNextChar,
|
|
||||||
inCsi,
|
|
||||||
inOsc,
|
|
||||||
inOscGotEsc,
|
|
||||||
needSt,
|
|
||||||
needStGotEsc,
|
|
||||||
};
|
|
||||||
|
|
||||||
auto state = State::start;
|
|
||||||
for (auto it = start; it != end; ++it) {
|
|
||||||
const auto c = *it;
|
|
||||||
switch (state) {
|
|
||||||
case State::start:
|
|
||||||
switch (c) {
|
|
||||||
case 0x1b:
|
|
||||||
state = State::gotEsc;
|
|
||||||
break;
|
|
||||||
case 0x9b:
|
|
||||||
state = State::inCsi;
|
|
||||||
break;
|
|
||||||
case 0x9d:
|
|
||||||
state = State::inOsc;
|
|
||||||
break;
|
|
||||||
// Other sequences terminated by ST, from ECMA-48, 5th ed.
|
|
||||||
case 0x90: // device control string
|
|
||||||
case 0x98: // start of string
|
|
||||||
case 0x9e: // privacy message
|
|
||||||
case 0x9f: // application program command
|
|
||||||
state = State::needSt;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return it;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::gotEsc:
|
|
||||||
switch (c) {
|
|
||||||
case '[':
|
|
||||||
state = State::inCsi;
|
|
||||||
break;
|
|
||||||
// Two-byte XTerm sequences
|
|
||||||
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
|
||||||
case ' ':
|
|
||||||
case '#':
|
|
||||||
case '%':
|
|
||||||
case '(':
|
|
||||||
case ')':
|
|
||||||
case '*':
|
|
||||||
case '+':
|
|
||||||
case '.':
|
|
||||||
case '/':
|
|
||||||
state = State::ignoreNextChar;
|
|
||||||
break;
|
|
||||||
case ']':
|
|
||||||
state = State::inOsc;
|
|
||||||
break;
|
|
||||||
// Other sequences terminated by ST, from ECMA-48, 5th ed.
|
|
||||||
case 'P': // device control string
|
|
||||||
case 'X': // start of string
|
|
||||||
case '^': // privacy message
|
|
||||||
case '_': // application program command
|
|
||||||
state = State::needSt;
|
|
||||||
default:
|
|
||||||
// Otherwise, assume this is a one-byte sequence
|
|
||||||
state = State::start;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::ignoreNextChar:
|
|
||||||
state = State::start;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::inCsi:
|
|
||||||
// ECMA-48, 5th ed. §5.4 d)
|
|
||||||
if (c >= 0x40 && c <= 0x7e) {
|
|
||||||
state = State::start;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::inOsc:
|
|
||||||
switch (c) {
|
|
||||||
case 0x1b:
|
|
||||||
state = State::inOscGotEsc;
|
|
||||||
break;
|
|
||||||
case 0x9c: // ST
|
|
||||||
case 0x07: // XTerm can also end OSC with 0x07
|
|
||||||
state = State::start;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::inOscGotEsc:
|
|
||||||
if (c == '\\') {
|
|
||||||
state = State::start;
|
|
||||||
} else {
|
|
||||||
state = State::inOsc;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::needSt:
|
|
||||||
switch (c) {
|
|
||||||
case 0x1b:
|
|
||||||
state = State::needStGotEsc;
|
|
||||||
break;
|
|
||||||
case 0x9c:
|
|
||||||
state = State::start;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case State::needStGotEsc:
|
|
||||||
if (c == '\\') {
|
|
||||||
state = State::start;
|
|
||||||
} else {
|
|
||||||
state = State::needSt;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return end;
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename Char>
|
template<typename Char>
|
||||||
static std::optional<WTF::String> stripANSI(const std::span<const Char> input)
|
static std::optional<WTF::String> stripANSI(const std::span<const Char> input)
|
||||||
{
|
{
|
||||||
@@ -204,11 +23,12 @@ static std::optional<WTF::String> stripANSI(const std::span<const Char> input)
|
|||||||
const auto end = start + input.size();
|
const auto end = start + input.size();
|
||||||
|
|
||||||
while (start != end) {
|
while (start != end) {
|
||||||
const auto escPos = findEscapeCharacter(start, end);
|
const auto escPos = ANSI::findEscapeCharacter(start, end);
|
||||||
if (!escPos) {
|
if (!escPos) {
|
||||||
// If no escape sequences found, return null to signal that the
|
// If no escape sequences found, return null to signal that the
|
||||||
// original string should be used.
|
// original string should be used.
|
||||||
if (!foundANSI) return std::nullopt;
|
if (!foundANSI)
|
||||||
|
return std::nullopt;
|
||||||
// Append the rest of the string
|
// Append the rest of the string
|
||||||
result.append(std::span { start, end });
|
result.append(std::span { start, end });
|
||||||
break;
|
break;
|
||||||
@@ -221,7 +41,7 @@ static std::optional<WTF::String> stripANSI(const std::span<const Char> input)
|
|||||||
|
|
||||||
// Append everything before the escape sequence
|
// Append everything before the escape sequence
|
||||||
result.append(std::span { start, escPos });
|
result.append(std::span { start, escPos });
|
||||||
const auto newPos = consumeANSI(escPos, end);
|
const auto newPos = ANSI::consumeANSI(escPos, end);
|
||||||
ASSERT(newPos > start);
|
ASSERT(newPos > start);
|
||||||
ASSERT(newPos <= end);
|
ASSERT(newPos <= end);
|
||||||
foundANSI = true;
|
foundANSI = true;
|
||||||
|
|||||||
748
src/bun.js/bindings/wrapAnsi.cpp
Normal file
748
src/bun.js/bindings/wrapAnsi.cpp
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
#include "root.h"
|
||||||
|
#include "wrapAnsi.h"
|
||||||
|
#include "ANSIHelpers.h"
|
||||||
|
|
||||||
|
#include <wtf/text/WTFString.h>
|
||||||
|
#include <wtf/text/StringBuilder.h>
|
||||||
|
#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)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static 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 c;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline uint8_t getVisibleWidth(char32_t cp, bool ambiguousIsWide)
|
||||||
|
{
|
||||||
|
return Bun__codepointWidth(cp, ambiguousIsWide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for wrapping
|
||||||
|
struct WrapAnsiOptions {
|
||||||
|
bool hard = false;
|
||||||
|
bool wordWrap = true;
|
||||||
|
bool trim = true;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Row Management (using WTF::Vector)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
class Row {
|
||||||
|
public:
|
||||||
|
Vector<Char> m_data;
|
||||||
|
|
||||||
|
void append(Char c)
|
||||||
|
{
|
||||||
|
m_data.append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
void append(const Char* start, const Char* end)
|
||||||
|
{
|
||||||
|
m_data.append(std::span { start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
void append(const Row& other)
|
||||||
|
{
|
||||||
|
m_data.appendVector(other.m_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t width(bool ambiguousIsNarrow) const
|
||||||
|
{
|
||||||
|
if (m_data.isEmpty())
|
||||||
|
return 0;
|
||||||
|
auto span = m_data.span();
|
||||||
|
return stringWidth(span.data(), span.data() + span.size(), ambiguousIsNarrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
void trimLeadingSpaces()
|
||||||
|
{
|
||||||
|
size_t removeCount = 0;
|
||||||
|
bool inEscape = false;
|
||||||
|
|
||||||
|
// Count leading spaces (preserving ANSI)
|
||||||
|
for (size_t i = 0; i < m_data.size(); ++i) {
|
||||||
|
Char c = m_data[i];
|
||||||
|
if (c == 0x1b) {
|
||||||
|
inEscape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inEscape) {
|
||||||
|
if (c == 'm' || c == 0x07)
|
||||||
|
inEscape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == ' ' || c == '\t')
|
||||||
|
removeCount++;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeCount == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Remove spaces while preserving ANSI codes
|
||||||
|
Vector<Char> newData;
|
||||||
|
newData.reserveCapacity(m_data.size() - removeCount);
|
||||||
|
|
||||||
|
inEscape = false;
|
||||||
|
size_t removed = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < m_data.size(); ++i) {
|
||||||
|
Char c = m_data[i];
|
||||||
|
if (c == 0x1b) {
|
||||||
|
inEscape = true;
|
||||||
|
newData.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inEscape) {
|
||||||
|
if (c == 'm' || c == 0x07)
|
||||||
|
inEscape = false;
|
||||||
|
newData.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((c == ' ' || c == '\t') && removed < removeCount) {
|
||||||
|
removed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newData.append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_data = std::move(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Word Wrapping Core Logic
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static void wrapWord(Vector<Row<Char>>& rows, const Char* wordStart, const Char* wordEnd, size_t columns, const WrapAnsiOptions& options)
|
||||||
|
{
|
||||||
|
bool isInsideEscape = false;
|
||||||
|
bool isInsideLinkEscape = false;
|
||||||
|
bool isInsideCsiEscape = false;
|
||||||
|
size_t vis = rows.last().width(options.ambiguousIsNarrow);
|
||||||
|
|
||||||
|
const Char* it = wordStart;
|
||||||
|
while (it < wordEnd) {
|
||||||
|
if (*it == 0x1b) {
|
||||||
|
isInsideEscape = true;
|
||||||
|
isInsideCsiEscape = false;
|
||||||
|
// Check for hyperlink escape (OSC 8)
|
||||||
|
if (wordEnd - it > 4) {
|
||||||
|
if (it[1] == ']' && it[2] == '8' && it[3] == ';' && it[4] == ';')
|
||||||
|
isInsideLinkEscape = true;
|
||||||
|
}
|
||||||
|
// Check for CSI escape (ESC [)
|
||||||
|
if (wordEnd - it > 1 && it[1] == '[')
|
||||||
|
isInsideCsiEscape = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t charLen = 0;
|
||||||
|
uint8_t charWidth = 0;
|
||||||
|
|
||||||
|
if (!isInsideEscape) {
|
||||||
|
char32_t cp;
|
||||||
|
if constexpr (sizeof(Char) == 1) {
|
||||||
|
// Latin1: each byte is one character, direct 1:1 mapping to U+0000-U+00FF
|
||||||
|
charLen = 1;
|
||||||
|
cp = static_cast<uint8_t>(*it);
|
||||||
|
} else {
|
||||||
|
cp = decodeUTF16(it, wordEnd - it, charLen);
|
||||||
|
}
|
||||||
|
charWidth = getVisibleWidth(cp, !options.ambiguousIsNarrow);
|
||||||
|
} else {
|
||||||
|
charLen = 1;
|
||||||
|
charWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInsideEscape && vis + charWidth <= columns) {
|
||||||
|
rows.last().append(it, it + charLen);
|
||||||
|
vis += charWidth;
|
||||||
|
} else if (!isInsideEscape) {
|
||||||
|
// Character doesn't fit on current line, start a new line
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
rows.last().append(it, it + charLen);
|
||||||
|
vis = charWidth; // Start with the width of the character we just added
|
||||||
|
} else {
|
||||||
|
rows.last().append(*it);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInsideEscape) {
|
||||||
|
if (isInsideLinkEscape) {
|
||||||
|
if (*it == 0x07) {
|
||||||
|
isInsideEscape = false;
|
||||||
|
isInsideLinkEscape = false;
|
||||||
|
}
|
||||||
|
} else if (isInsideCsiEscape) {
|
||||||
|
// CSI sequence ends with a byte in 0x40-0x7E range
|
||||||
|
// (excluding '[' which is the CSI introducer)
|
||||||
|
if (*it >= 0x40 && *it <= 0x7E && *it != '[') {
|
||||||
|
isInsideEscape = false;
|
||||||
|
isInsideCsiEscape = false;
|
||||||
|
}
|
||||||
|
} else if (*it == 'm') {
|
||||||
|
// Fallback for non-CSI SGR-like sequences
|
||||||
|
isInsideEscape = false;
|
||||||
|
}
|
||||||
|
it++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vis == columns && it + charLen < wordEnd) {
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
vis = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
it += charLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edge case: last row is only ANSI escape codes
|
||||||
|
if (vis == 0 && !rows.last().m_data.isEmpty() && rows.size() > 1) {
|
||||||
|
Row<Char> lastRow = std::move(rows.last());
|
||||||
|
rows.removeLast();
|
||||||
|
rows.last().append(lastRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a character ends a CSI escape sequence
|
||||||
|
// CSI sequences end with bytes in 0x40-0x7E range (excluding '[' which is the introducer)
|
||||||
|
template<typename Char>
|
||||||
|
static bool isCsiTerminator(Char c)
|
||||||
|
{
|
||||||
|
return c >= 0x40 && c <= 0x7E && c != '[';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a character ends an ANSI escape sequence
|
||||||
|
template<typename Char>
|
||||||
|
static bool isAnsiEscapeTerminator(Char c, bool isOscSequence)
|
||||||
|
{
|
||||||
|
if (isOscSequence)
|
||||||
|
return c == 0x07; // BEL terminates OSC sequences
|
||||||
|
return isCsiTerminator(c); // CSI terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static void trimRowTrailingSpaces(Row<Char>& row, bool ambiguousIsNarrow)
|
||||||
|
{
|
||||||
|
// Find last visible word
|
||||||
|
auto span = row.m_data.span();
|
||||||
|
const Char* data = span.data();
|
||||||
|
size_t size = span.size();
|
||||||
|
|
||||||
|
// Split by spaces and find last word with visible content
|
||||||
|
size_t lastVisibleEnd = 0;
|
||||||
|
size_t wordStart = 0;
|
||||||
|
bool hasVisibleContent = false;
|
||||||
|
|
||||||
|
for (size_t i = 0; i <= size; ++i) {
|
||||||
|
if (i == size || data[i] == ' ') {
|
||||||
|
if (wordStart < i) {
|
||||||
|
size_t wordWidth = stringWidth(data + wordStart, data + i, ambiguousIsNarrow);
|
||||||
|
if (wordWidth > 0) {
|
||||||
|
hasVisibleContent = true;
|
||||||
|
lastVisibleEnd = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasVisibleContent) {
|
||||||
|
// Keep only ANSI codes
|
||||||
|
Vector<Char> ansiOnly;
|
||||||
|
bool inEscape = false;
|
||||||
|
bool inOscEscape = false;
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
if (data[i] == 0x1b || inEscape) {
|
||||||
|
ansiOnly.append(data[i]);
|
||||||
|
if (data[i] == 0x1b) {
|
||||||
|
inEscape = true;
|
||||||
|
inOscEscape = (i + 1 < size && data[i + 1] == ']');
|
||||||
|
} else if (isAnsiEscapeTerminator(data[i], inOscEscape)) {
|
||||||
|
inEscape = false;
|
||||||
|
inOscEscape = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.m_data = std::move(ansiOnly);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastVisibleEnd < size) {
|
||||||
|
// Collect trailing ANSI codes
|
||||||
|
Vector<Char> trailingAnsi;
|
||||||
|
bool inEscape = false;
|
||||||
|
bool inOscEscape = false;
|
||||||
|
for (size_t i = lastVisibleEnd; i < size; ++i) {
|
||||||
|
if (data[i] == 0x1b || inEscape) {
|
||||||
|
trailingAnsi.append(data[i]);
|
||||||
|
if (data[i] == 0x1b) {
|
||||||
|
inEscape = true;
|
||||||
|
inOscEscape = (i + 1 < size && data[i + 1] == ']');
|
||||||
|
} else if (isAnsiEscapeTerminator(data[i], inOscEscape)) {
|
||||||
|
inEscape = false;
|
||||||
|
inOscEscape = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.m_data.shrink(lastVisibleEnd);
|
||||||
|
row.m_data.appendVector(trailingAnsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SGR Code Parsing and Style Preservation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static constexpr uint32_t END_CODE = 39;
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static std::optional<uint32_t> parseSgrCode(const Char* start, const Char* end)
|
||||||
|
{
|
||||||
|
if (end - start < 3 || start[0] != 0x1b || start[1] != '[')
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
uint32_t code = 0;
|
||||||
|
for (const Char* it = start + 2; it < end; ++it) {
|
||||||
|
Char c = *it;
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
code = code * 10 + (c - '0');
|
||||||
|
} else if (c == 'm') {
|
||||||
|
return code;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static std::pair<const Char*, const Char*> parseOsc8Url(const Char* start, const Char* end)
|
||||||
|
{
|
||||||
|
// Format: ESC ] 8 ; ; url BEL
|
||||||
|
if (end - start < 6)
|
||||||
|
return { nullptr, nullptr };
|
||||||
|
if (start[0] != 0x1b || start[1] != ']' || start[2] != '8' || start[3] != ';' || start[4] != ';')
|
||||||
|
return { nullptr, nullptr };
|
||||||
|
|
||||||
|
const Char* urlStart = start + 5;
|
||||||
|
const Char* urlEnd = urlStart;
|
||||||
|
|
||||||
|
while (urlEnd < end && *urlEnd != 0x07 && *urlEnd != 0x1b)
|
||||||
|
urlEnd++;
|
||||||
|
|
||||||
|
if (urlEnd == urlStart)
|
||||||
|
return { nullptr, nullptr };
|
||||||
|
|
||||||
|
return { urlStart, urlEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::optional<uint32_t> getCloseCode(uint32_t code)
|
||||||
|
{
|
||||||
|
switch (code) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
return 22;
|
||||||
|
case 3:
|
||||||
|
return 23;
|
||||||
|
case 4:
|
||||||
|
return 24;
|
||||||
|
case 5:
|
||||||
|
case 6:
|
||||||
|
return 25;
|
||||||
|
case 7:
|
||||||
|
return 27;
|
||||||
|
case 8:
|
||||||
|
return 28;
|
||||||
|
case 9:
|
||||||
|
return 29;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 30 && code <= 37)
|
||||||
|
return 39;
|
||||||
|
if (code >= 40 && code <= 47)
|
||||||
|
return 49;
|
||||||
|
if (code >= 90 && code <= 97)
|
||||||
|
return 39;
|
||||||
|
if (code >= 100 && code <= 107)
|
||||||
|
return 49;
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static void joinRowsWithAnsiPreservation(const Vector<Row<Char>>& rows, StringBuilder& result)
|
||||||
|
{
|
||||||
|
// First join all rows
|
||||||
|
Vector<Char> joined;
|
||||||
|
size_t totalSize = 0;
|
||||||
|
for (const auto& row : rows)
|
||||||
|
totalSize += row.m_data.size() + 1;
|
||||||
|
|
||||||
|
joined.reserveCapacity(totalSize);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
if (i > 0)
|
||||||
|
joined.append(static_cast<Char>('\n'));
|
||||||
|
joined.appendVector(rows[i].m_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process for ANSI style preservation
|
||||||
|
std::optional<uint32_t> escapeCode;
|
||||||
|
const Char* escapeUrl = nullptr;
|
||||||
|
size_t escapeUrlLen = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < joined.size(); ++i) {
|
||||||
|
Char c = joined[i];
|
||||||
|
result.append(static_cast<UChar>(c));
|
||||||
|
|
||||||
|
if (c == 0x1b && i + 1 < joined.size()) {
|
||||||
|
auto span = joined.span();
|
||||||
|
// Parse ANSI sequence
|
||||||
|
if (joined[i + 1] == '[') {
|
||||||
|
if (auto code = parseSgrCode(span.data() + i, span.data() + span.size())) {
|
||||||
|
if (*code == END_CODE || *code == 0)
|
||||||
|
escapeCode = std::nullopt;
|
||||||
|
else
|
||||||
|
escapeCode = *code;
|
||||||
|
}
|
||||||
|
} else if (i + 4 < joined.size() && joined[i + 1] == ']' && joined[i + 2] == '8' && joined[i + 3] == ';' && joined[i + 4] == ';') {
|
||||||
|
auto [urlStart, urlEnd] = parseOsc8Url(span.data() + i, span.data() + span.size());
|
||||||
|
if (urlStart && urlEnd != urlStart) {
|
||||||
|
escapeUrl = urlStart;
|
||||||
|
escapeUrlLen = urlEnd - urlStart;
|
||||||
|
} else {
|
||||||
|
escapeUrl = nullptr;
|
||||||
|
escapeUrlLen = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if next character is newline
|
||||||
|
if (i + 1 < joined.size() && joined[i + 1] == '\n') {
|
||||||
|
// Close styles before newline
|
||||||
|
if (escapeUrl) {
|
||||||
|
result.append("\x1b]8;;\x07"_s);
|
||||||
|
}
|
||||||
|
if (escapeCode) {
|
||||||
|
if (auto closeCode = getCloseCode(*escapeCode)) {
|
||||||
|
result.append("\x1b["_s);
|
||||||
|
result.append(String::number(*closeCode));
|
||||||
|
result.append('m');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (c == '\n') {
|
||||||
|
// Restore styles after newline
|
||||||
|
if (escapeCode) {
|
||||||
|
result.append("\x1b["_s);
|
||||||
|
result.append(String::number(*escapeCode));
|
||||||
|
result.append('m');
|
||||||
|
}
|
||||||
|
if (escapeUrl) {
|
||||||
|
result.append("\x1b]8;;"_s);
|
||||||
|
for (size_t j = 0; j < escapeUrlLen; ++j)
|
||||||
|
result.append(static_cast<UChar>(escapeUrl[j]));
|
||||||
|
result.append(static_cast<UChar>(0x07));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Line Processing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static void processLine(const Char* lineStart, const Char* lineEnd, size_t columns, const WrapAnsiOptions& options, Vector<Row<Char>>& rows)
|
||||||
|
{
|
||||||
|
// Handle empty or whitespace-only strings with trim
|
||||||
|
if (options.trim) {
|
||||||
|
const Char* trimStart = lineStart;
|
||||||
|
const Char* trimEnd = lineEnd;
|
||||||
|
while (trimStart < trimEnd && (*trimStart == ' ' || *trimStart == '\t'))
|
||||||
|
trimStart++;
|
||||||
|
while (trimEnd > trimStart && (*(trimEnd - 1) == ' ' || *(trimEnd - 1) == '\t'))
|
||||||
|
trimEnd--;
|
||||||
|
if (trimStart >= trimEnd)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate word lengths
|
||||||
|
Vector<size_t> wordLengths;
|
||||||
|
const Char* wordStart = lineStart;
|
||||||
|
for (const Char* it = lineStart; it <= lineEnd; ++it) {
|
||||||
|
if (it == lineEnd || *it == ' ') {
|
||||||
|
if (wordStart < it) {
|
||||||
|
wordLengths.append(stringWidth(wordStart, it, options.ambiguousIsNarrow));
|
||||||
|
} else {
|
||||||
|
wordLengths.append(0);
|
||||||
|
}
|
||||||
|
wordStart = it + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with empty first row
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
|
||||||
|
// Process each word
|
||||||
|
wordStart = lineStart;
|
||||||
|
size_t wordIndex = 0;
|
||||||
|
|
||||||
|
for (const Char* it = lineStart; it <= lineEnd; ++it) {
|
||||||
|
if (it < lineEnd && *it != ' ')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const Char* wordEnd = it;
|
||||||
|
|
||||||
|
if (options.trim)
|
||||||
|
rows.last().trimLeadingSpaces();
|
||||||
|
|
||||||
|
size_t rowLength = rows.last().width(options.ambiguousIsNarrow);
|
||||||
|
|
||||||
|
if (wordIndex != 0) {
|
||||||
|
if (rowLength >= columns && (!options.wordWrap || !options.trim)) {
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
rowLength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowLength > 0 || !options.trim) {
|
||||||
|
rows.last().append(static_cast<Char>(' '));
|
||||||
|
rowLength++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t wordLen = wordIndex < wordLengths.size() ? wordLengths[wordIndex] : 0;
|
||||||
|
|
||||||
|
// Hard wrap mode
|
||||||
|
if (options.hard && wordLen > columns) {
|
||||||
|
size_t remainingColumns = columns > rowLength ? columns - rowLength : 0;
|
||||||
|
size_t breaksStartingThisLine = 1 + (wordLen > remainingColumns ? (wordLen - remainingColumns - 1) / columns : 0);
|
||||||
|
size_t breaksStartingNextLine = wordLen > 0 ? (wordLen - 1) / columns : 0;
|
||||||
|
if (breaksStartingNextLine < breaksStartingThisLine)
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
|
||||||
|
wrapWord(rows, wordStart, wordEnd, columns, options);
|
||||||
|
wordStart = it + 1;
|
||||||
|
wordIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowLength + wordLen > columns && rowLength > 0 && wordLen > 0) {
|
||||||
|
if (!options.wordWrap && rowLength < columns) {
|
||||||
|
wrapWord(rows, wordStart, wordEnd, columns, options);
|
||||||
|
wordStart = it + 1;
|
||||||
|
wordIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.append(Row<Char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
rowLength = rows.last().width(options.ambiguousIsNarrow);
|
||||||
|
if (rowLength + wordLen > columns && !options.wordWrap) {
|
||||||
|
wrapWord(rows, wordStart, wordEnd, columns, options);
|
||||||
|
wordStart = it + 1;
|
||||||
|
wordIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.last().append(wordStart, wordEnd);
|
||||||
|
wordStart = it + 1;
|
||||||
|
wordIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing whitespace from rows if needed
|
||||||
|
if (options.trim) {
|
||||||
|
for (auto& row : rows)
|
||||||
|
trimRowTrailingSpaces(row, options.ambiguousIsNarrow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
template<typename Char>
|
||||||
|
static WTF::String wrapAnsiImpl(std::span<const Char> input, size_t columns, const WrapAnsiOptions& options)
|
||||||
|
{
|
||||||
|
if (columns == 0 || input.empty()) {
|
||||||
|
// Return copy of input
|
||||||
|
StringBuilder result;
|
||||||
|
result.reserveCapacity(input.size());
|
||||||
|
for (auto c : input)
|
||||||
|
result.append(static_cast<UChar>(c));
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize \r\n to \n
|
||||||
|
Vector<Char> normalized;
|
||||||
|
normalized.reserveCapacity(input.size());
|
||||||
|
|
||||||
|
for (size_t i = 0; i < input.size(); ++i) {
|
||||||
|
if (i + 1 < input.size() && input[i] == '\r' && input[i + 1] == '\n') {
|
||||||
|
normalized.append(static_cast<Char>('\n'));
|
||||||
|
i++; // Skip next char
|
||||||
|
} else {
|
||||||
|
normalized.append(input[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each line separately
|
||||||
|
StringBuilder result;
|
||||||
|
result.reserveCapacity(input.size() + input.size() / 10);
|
||||||
|
|
||||||
|
auto span = normalized.span();
|
||||||
|
const Char* lineStart = span.data();
|
||||||
|
const Char* const dataEnd = span.data() + span.size();
|
||||||
|
bool firstLine = true;
|
||||||
|
|
||||||
|
while (lineStart <= dataEnd) {
|
||||||
|
// Find next newline using WTF::find
|
||||||
|
auto remaining = std::span<const Char>(lineStart, dataEnd);
|
||||||
|
size_t nlPos = WTF::find(remaining, static_cast<Char>('\n'));
|
||||||
|
const Char* lineEnd = (nlPos == WTF::notFound) ? dataEnd : lineStart + nlPos;
|
||||||
|
|
||||||
|
// Add newline between input lines
|
||||||
|
if (!firstLine)
|
||||||
|
result.append('\n');
|
||||||
|
firstLine = false;
|
||||||
|
|
||||||
|
// Process this input line
|
||||||
|
Vector<Row<Char>> lineRows;
|
||||||
|
processLine(lineStart, lineEnd, columns, options, lineRows);
|
||||||
|
|
||||||
|
// Join and append this line's rows with ANSI preservation
|
||||||
|
if (!lineRows.isEmpty()) {
|
||||||
|
joinRowsWithAnsiPreservation(lineRows, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
lineStart = lineEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JavaScript Binding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunWrapAnsi, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
|
||||||
|
{
|
||||||
|
auto& vm = globalObject->vm();
|
||||||
|
auto scope = DECLARE_THROW_SCOPE(vm);
|
||||||
|
|
||||||
|
// Get arguments
|
||||||
|
JSC::JSValue inputValue = callFrame->argument(0);
|
||||||
|
JSC::JSValue columnsValue = callFrame->argument(1);
|
||||||
|
JSC::JSValue optionsValue = callFrame->argument(2);
|
||||||
|
|
||||||
|
// Convert input to string
|
||||||
|
JSC::JSString* jsString = inputValue.toString(globalObject);
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
|
||||||
|
auto view = jsString->view(globalObject);
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
|
||||||
|
if (view->isEmpty())
|
||||||
|
return JSC::JSValue::encode(JSC::jsEmptyString(vm));
|
||||||
|
|
||||||
|
// Get columns
|
||||||
|
size_t columns = 0;
|
||||||
|
if (!columnsValue.isUndefined()) {
|
||||||
|
double colsDouble = columnsValue.toIntegerOrInfinity(globalObject);
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
// Only set columns if positive and finite (negative values would wrap to huge size_t)
|
||||||
|
if (colsDouble > 0 && std::isfinite(colsDouble))
|
||||||
|
columns = static_cast<size_t>(colsDouble);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse options
|
||||||
|
WrapAnsiOptions options;
|
||||||
|
if (optionsValue.isObject()) {
|
||||||
|
JSC::JSObject* optionsObj = optionsValue.toObject(globalObject);
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
|
||||||
|
JSC::JSValue hardValue = optionsObj->get(globalObject, JSC::Identifier::fromString(vm, "hard"_s));
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
if (!hardValue.isUndefined())
|
||||||
|
options.hard = hardValue.toBoolean(globalObject);
|
||||||
|
|
||||||
|
JSC::JSValue wordWrapValue = optionsObj->get(globalObject, JSC::Identifier::fromString(vm, "wordWrap"_s));
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
if (!wordWrapValue.isUndefined())
|
||||||
|
options.wordWrap = wordWrapValue.toBoolean(globalObject);
|
||||||
|
|
||||||
|
JSC::JSValue trimValue = optionsObj->get(globalObject, JSC::Identifier::fromString(vm, "trim"_s));
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
if (!trimValue.isUndefined())
|
||||||
|
options.trim = trimValue.toBoolean(globalObject);
|
||||||
|
|
||||||
|
JSC::JSValue ambiguousValue = optionsObj->get(globalObject, JSC::Identifier::fromString(vm, "ambiguousIsNarrow"_s));
|
||||||
|
RETURN_IF_EXCEPTION(scope, {});
|
||||||
|
if (!ambiguousValue.isUndefined())
|
||||||
|
options.ambiguousIsNarrow = ambiguousValue.toBoolean(globalObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process based on encoding
|
||||||
|
WTF::String result;
|
||||||
|
if (view->is8Bit()) {
|
||||||
|
result = wrapAnsiImpl<Latin1Character>(view->span8(), columns, options);
|
||||||
|
} else {
|
||||||
|
result = wrapAnsiImpl<UChar>(view->span16(), columns, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSC::JSValue::encode(JSC::jsString(vm, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Bun
|
||||||
9
src/bun.js/bindings/wrapAnsi.h
Normal file
9
src/bun.js/bindings/wrapAnsi.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "root.h"
|
||||||
|
|
||||||
|
namespace Bun {
|
||||||
|
|
||||||
|
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunWrapAnsi);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1146,6 +1146,32 @@ pub const visible = struct {
|
|||||||
// extern "C" bool icu_hasBinaryProperty(UChar32 cp, unsigned int prop)
|
// extern "C" bool icu_hasBinaryProperty(UChar32 cp, unsigned int prop)
|
||||||
extern fn icu_hasBinaryProperty(c: u32, which: c_uint) bool;
|
extern fn icu_hasBinaryProperty(c: u32, which: c_uint) bool;
|
||||||
|
|
||||||
|
// C exports for wrapAnsi.cpp
|
||||||
|
|
||||||
|
/// Calculate visible width of UTF-8 string excluding ANSI escape codes
|
||||||
|
export fn Bun__visibleWidthExcludeANSI_utf8(ptr: [*]const u8, len: usize, ambiguous_as_wide: bool) usize {
|
||||||
|
_ = ambiguous_as_wide; // UTF-8 version doesn't use this parameter
|
||||||
|
const input = ptr[0..len];
|
||||||
|
return visible.width.exclude_ansi_colors.utf8(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate visible width of UTF-16 string excluding ANSI escape codes
|
||||||
|
export fn Bun__visibleWidthExcludeANSI_utf16(ptr: [*]const u16, len: usize, ambiguous_as_wide: bool) usize {
|
||||||
|
const input = ptr[0..len];
|
||||||
|
return visible.width.exclude_ansi_colors.utf16(input, ambiguous_as_wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate visible width of Latin-1 string excluding ANSI escape codes
|
||||||
|
export fn Bun__visibleWidthExcludeANSI_latin1(ptr: [*]const u8, len: usize) usize {
|
||||||
|
const input = ptr[0..len];
|
||||||
|
return visible.width.exclude_ansi_colors.latin1(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate visible width of a single codepoint
|
||||||
|
export fn Bun__codepointWidth(cp: u32, ambiguous_as_wide: bool) u8 {
|
||||||
|
return @intCast(visibleCodepointWidth(cp, ambiguous_as_wide));
|
||||||
|
}
|
||||||
|
|
||||||
const bun = @import("bun");
|
const bun = @import("bun");
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
|
|||||||
255
test/js/bun/util/wrapAnsi.npm.test.ts
Normal file
255
test/js/bun/util/wrapAnsi.npm.test.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Tests ported from wrap-ansi npm package
|
||||||
|
* https://github.com/chalk/wrap-ansi
|
||||||
|
*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||||
|
* and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
* including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||||
|
* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all copies or
|
||||||
|
* substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||||
|
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
|
||||||
|
// ANSI color helpers (equivalent to chalk with level 1)
|
||||||
|
const red = (s: string) => `\u001B[31m${s}\u001B[39m`;
|
||||||
|
const green = (s: string) => `\u001B[32m${s}\u001B[39m`;
|
||||||
|
const blue = (s: string) => `\u001B[34m${s}\u001B[39m`;
|
||||||
|
const bgGreen = (s: string) => `\u001B[42m${s}\u001B[49m`;
|
||||||
|
const bgRed = (s: string) => `\u001B[41m${s}\u001B[49m`;
|
||||||
|
const black = (s: string) => `\u001B[30m${s}\u001B[39m`;
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m|\u001B\]8;;[^\u0007]*\u0007/g, "");
|
||||||
|
const hasAnsi = (s: string) => /\u001B\[[0-9;]*m/.test(s);
|
||||||
|
|
||||||
|
// Fixtures
|
||||||
|
const fixture =
|
||||||
|
"The quick brown " + red("fox jumped over ") + "the lazy " + green("dog and then ran away with the unicorn.");
|
||||||
|
const fixture2 = "12345678\n901234567890";
|
||||||
|
const fixture3 = "12345678\n901234567890 12345";
|
||||||
|
const fixture4 = "12345678\n";
|
||||||
|
const fixture5 = "12345678\n ";
|
||||||
|
|
||||||
|
// When "hard" is false
|
||||||
|
|
||||||
|
test("wraps string at 20 characters", () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture, 20);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"The quick brown \u001B[31mfox\u001B[39m\n\u001B[31mjumped over \u001B[39mthe lazy\n\u001B[32mdog and then ran\u001B[39m\n\u001B[32maway with the\u001B[39m\n\u001B[32municorn.\u001B[39m",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripAnsi(result)
|
||||||
|
.split("\n")
|
||||||
|
.every(line => line.length <= 20),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wraps string at 30 characters", () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture, 30);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"The quick brown \u001B[31mfox jumped\u001B[39m\n\u001B[31mover \u001B[39mthe lazy \u001B[32mdog and then ran\u001B[39m\n\u001B[32maway with the unicorn.\u001B[39m",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripAnsi(result)
|
||||||
|
.split("\n")
|
||||||
|
.every(line => line.length <= 30),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not break strings longer than "cols" characters', () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture, 5, { hard: false });
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"The\nquick\nbrown\n\u001B[31mfox\u001B[39m\n\u001B[31mjumped\u001B[39m\n\u001B[31mover\u001B[39m\n\u001B[31m\u001B[39mthe\nlazy\n\u001B[32mdog\u001B[39m\n\u001B[32mand\u001B[39m\n\u001B[32mthen\u001B[39m\n\u001B[32mran\u001B[39m\n\u001B[32maway\u001B[39m\n\u001B[32mwith\u001B[39m\n\u001B[32mthe\u001B[39m\n\u001B[32municorn.\u001B[39m",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripAnsi(result)
|
||||||
|
.split("\n")
|
||||||
|
.some(line => line.length > 5),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles colored string that wraps on to multiple lines", () => {
|
||||||
|
const result = Bun.wrapAnsi(green("hello world") + " hey!", 5, { hard: false });
|
||||||
|
const lines = result.split("\n");
|
||||||
|
expect(hasAnsi(lines[0])).toBe(true);
|
||||||
|
expect(hasAnsi(lines[1])).toBe(true);
|
||||||
|
expect(hasAnsi(lines[2])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not prepend newline if first string is greater than "cols"', () => {
|
||||||
|
const result = Bun.wrapAnsi(green("hello") + "-world", 5, { hard: false });
|
||||||
|
expect(result.split("\n").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When "hard" is true
|
||||||
|
|
||||||
|
test('breaks strings longer than "cols" characters', () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture, 5, { hard: true });
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"The\nquick\nbrown\n\u001B[31mfox j\u001B[39m\n\u001B[31mumped\u001B[39m\n\u001B[31mover\u001B[39m\n\u001B[31m\u001B[39mthe\nlazy\n\u001B[32mdog\u001B[39m\n\u001B[32mand\u001B[39m\n\u001B[32mthen\u001B[39m\n\u001B[32mran\u001B[39m\n\u001B[32maway\u001B[39m\n\u001B[32mwith\u001B[39m\n\u001B[32mthe\u001B[39m\n\u001B[32munico\u001B[39m\n\u001B[32mrn.\u001B[39m",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripAnsi(result)
|
||||||
|
.split("\n")
|
||||||
|
.every(line => line.length <= 5),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes last row if it contained only ansi escape codes", () => {
|
||||||
|
const result = Bun.wrapAnsi(green("helloworld"), 2, { hard: true });
|
||||||
|
expect(
|
||||||
|
stripAnsi(result)
|
||||||
|
.split("\n")
|
||||||
|
.every(x => x.length === 2),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not prepend newline if first word is split", () => {
|
||||||
|
const result = Bun.wrapAnsi(green("hello") + "world", 5, { hard: true });
|
||||||
|
expect(result.split("\n").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("takes into account line returns inside input", () => {
|
||||||
|
expect(Bun.wrapAnsi(fixture2, 10, { hard: true })).toBe("12345678\n9012345678\n90");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("word wrapping", () => {
|
||||||
|
expect(Bun.wrapAnsi(fixture3, 15)).toBe("12345678\n901234567890\n12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no word-wrapping", () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture3, 15, { wordWrap: false });
|
||||||
|
expect(result).toBe("12345678\n901234567890 12\n345");
|
||||||
|
|
||||||
|
const result2 = Bun.wrapAnsi(fixture3, 5, { wordWrap: false });
|
||||||
|
expect(result2).toBe("12345\n678\n90123\n45678\n90 12\n345");
|
||||||
|
|
||||||
|
const result3 = Bun.wrapAnsi(fixture5, 5, { wordWrap: false });
|
||||||
|
expect(result3).toBe("12345\n678\n");
|
||||||
|
|
||||||
|
const result4 = Bun.wrapAnsi(fixture, 5, { wordWrap: false });
|
||||||
|
expect(result4).toBe(
|
||||||
|
"The q\nuick\nbrown\n\u001B[31mfox j\u001B[39m\n\u001B[31mumped\u001B[39m\n\u001B[31mover\u001B[39m\n\u001B[31m\u001B[39mthe l\nazy \u001B[32md\u001B[39m\n\u001B[32mog an\u001B[39m\n\u001B[32md the\u001B[39m\n\u001B[32mn ran\u001B[39m\n\u001B[32maway\u001B[39m\n\u001B[32mwith\u001B[39m\n\u001B[32mthe u\u001B[39m\n\u001B[32mnicor\u001B[39m\n\u001B[32mn.\u001B[39m",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no word-wrapping and no trimming", () => {
|
||||||
|
const result = Bun.wrapAnsi(fixture3, 13, { wordWrap: false, trim: false });
|
||||||
|
expect(result).toBe("12345678\n901234567890 \n12345");
|
||||||
|
|
||||||
|
const result2 = Bun.wrapAnsi(fixture4, 5, { wordWrap: false, trim: false });
|
||||||
|
expect(result2).toBe("12345\n678\n");
|
||||||
|
|
||||||
|
const result3 = Bun.wrapAnsi(fixture5, 5, { wordWrap: false, trim: false });
|
||||||
|
expect(result3).toBe("12345\n678\n ");
|
||||||
|
|
||||||
|
// NOTE: The NPM test expects malformed ANSI codes (e.g., "[31mjumpe[39m" without ESC character)
|
||||||
|
// when ANSI escape sequences get character-wrapped across lines. Our implementation
|
||||||
|
// correctly preserves complete ANSI escape sequences. The visual output is equivalent.
|
||||||
|
const result4 = Bun.wrapAnsi(fixture, 5, { wordWrap: false, trim: false });
|
||||||
|
expect(result4).toBe(
|
||||||
|
"The q\nuick \nbrown\n \u001B[31mfox \u001B[39m\n\u001B[31mjumpe\u001B[39m\n\u001B[31md ove\u001B[39m\n\u001B[31mr \u001B[39mthe\n lazy\n \u001B[32mdog \u001B[39m\n\u001B[32mand t\u001B[39m\n\u001B[32mhen r\u001B[39m\n\u001B[32man aw\u001B[39m\n\u001B[32may wi\u001B[39m\n\u001B[32mth th\u001B[39m\n\u001B[32me uni\u001B[39m\n\u001B[32mcorn.\u001B[39m",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports fullwidth characters", () => {
|
||||||
|
expect(Bun.wrapAnsi("안녕하세", 4, { hard: true })).toBe("안녕\n하세");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports unicode surrogate pairs", () => {
|
||||||
|
expect(Bun.wrapAnsi("a\uD83C\uDE00bc", 2, { hard: true })).toBe("a\n\uD83C\uDE00\nbc");
|
||||||
|
expect(Bun.wrapAnsi("a\uD83C\uDE00bc\uD83C\uDE00d\uD83C\uDE00", 2, { hard: true })).toBe(
|
||||||
|
"a\n\uD83C\uDE00\nbc\n\uD83C\uDE00\nd\n\uD83C\uDE00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#23, properly wraps whitespace with no trimming", () => {
|
||||||
|
expect(Bun.wrapAnsi(" ", 2, { trim: false })).toBe(" \n ");
|
||||||
|
expect(Bun.wrapAnsi(" ", 2, { trim: false, hard: true })).toBe(" \n ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#24, trims leading and trailing whitespace only on actual wrapped lines and only with trimming", () => {
|
||||||
|
expect(Bun.wrapAnsi(" foo bar ", 3)).toBe("foo\nbar");
|
||||||
|
expect(Bun.wrapAnsi(" foo bar ", 6)).toBe("foo\nbar");
|
||||||
|
expect(Bun.wrapAnsi(" foo bar ", 42)).toBe("foo bar");
|
||||||
|
expect(Bun.wrapAnsi(" foo bar ", 42, { trim: false })).toBe(" foo bar ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#24, trims leading and trailing whitespace inside a color block only on actual wrapped lines and only with trimming", () => {
|
||||||
|
// NOTE: Bun's implementation closes and reopens ANSI codes around newlines for robustness.
|
||||||
|
// The visual output is equivalent: both lines appear in blue.
|
||||||
|
// NPM: "\u001B[34mfoo\nbar\u001B[39m"
|
||||||
|
// Bun: "\u001B[34mfoo\u001B[39m\n\u001B[34mbar\u001B[39m"
|
||||||
|
const result = Bun.wrapAnsi(blue(" foo bar "), 6);
|
||||||
|
expect(result).toBe("\u001B[34mfoo\u001B[39m\n\u001B[34mbar\u001B[39m");
|
||||||
|
expect(Bun.wrapAnsi(blue(" foo bar "), 42)).toBe(blue("foo bar"));
|
||||||
|
expect(Bun.wrapAnsi(blue(" foo bar "), 42, { trim: false })).toBe(blue(" foo bar "));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#25, properly wraps whitespace between words with no trimming", () => {
|
||||||
|
expect(Bun.wrapAnsi("foo bar", 3)).toBe("foo\nbar");
|
||||||
|
expect(Bun.wrapAnsi("foo bar", 3, { hard: true })).toBe("foo\nbar");
|
||||||
|
expect(Bun.wrapAnsi("foo bar", 3, { trim: false })).toBe("foo\n \nbar");
|
||||||
|
expect(Bun.wrapAnsi("foo bar", 3, { trim: false, hard: true })).toBe("foo\n \nbar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#26, does not multiplicate leading spaces with no trimming", () => {
|
||||||
|
expect(Bun.wrapAnsi(" a ", 10, { trim: false })).toBe(" a ");
|
||||||
|
expect(Bun.wrapAnsi(" a ", 10, { trim: false })).toBe(" a ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#27, does not remove spaces in line with ansi escapes when no trimming", () => {
|
||||||
|
expect(Bun.wrapAnsi(bgGreen(` ${black("OK")} `), 100, { trim: false })).toBe(bgGreen(` ${black("OK")} `));
|
||||||
|
expect(Bun.wrapAnsi(bgGreen(` ${black("OK")} `), 100, { trim: false })).toBe(bgGreen(` ${black("OK")} `));
|
||||||
|
expect(Bun.wrapAnsi(bgGreen(" hello "), 10, { hard: true, trim: false })).toBe(bgGreen(" hello "));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#35, wraps hyperlinks, preserving clickability in supporting terminals", () => {
|
||||||
|
const result1 = Bun.wrapAnsi(
|
||||||
|
"Check out \u001B]8;;https://www.example.com\u0007my website\u001B]8;;\u0007, it is \u001B]8;;https://www.example.com\u0007supercalifragilisticexpialidocious\u001B]8;;\u0007.",
|
||||||
|
16,
|
||||||
|
{ hard: true },
|
||||||
|
);
|
||||||
|
expect(result1).toBe(
|
||||||
|
"Check out \u001B]8;;https://www.example.com\u0007my\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007website\u001B]8;;\u0007, it is\n\u001B]8;;https://www.example.com\u0007supercalifragili\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007sticexpialidocio\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007us\u001B]8;;\u0007.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result2 = Bun.wrapAnsi(
|
||||||
|
`Check out \u001B]8;;https://www.example.com\u0007my \uD83C\uDE00 ${bgGreen("website")}\u001B]8;;\u0007, it ${bgRed("is \u001B]8;;https://www.example.com\u0007super\uD83C\uDE00califragilisticexpialidocious\u001B]8;;\u0007")}.`,
|
||||||
|
16,
|
||||||
|
{ hard: true },
|
||||||
|
);
|
||||||
|
expect(result2).toBe(
|
||||||
|
"Check out \u001B]8;;https://www.example.com\u0007my 🈀\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007\u001B[42mwebsite\u001B[49m\u001B]8;;\u0007, it \u001B[41mis\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007super🈀califragi\u001B]8;;\u0007\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007listicexpialidoc\u001B]8;;\u0007\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007ious\u001B]8;;\u0007\u001B[49m.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("covers non-SGR/non-hyperlink ansi escapes", () => {
|
||||||
|
expect(Bun.wrapAnsi("Hello, \u001B[1D World!", 8)).toBe("Hello,\u001B[1D\nWorld!");
|
||||||
|
expect(Bun.wrapAnsi("Hello, \u001B[1D World!", 8, { trim: false })).toBe("Hello, \u001B[1D \nWorld!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#39, normalizes newlines", () => {
|
||||||
|
expect(Bun.wrapAnsi("foobar\r\nfoobar\r\nfoobar\nfoobar", 3, { hard: true })).toBe(
|
||||||
|
"foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar",
|
||||||
|
);
|
||||||
|
expect(Bun.wrapAnsi("foo bar\r\nfoo bar\r\nfoo bar\nfoo bar", 3)).toBe("foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar");
|
||||||
|
});
|
||||||
236
test/js/bun/util/wrapAnsi.test.ts
Normal file
236
test/js/bun/util/wrapAnsi.test.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user