From 4fa69773a313af96d18553e9e940d1f39e4dd64a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 14 Aug 2025 22:42:05 -0700 Subject: [PATCH] Introduce `Bun.stripANSI` (#21801) ### What does this PR do? Introduce `Bun.stripANSI`, a SIMD-accelerated drop-in replacement for the popular `"strip-ansi"` package. `Bun.stripANSI` performs >10x faster and fixes several bugs in `strip-ansi`, like [this long-standing one](https://github.com/chalk/strip-ansi/issues/43). ### How did you verify your code works? There are tests that check the output of `strip-ansi` matches `Bun.stripANSI`. For cases where `strip-ansi`'s behavior is incorrect, the expected value is manually provided. --------- Co-authored-by: Jarred-Sumner <709451+Jarred-Sumner@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: taylor.fish --- bench/bun.lock | 92 ++++++ bench/package.json | 1 + bench/snippets/strip-ansi.mjs | 37 +++ bun.lock | 12 +- cmake/sources/CxxSources.txt | 1 + packages/bun-types/bun.d.ts | 17 + src/bun.js/bindings/BunObject.cpp | 5 + src/bun.js/bindings/stripANSI.cpp | 265 ++++++++++++++++ src/bun.js/bindings/stripANSI.h | 9 + src/js/internal/util/inspect.js | 19 +- src/js/node/readline.ts | 13 +- test/bun.lock | 5 +- test/js/bun/util/stripANSI.test.ts | 481 +++++++++++++++++++++++++++++ test/package.json | 3 +- 14 files changed, 923 insertions(+), 37 deletions(-) create mode 100644 bench/snippets/strip-ansi.mjs create mode 100644 src/bun.js/bindings/stripANSI.cpp create mode 100644 src/bun.js/bindings/stripANSI.h create mode 100644 test/js/bun/util/stripANSI.test.ts diff --git a/bench/bun.lock b/bench/bun.lock index 31e497f2fa..e9f41f8407 100644 --- a/bench/bun.lock +++ b/bench/bun.lock @@ -15,11 +15,13 @@ "eventemitter3": "^5.0.0", "execa": "^8.0.1", "fast-glob": "3.3.1", + "fastify": "^5.0.0", "fdir": "^6.1.0", "mitata": "^1.0.25", "react": "^18.3.1", "react-dom": "^18.3.1", "string-width": "7.1.0", + "strip-ansi": "^7.1.0", "tinycolor2": "^1.6.0", "zx": "^7.2.3", }, @@ -93,6 +95,18 @@ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.14.54", "", { "os": "linux", "cpu": "none" }, "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.2", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.0", "", {}, "sha512-kJExsp4JCms7ipzg7SJ3y8DwmePaELHxKYtg+tZow+k0znUTf3cb+npgyqm8+ATZOdmfgfydIebPDWM172wfyA=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.0.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.1.1", "", { "dependencies": { "@jridgewell/set-array": "^1.0.0", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.0", "", {}, "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="], @@ -143,10 +157,20 @@ "@types/which": ["@types/which@3.0.3", "", {}, "sha512-2C1+XoY0huExTbs8MQv1DuS5FS86+SEjdM9F/+GS61gg5Hqbtj8ZiDSx8MfWcyei907fIPbfPGCOrNUTnVHY1g=="], + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "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=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="], + "benchmark": ["benchmark@2.1.4", "", { "dependencies": { "lodash": "^4.17.4", "platform": "^1.3.3" } }, "sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ=="], "braces": ["braces@3.0.2", "", { "dependencies": { "fill-range": "^7.0.1" } }, "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A=="], @@ -167,12 +191,16 @@ "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cross-spawn": ["cross-spawn@7.0.3", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], @@ -233,10 +261,22 @@ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "fast-json-stringify": ["fast-json-stringify@6.0.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^2.0.0", "rfdc": "^1.2.0" } }, "sha512-s7SJE83QKBZwg54dIbD5rCtzOBVD43V1ReWXXYqBgwCwHLYAAT0RQc/FmrQglXqWPpz6omtryJQOau5jI4Nrvg=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], + + "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], + + "fastify": ["fastify@5.5.0", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-ZWSWlzj3K/DcULCnCjEiC2zn2FBPdlZsSA/pnPa/dbUfLvxkD/Nqmb0XXMXLrWkeM4uQPUvjdJpwtXmTfriXqw=="], + "fastq": ["fastq@1.15.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw=="], "fdir": ["fdir@6.1.0", "", { "peerDependencies": { "picomatch": "2.x" } }, "sha512-274qhz5PxNnA/fybOu6apTCUnM0GnO3QazB6VH+oag/7DQskdYq8lm07ZSm90kEQuWYH5GvjAxGruuHrEr0bcg=="], @@ -245,6 +285,8 @@ "fill-range": ["fill-range@7.0.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ=="], + "find-my-way": ["find-my-way@9.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "from": ["from@0.1.7", "", {}, "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="], @@ -273,6 +315,8 @@ "ignore": ["ignore@5.3.0", "", {}, "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg=="], + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -289,10 +333,16 @@ "jsesc": ["jsesc@2.5.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@2.0.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -323,6 +373,8 @@ "npm-run-path": ["npm-run-path@5.2.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -335,24 +387,50 @@ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "pino": ["pino@9.9.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "ps-tree": ["ps-tree@1.2.0", "", { "dependencies": { "event-stream": "=3.3.4" }, "bin": { "ps-tree": "./bin/ps-tree.js" } }, "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "secure-json-parse": ["secure-json-parse@4.0.0", "", {}, "sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA=="], + "semver": ["semver@6.3.0", "", { "bin": { "semver": "./bin/semver.js" } }, "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -363,8 +441,12 @@ "slash": ["slash@4.0.0", "", {}, "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], + "split": ["split@0.3.3", "", { "dependencies": { "through": "2" } }, "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stream-combiner": ["stream-combiner@0.0.4", "", { "dependencies": { "duplexer": "~0.1.1" } }, "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw=="], "string-width": ["string-width@7.1.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw=="], @@ -375,6 +457,8 @@ "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -383,6 +467,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -407,8 +493,14 @@ "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=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "fastify/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + "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=="], diff --git a/bench/package.json b/bench/package.json index e4feeb93f4..b65de87a31 100644 --- a/bench/package.json +++ b/bench/package.json @@ -18,6 +18,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "string-width": "7.1.0", + "strip-ansi": "^7.1.0", "tinycolor2": "^1.6.0", "zx": "^7.2.3" }, diff --git a/bench/snippets/strip-ansi.mjs b/bench/snippets/strip-ansi.mjs new file mode 100644 index 0000000000..183f5461f3 --- /dev/null +++ b/bench/snippets/strip-ansi.mjs @@ -0,0 +1,37 @@ +import npmStripAnsi from "strip-ansi"; +import { bench, run } from "../runner.mjs"; + +let bunStripANSI = null; +if (!process.env.FORCE_NPM) { + bunStripANSI = globalThis?.Bun?.stripANSI; +} + +const stripANSI = bunStripANSI || npmStripAnsi; +const formatter = new Intl.NumberFormat(); +const format = n => { + return formatter.format(n); +}; + +const inputs = [ + ["hello world", "no-ansi"], + ["\x1b[31mred\x1b[39m", "ansi"], + ["a".repeat(1024 * 16), "long-no-ansi"], + ["\x1b[31mred\x1b[39m".repeat(1024 * 16), "long-ansi"], +]; + +const maxInputLength = Math.max(...inputs.map(([input]) => input.length)); + +for (const [input, textLabel] of inputs) { + const label = bunStripANSI ? "Bun.stripANSI" : "npm/strip-ansi"; + const name = `${label} ${format(input.length).padStart(format(maxInputLength).length, " ")} chars ${textLabel}`; + + bench(name, () => { + stripANSI(input); + }); + + if (bunStripANSI && bunStripANSI(input) !== npmStripAnsi(input)) { + throw new Error("strip-ansi mismatch"); + } +} + +await run(); diff --git a/bun.lock b/bun.lock index d3b8264753..cb7e8f42c2 100644 --- a/bun.lock +++ b/bun.lock @@ -39,8 +39,8 @@ }, }, "overrides": { - "@types/bun": "workspace:packages/@types/bun", "bun-types": "workspace:packages/bun-types", + "@types/bun": "workspace:packages/@types/bun", }, "packages": { "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], @@ -147,7 +147,7 @@ "@octokit/webhooks-types": ["@octokit/webhooks-types@7.6.1", "", {}, "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw=="], - "@sentry/types": ["@sentry/types@7.120.3", "", {}, "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow=="], + "@sentry/types": ["@sentry/types@7.120.4", "", {}, "sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q=="], "@types/aws-lambda": ["@types/aws-lambda@8.10.152", "", {}, "sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw=="], @@ -159,9 +159,9 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], + "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], @@ -311,7 +311,7 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "universal-github-app-jwt": ["universal-github-app-jwt@1.2.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.2" } }, "sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g=="], @@ -333,8 +333,6 @@ "@octokit/webhooks/@octokit/webhooks-methods": ["@octokit/webhooks-methods@4.1.0", "", {}, "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ=="], - "bun-tracestrings/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "camel-case/no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="], "change-case/camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], diff --git a/cmake/sources/CxxSources.txt b/cmake/sources/CxxSources.txt index ddc959fa37..a2a3510ab2 100644 --- a/cmake/sources/CxxSources.txt +++ b/cmake/sources/CxxSources.txt @@ -192,6 +192,7 @@ src/bun.js/bindings/ServerRouteList.cpp src/bun.js/bindings/spawn.cpp src/bun.js/bindings/SQLClient.cpp src/bun.js/bindings/sqlite/JSSQLStatement.cpp +src/bun.js/bindings/stripANSI.cpp src/bun.js/bindings/Strong.cpp src/bun.js/bindings/Uint8Array.cpp src/bun.js/bindings/Undici.cpp diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index af7cdf467e..bb24cbb32c 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -596,6 +596,23 @@ declare module "bun" { options?: StringWidthOptions, ): number; + /** + * Remove ANSI escape codes from a string. + * + * @category Utilities + * + * @param input The string to remove ANSI escape codes from. + * @returns The string with ANSI escape codes removed. + * + * @example + * ```ts + * import { stripANSI } from "bun"; + * + * console.log(stripANSI("\u001b[31mhello\u001b[39m")); // "hello" + * ``` + */ + function stripANSI(input: string): string; + /** * TOML related APIs */ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 790ad54588..57c9f15d8a 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -72,6 +72,10 @@ BUN_DECLARE_HOST_FUNCTION(Bun__fetchPreconnect); BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv7); BUN_DECLARE_HOST_FUNCTION(Bun__randomUUIDv5); +namespace Bun { +JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI); +} + using namespace JSC; using namespace WebCore; @@ -782,6 +786,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj stdin BunObject_lazyPropCb_wrap_stdin DontDelete|PropertyCallback stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2 + stripANSI jsFunctionBunStripANSI DontDelete|Function 1 unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback version constructBunVersion ReadOnly|DontDelete|PropertyCallback which BunObject_callback_which DontDelete|Function 1 diff --git a/src/bun.js/bindings/stripANSI.cpp b/src/bun.js/bindings/stripANSI.cpp new file mode 100644 index 0000000000..da49414a4c --- /dev/null +++ b/src/bun.js/bindings/stripANSI.cpp @@ -0,0 +1,265 @@ +#include "root.h" +#include "stripANSI.h" + +#include +#include +#include + +namespace Bun { +using namespace WTF; + +template +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 +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; + + constexpr size_t stride = SIMD::stride; + // Matches 0x10-0x1f and 0x90-0x9f. These characters have a high + // probability of being escape characters. + constexpr auto escMask = SIMD::splat(static_cast(~0b10001111U)); + constexpr auto escVector = SIMD::splat(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(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 +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 +static std::optional stripANSI(const std::span input) +{ + if (input.empty()) { + // Signal that the original string should be used + return std::nullopt; + } + + StringBuilder result; + bool foundANSI = false; + + auto start = input.data(); + const auto end = start + input.size(); + + while (start != end) { + const auto escPos = findEscapeCharacter(start, end); + if (!escPos) { + // If no escape sequences found, return null to signal that the + // original string should be used. + if (!foundANSI) return std::nullopt; + // Append the rest of the string + result.append(std::span { start, end }); + break; + } + + // Lazily reserve capacity on first ESC found + if (!foundANSI) { + result.reserveCapacity(input.size()); + } + + // Append everything before the escape sequence + result.append(std::span { start, escPos }); + const auto newPos = consumeANSI(escPos, end); + ASSERT(newPos > start); + ASSERT(newPos <= end); + foundANSI = true; + start = newPos; + } + return result.toString(); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionBunStripANSI, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + const JSC::JSValue input = callFrame->argument(0); + + // Convert to JSString to get the view + JSC::JSString* const jsString = input.toString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Get StringView to avoid joining sliced strings + const auto view = jsString->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + if (view->isEmpty()) { + return JSC::JSValue::encode(JSC::jsEmptyString(vm)); + } + + std::optional result; + if (view->is8Bit()) { + result = stripANSI(view->span8()); + } else { + result = stripANSI(view->span16()); + } + + if (!result) { + // If no ANSI sequences were found, return the original string + return JSC::JSValue::encode(jsString); + } + return JSC::JSValue::encode(JSC::jsString(vm, *result)); +} +} diff --git a/src/bun.js/bindings/stripANSI.h b/src/bun.js/bindings/stripANSI.h new file mode 100644 index 0000000000..0518d73969 --- /dev/null +++ b/src/bun.js/bindings/stripANSI.h @@ -0,0 +1,9 @@ +#pragma once + +#include "root.h" + +namespace Bun { + +JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI); + +} diff --git a/src/js/internal/util/inspect.js b/src/js/internal/util/inspect.js index 86d80102d3..2a86431c76 100644 --- a/src/js/internal/util/inspect.js +++ b/src/js/internal/util/inspect.js @@ -2643,7 +2643,7 @@ function formatWithOptionsInternal(inspectOptions, args) { } return str; } - +const stripANSI = Bun.stripANSI; const internalGetStringWidth = $newZigFunction("string.zig", "String.jsGetStringWidth", 1); /** * Returns the number of columns required to display the given string. @@ -2654,24 +2654,9 @@ function getStringWidth(str, removeControlChars = true) { return internalGetStringWidth(str); } -// Regex used for ansi escape code splitting -// Ref: https://github.com/chalk/ansi-regex/blob/f338e1814144efb950276aac84135ff86b72dc8e/index.js -// License: MIT by Sindre Sorhus -// Matches all ansi escape code sequences in a string -const ansiPattern = new RegExp( - "[\\u001B\\u009B][[\\]()#;?]*" + - "(?:(?:(?:(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]+)*" + - "|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]*)*)?" + - "(?:\\u0007|\\u001B\\u005C|\\u009C))" + - "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?" + - "[\\dA-PR-TZcf-nq-uy=><~]))", - "g", -); -const ansi = new RegExp(ansiPattern, "g"); -/** Remove all VT control characters. Use to estimate displayed string width. */ function stripVTControlCharacters(str) { if (typeof str !== "string") throw new codes.ERR_INVALID_ARG_TYPE("str", "string", str); - return RegExpPrototypeSymbolReplace(ansi, str, ""); + return stripANSI(str); } // utils diff --git a/src/js/node/readline.ts b/src/js/node/readline.ts index 59d75ad490..0f9e2bd0d3 100644 --- a/src/js/node/readline.ts +++ b/src/js/node/readline.ts @@ -130,23 +130,14 @@ var getStringWidth = function getStringWidth(str, removeControlChars = true) { return internalGetStringWidth(str); }; -// Regex used for ansi escape code splitting -// Adopted from https://github.com/chalk/ansi-regex/blob/HEAD/index.js -// License: MIT, authors: @sindresorhus, Qix-, arjunmehta and LitoMore -// Matches all ansi escape code sequences in a string -var ansiPattern = - "[\\u001B\\u009B][[\\]()#;?]*" + - "(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*" + - "|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)" + - "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"; -var ansi = new RegExp(ansiPattern, "g"); +const stripANSI = Bun.stripANSI; /** * Remove all VT control characters. Use to estimate displayed string width. */ function stripVTControlCharacters(str) { validateString(str, "str"); - return RegExpPrototypeSymbolReplace.$call(ansi, str, ""); + return stripANSI(str); } // Constants diff --git a/test/bun.lock b/test/bun.lock index 6237571406..d5dc61d49f 100644 --- a/test/bun.lock +++ b/test/bun.lock @@ -25,6 +25,7 @@ "@testing-library/react": "16.1.0", "@verdaccio/config": "6.0.0-6-next.76", "acorn": "8.15.0", + "ansi-regex": "6.1.0", "astro": "5.5.5", "aws-cdk-lib": "2.148.0", "axios": "1.6.8", @@ -871,7 +872,7 @@ "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], - "ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="], + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3237,6 +3238,8 @@ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="], + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "strip-literal/acorn": ["acorn@8.12.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw=="], diff --git a/test/js/bun/util/stripANSI.test.ts b/test/js/bun/util/stripANSI.test.ts new file mode 100644 index 0000000000..a7dbd8073c --- /dev/null +++ b/test/js/bun/util/stripANSI.test.ts @@ -0,0 +1,481 @@ +import { heapStats } from "bun:jsc"; +import { describe, expect, test } from "bun:test"; +import stripAnsi from "strip-ansi"; + +describe("Bun.stripANSI", () => { + test("returns same string object when no ANSI sequences present", () => { + var input = "hello world"; + const stripANSI = Bun.stripANSI; + const numStrings = heapStats().objectTypeCounts.string; + const result = stripANSI(input); + // Make sure the string wasn't modified + expect(result).toBe(input); + // Verify it's the same object, not a copy + expect(heapStats().objectTypeCounts.string).toBe(numStrings); + }); + + test("returns new string when ANSI sequences are removed", () => { + const input = "\x1b[31mhello\x1b[0m world"; + const result = Bun.stripANSI(input); + expect(result).toBe("hello world"); + // Verify it's a different object + expect(result === input).toBe(false); + }); + + // Tests of the form [input, expected] are used when strip-ansi's behavior + // is incorrect or undesirable. + const testCases: (string | [string, string])[] = [ + // Basic colors + "\x1b[31mred\x1b[39m", + "\x1b[32mgreen\x1b[39m", + "\x1b[33myellow\x1b[39m", + "\x1b[34mblue\x1b[39m", + "\x1b[35mmagenta\x1b[39m", + "\x1b[36mcyan\x1b[39m", + "\x1b[37mwhite\x1b[39m", + + // Background colors + "\x1b[41mred background\x1b[49m", + "\x1b[42mgreen background\x1b[49m", + + // Text styles + "\x1b[1mbold\x1b[22m", + "\x1b[2mdim\x1b[22m", + "\x1b[3mitalic\x1b[23m", + "\x1b[4munderline\x1b[24m", + "\x1b[5mblink\x1b[25m", + "\x1b[7mreverse\x1b[27m", + "\x1b[8mhidden\x1b[28m", + "\x1b[9mstrikethrough\x1b[29m", + + // 256 colors + "\x1b[38;5;196mred\x1b[39m", + "\x1b[48;5;196mred background\x1b[49m", + + // RGB colors + "\x1b[38;2;255;0;0mred\x1b[39m", + "\x1b[48;2;255;0;0mred background\x1b[49m", + + // Cursor movement + "\x1b[2Aup", + "\x1b[2Bdown", + "\x1b[2Cforward", + "\x1b[2Dback", + "\x1b[Hhome", + "\x1b[2;3Hposition", + + // Erase sequences + "\x1b[2Jclear", + "\x1b[Kclear line", + "\x1b[1Kclear line before", + "\x1b[2Kclear entire line", + + // Combined sequences + "\x1b[1;31mbold red\x1b[0m", + "\x1b[1;4;31mbold underline red\x1b[0m", + "\x1b[31;42mred on green\x1b[0m", + + // Nested sequences + "\x1b[31mred \x1b[1mbold\x1b[22m red\x1b[39m", + "\x1b[31m\x1b[32m\x1b[33myellow\x1b[39m", + + // OSC sequences + ["\x1b]0;window title\x07text", "text"], + ["\x1b]0;window title\x1b\\text", "text"], + "\x1b]8;;https://example.com\x07link\x1b]8;;\x07", + + // Other escape sequences + "\x1b(Btext", + "\x1b)Btext", + ["\x1b*Btext", "text"], + ["\x1b+Btext", "text"], + "\x1b=text", + "\x1b>text", + "\x1bDtext", + "\x1bEtext", + "\x1bHtext", + "\x1bMtext", + ["\x1b7text", "text"], + ["\x1b8text", "text"], + ["\x1b#8text", "text"], + ["\x1b%Gtext", "text"], + + // No ANSI codes + "plain text", + "", + "hello world", + + // Partial sequences + ["text\x1b", "text"], + ["text\x1b[", "text"], + "text\x1b[3", + + // Real world examples + "\x1b[2K\x1b[1G\x1b[36m?\x1b[39m Installing...", + "\x1b[32m+ added\x1b[39m\n\x1b[31m- removed\x1b[39m", + "\x1b[1A\x1b[2K\x1b[32m████████\x1b[39m 100%", + + // Unicode handling + "\x1b[31m你好\x1b[39m", + "\x1b[32m😀\x1b[39m", + "\x1b[33m🚀 rocket\x1b[39m", + + // SGR parameters + "\x1b[0;1;31mtext\x1b[0m", + "\x1b[;;mtext", + "\x1b[1;;31mtext\x1b[m", + + // Reset sequences + "\x1b[0mtext", + "\x1b[mtext", + "text\x1b[0m", + "text\x1b[m", + + // Malformed sequences + "\x1b[31text", + "\x1b[moretext", + ["\x1b]incomplete", ""], + ["\x1b]", ""], + "\x1b]i", + ["\x1b]in", ""], + ["\x1b]inc", ""], + + // Preserves whitespace + "\x1b[31m text \x1b[39m", + "\x1b[31m\ttext\t\x1b[39m", + "\x1b[31m\ntext\n\x1b[39m", + + // Edge cases + "\x1b[mtext", + "\x1b[0m\x1b[0m\x1b[0mtext", + "text\x1b[31m", + "\x1b[31m\x1b[32m\x1b[33m", + + // OSC sequences (Operating System Commands) + ["\x1b]0;title\x07text", "text"], + ["\x1b]0;window title\x1b\\text", "text"], + ["\x1b]2;title\x07", ""], + ["\x1b]8;;https://example.com\x07link text\x1b]8;;\x07", "link text"], + ["\x1b]8;;file:///path/to/file\x1b\\clickable\x1b]8;;\x1b\\", "clickable"], + + // C1 CSI sequences (using 0x9B instead of ESC[) + "\x9b31mtext\x9b39m", + "\x9b2Ktext", + "\x9b1Atext", + + // Complex CSI parameters + "\x1b[38;5;196mred text\x1b[0m", + "\x1b[38;2;255;0;0mrgb red\x1b[0m", + "\x1b[48;5;21mblue bg\x1b[0m", + "\x1b[1;4;31mbold underline red\x1b[0m", + + // Cursor movement + "\x1b[10Atext", + "\x1b[5Btext", + "\x1b[20Ctext", + "\x1b[15Dtext", + "\x1b[2;5Htext", + "\x1b[Ktext", + "\x1b[2Jtext", + + // Save/restore cursor + ["\x1b[stext\x1b[u", "text"], + ["\x1b7text\x1b8", "text"], + + // Scroll sequences + "\x1b[5Stext", + "\x1b[3Ttext", + + // Alternative CSI final bytes + "\x1b[?25htext", // show cursor + "\x1b[?25ltext", // hide cursor + ["\x1b[=3htext", "text"], + ["\x1b[>5ctext", "text"], + ["\x1b[<6~text", "text"], + + // Prefix characters in sequences + "\x1b[?1049htext", + ["\x1b]#text", ""], // missing ST + "\x1b[(text", + "\x1b[)text", + "\x1b[;text", + + // Multiple parameters with empty values + "\x1b[;5;mtext", + "\x1b[31;;39mtext", + "\x1b[;;;mtext", + + // Large parameter numbers + ["\x1b[12345mtext", "text"], + "\x1b[1234mtext", + "\x1b[9999;1234mtext", + + // String terminator variations + ["\x1b]0;title\x9ctext", "text"], // 0x9C terminator + ["\x1b]2;test\x07more", "more"], + + // Mixed sequences + ["\x1b[31m\x1b]0;title\x07\x1b[39mtext", "text"], + ["\x1b]8;;\x07\x1b[4mlink\x1b[24m\x1b]8;;\x07", "link"], + + // Sequences at boundaries + "\x1b[31m", + "\x1b[31mtext\x1b[39m\x1b[32m", + "start\x1b[31mtext\x1b[39mend", + + // Invalid but should be partially consumed + "\x1b[31invalid", // 3 should be consumed as CSI final + "\x1b[9invalid", // 9 should be consumed as CSI final + "\x1b[Zinvalid", // Z should be consumed as CSI final + + // Very long parameter sequences + "\x1b[1;2;3;4;5;6;7;8;9;10;11;12mtext\x1b[0m", + "\x1b[" + "1;".repeat(100) + "mtext", + + // Nested-looking sequences (not actually nested) + ["\x1b[31m\x1b in text\x1b[39m", "n text"], // ESC SP is a two-byte sequence + ["\x1b]0;\x1b[31mred\x1b[39m\x07text", "text"], + + // Control characters mixed with ANSI + "\x1b[31m\x08\x09\x0a\x0d\x1b[39m", + + // Real terminal sequences + "\x1b[?1049h\x1b[22;0;0t\x1b[1;1H\x1b[2Jtext", + "\x1b[H\x1b[2J\x1b[3J", // clear screen sequence + "\x1b[6n", // cursor position query + + // Edge cases with C1 CSI (0x9B) + "\x9b31mtext\x9b39m", + ["\x9b[31mtext", "31mtext"], // 0x9B followed by [ is invalid + "\x9bHtext", // Cursor Home + "\x9b2Jtext", // Clear Screen + + // OSC sequences with various terminators + ["\x1b]0;Window Title\x1b\\text", "text"], // ESC \ terminator + ["\x1b]1;Icon Name\x07text", "text"], // BEL terminator + ["\x1b]2;Both\x9ctext", "text"], // ST terminator + ["\x1b]8;;http://example.com\x07", ""], // Hyperlink OSC + + // Invalid OSC sequences (missing terminator) + ["\x1b]0;title", ""], // No terminator, consumes rest + ["\x1b]2;test\x1bother", ""], // Incomplete ESC terminator + + // Complex prefix combinations + ["\x1b[[[31mtext", "[31mtext"], // [ terminates CSI + ["\x1b]]]]0;title\x07text", "text"], + ["\x1b()()#;?31mtext", "()#;?31mtext"], // ESC ( is a two-byte sequence + ["\x1b#?#?[31mtext", "#?[31mtext"], // ESC # is a two-byte sequence + + // CSI sequences with intermediate bytes + ["\x1b[!ptext", "text"], // DECSTR + ['\x1b["qtext', "text"], // DECSCA + ["\x1b[$ptext", "text"], // DECRQM + ["\x1b[%@text", "text"], // Select UTF-8 + + // Private mode sequences + "\x1b[?25htext", // Show cursor + "\x1b[?25ltext", // Hide cursor + "\x1b[?1049htext", // Alternative screen buffer + "\x1b[?2004htext", // Bracketed paste mode + + // SGR (Select Graphic Rendition) variations + "\x1b[38;5;196mtext", // 256-color foreground + "\x1b[48;2;255;0;0mtext", // RGB background + "\x1b[38;2;0;255;0;48;5;17mtext", // Mixed RGB and 256-color + + // Function key sequences + "\x1b[11~text", // F1 + "\x1b[24~text", // F12 + "\x1b[1;5Ptext", // Ctrl+F1 + + // Cursor movement sequences + "\x1b[10;20Htext", // Cursor position + "\x1b[5Atext", // Cursor up + "\x1b[3Btext", // Cursor down + "\x1b[2Ctext", // Cursor right + "\x1b[4Dtext", // Cursor left + + // Erase sequences + "\x1b[0Ktext", // Erase to end of line + "\x1b[1Ktext", // Erase to beginning of line + "\x1b[2Ktext", // Erase entire line + "\x1b[0Jtext", // Erase to end of screen + "\x1b[1Jtext", // Erase to beginning of screen + "\x1b[2Jtext", // Erase entire screen + "\x1b[3Jtext", // Erase scrollback buffer + + // Save/restore cursor + ["\x1b[stext\x1b[u", "text"], // Save and restore cursor + ["\x1b7text\x1b8", "text"], // Save and restore cursor (alternate) + + // Scroll sequences + "\x1b[5Stext", // Scroll up + "\x1b[3Ttext", // Scroll down + "\x1bMtext", // Reverse line feed + "\x1bDtext", // Line feed + + // Tab sequences + "\x1b[3gtext", // Clear tab stop + "\x1b[0gtext", // Clear tab stop at cursor + "\x1bHtext", // Set tab stop + + // Insert/delete sequences + ["\x1b[5@text", "text"], // Insert characters + "\x1b[3Ptext", // Delete characters + "\x1b[2Ltext", // Insert lines + "\x1b[4Mtext", // Delete lines + + // Mode setting sequences + "\x1b[4htext", // Insert mode + "\x1b[4ltext", // Replace mode + "\x1b[20htext", // Automatic newline + "\x1b[20ltext", // Normal linefeed + + // Device status report + "\x1b[5ntext", // Device status report + "\x1b[6ntext", // Cursor position report + "\x1b[?15ntext", // Printer status report + + // Character sets + "\x1b(Atext", // UK character set + "\x1b)Btext", // US character set + ["\x1b*0text", "text"], // DEC special character set + ["\x1b+Btext", "text"], // G3 character set + + // Double-width/height sequences + ["\x1b#3text", "text"], // Double-height line (top half) + ["\x1b#4text", "text"], // Double-height line (bottom half) + ["\x1b#5text", "text"], // Single-width line + ["\x1b#6text", "text"], // Double-width line + + // Malformed sequences that should partially match + "\x1b[31", // Incomplete CSI (no final byte) + ["\x1b[31;", ""], // Incomplete parameters + "\x1b[31;4", // Incomplete parameters + ["\x1b]0;title", ""], // Incomplete OSC + ["\x1b]0;title\x1b", ""], // Incomplete OSC terminator + + // Sequences with invalid parameters + ["\x1b[99999mtext", "text"], // Parameter too long (>4 digits), but strip anyway + "\x1b[;;;;;mtext", // Multiple empty parameters + "\x1b[1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20mtext", // Many parameters + + // Mixed valid and invalid sequences + "\x1b[31mred\x1binvalid\x1b[39mnormal", + "\x1b]0;title\x07\x1binvalid\x1b[32mgreen", + + // Unicode content in sequences + ["\x1b]0;タイトル\x07text", "text"], // Japanese in OSC + ["\x1b]2;🚀 rocket\x07text", "text"], // Emoji in OSC + "\x1b[31m🌈 rainbow\x1b[39m after", + + // Zero-width sequences + "\x1b[0mtext", // Reset all attributes + "\x1b[mtext", // Reset all attributes (no parameters) + "\x1b[;mtext", // Reset with empty parameter + + // Application keypad sequences + "\x1b=text", // Application keypad mode + "\x1b>text", // Numeric keypad mode + + // Bracketed paste sequences + "\x1b[200~pasted\x1b[201~text", + + // Focus events + "\x1b[Itext", // Focus in + "\x1b[Otext", // Focus out + + // Multiple sequences of varying lengths + "\x1b[31m\x1b[32m\x1b[33m\x1b[34m\x1b[35m\x1b[36m\x1b[37mtext", // 7 short sequences + "\x1b[38;5;196m\x1b[48;5;21m\x1b[1m\x1b[4mtext\x1b[0m", // Mixed length sequences + "\x1b[31mred\x1b[32mgreen\x1b[33myellow\x1b[34mblue\x1b[39mnormal", + + // Long sequences (>16 characters) + "\x1b[38;2;255;128;64;48;5;196;1;4;9;7mtext", // Very long CSI with many parameters + ["\x1b]0;This is a very long window title that exceeds 16 characters\x07text", "text"], // Long OSC + "\x1b]8;;https://very-long-domain-name.example.com/path/to/resource\x07link\x1b]8;;\x07", // Long URL in OSC + "\x1b[38;2;255;255;255;48;2;128;128;128;1;3;4;9mstyledtext\x1b[0m", // RGB colors with attributes + + // Multiple long sequences + [ + "\x1b]0;Window Title\x07\x1b[38;2;255;0;0;48;2;0;255;0mcolorful\x1b[0m\x1b]8;;https://example.com\x07link\x1b]8;;\x07", + "colorfullink", + ], + "\x1b[38;5;196;48;5;21;1;4;9mstyle1\x1b[38;5;46;48;5;201;22;24;29mstyle2\x1b[0m", + + // Sequences with maximum parameter counts + "\x1b[1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20;21;22;23;24;25;26;27;28;29;30mtext", + "\x1b[255;255;255;255;255;255;255;255;255;255;255;255;255;255;255;255;255;255;255;255mtext", + + // Mixed short and long sequences in succession + "\x1b[31m\x1b[38;2;255;0;0;48;2;0;255;0;1;4;9m\x1b[32m\x1b[38;5;196;48;5;21;22;24mtext", + "\x1b[H\x1b[2J\x1b[38;2;255;255;255;48;2;0;0;0;1;3;4;7;9;53mstyledtext\x1b[0m\x1b[K", + + // Long OSC sequences with various terminators + ["\x1b]0;Title with special chars !@#$%^&*()_+-=[]{}|;:,.<>?\x07text", "text"], + "\x1b]8;;https://user:pass@subdomain.example.com:8080/path/to/resource?query=value#fragment\x07hyperlink\x1b]8;;\x07", + ["\x1b]2;Icon name with unicode: 🚀🌈⭐💎🎯\x1b\\text", "text"], + + // Sequences that span SIMD boundaries (assuming 16-byte chunks) + "\x1b[31m123456789012345\x1b[32mtext", // Crosses 16-byte boundary + "12345678901234567\x1b[31mtext", // ANSI starts after 16 bytes + "123456789012345\x1b[38;2;255;0;0mtext", // Long sequence after 15 chars + + // Multiple sequences with content between that crosses SIMD boundaries + "\x1b[31m12345678901234567890\x1b[32m12345678901234567890\x1b[33mtext", + "prefix\x1b[31m12345678901234567890\x1b[32mmiddle\x1b[33m12345678901234567890suffix", + + // Very long content with scattered sequences + "a".repeat(100) + "\x1b[31m" + "b".repeat(50) + "\x1b[32m" + "c".repeat(100), + "\x1b[31m" + "x".repeat(200) + "\x1b[32m" + "y".repeat(200) + "\x1b[0m", + + // Complex mixed sequences with varying parameter lengths + "\x1b[1m\x1b[38;5;196m\x1b[48;2;255;255;255m\x1b[4;9;53mcomplex\x1b[22;24;29;49;39mtext", + "\x1b]0;\x07\x1b[31;32;33;34;35;36;37mcolors\x1b[0m\x1b]8;;\x07", + + // Alternating short and long sequences + "\x1b[31m\x1b[38;2;255;0;0;48;2;0;255;0m\x1b[32m\x1b[38;5;196;48;5;21;1;4mtext", + "\x1b[H\x1b[38;2;255;255;255;48;2;0;0;0;1;3;4;7;9mstyle\x1b[K\x1b[38;5;46mmore\x1b[0m", + + // Strip a single escape character + ["\x1b", ""], + ]; + + for (const testCase of testCases) { + let input; + let expected; + if (testCase instanceof Array) { + [input, expected] = testCase; + } else { + input = testCase; + expected = stripAnsi(input); + } + test(JSON.stringify(input), () => { + const received = Bun.stripANSI(input); + expect(Bun.stripANSI(input), `${JSON.stringify(expected)} != ${JSON.stringify(received)}`).toBe(expected); + }); + } + + test("long strings", () => { + const longText = "a".repeat(10000); + const withAnsi = `\x1b[31m${longText}\x1b[39m`; + expect(Bun.stripANSI(withAnsi)).toBe(stripAnsi(withAnsi)); + }); + + test("multiple sequences in long string", () => { + const parts = []; + for (let i = 0; i < 1000; i++) { + parts.push(`\x1b[${30 + (i % 8)}mword${i}\x1b[39m`); + } + const input = parts.join(" "); + expect(Bun.stripANSI(input)).toBe(stripAnsi(input)); + }); + + test("non-string input", () => { + expect(Bun.stripANSI(123 as any)).toBe("123"); + expect(Bun.stripANSI(true as any)).toBe("true"); + expect(Bun.stripANSI(false as any)).toBe("false"); + expect(Bun.stripANSI(null as any)).toBe("null"); + expect(Bun.stripANSI(undefined as any)).toBe("undefined"); + }); +}); diff --git a/test/package.json b/test/package.json index 022660b278..8b302e35dc 100644 --- a/test/package.json +++ b/test/package.json @@ -30,6 +30,7 @@ "@testing-library/react": "16.1.0", "@verdaccio/config": "6.0.0-6-next.76", "acorn": "8.15.0", + "ansi-regex": "6.1.0", "astro": "5.5.5", "aws-cdk-lib": "2.148.0", "axios": "1.6.8", @@ -66,6 +67,7 @@ "mysql2": "3.7.0", "node-gyp": "10.0.1", "nodemailer": "6.9.3", + "p-queue": "8.1.0", "pg": "8.11.1", "pg-connection-string": "2.6.1", "pg-gateway": "0.3.0-beta.4", @@ -90,7 +92,6 @@ "string-width": "7.0.0", "strip-ansi": "7.1.0", "stripe": "15.4.0", - "p-queue": "8.1.0", "superagent": "10.2.2", "supertest": "6.3.3", "svelte": "5.20.4",