Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions benchmark/util/strip-vt-control-characters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

const common = require('../common.js');

const { stripVTControlCharacters } = require('node:util');
const assert = require('node:assert');

const bench = common.createBenchmark(main, {
input: ['noAnsi-short', 'noAnsi-long', 'ansi-short', 'ansi-long'],
n: [1e6],
});

function main({ input, n }) {
let str;
switch (input) {
case 'noAnsi-short':
str = 'This is a plain text string without any ANSI codes';
break;
case 'noAnsi-long':
str = 'Long plain text without ANSI. '.repeat(333);
break;
case 'ansi-short':
str = '\u001B[31mHello\u001B[39m';
break;
case 'ansi-long':
str = ('\u001B[31m' + 'colored text '.repeat(10) + '\u001B[39m').repeat(10);
break;
}

bench.start();
for (let i = 0; i < n; i++) {
const result = stripVTControlCharacters(str);
assert.ok(typeof result === 'string');
}
bench.end(n);
}
4 changes: 4 additions & 0 deletions lib/internal/util/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -3036,6 +3036,10 @@ if (internalBinding('config').hasIntl) {
function stripVTControlCharacters(str) {
validateString(str, 'str');

if (!StringPrototypeIncludes(str, '\u001B') &&
!StringPrototypeIncludes(str, '\u009B'))
return str;
Copy link
Copy Markdown
Member

@gurgunday gurgunday Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are doing two passes here, why not use a simple regex here that only checks these 2 characters in one pass?

Suggested change
if (!StringPrototypeIncludes(str, '\u001B') &&
!StringPrototypeIncludes(str, '\u009B'))
return str;
if (!RegExpPrototypeTest(/[\u001B\u009B]/, str))
return str;

Did you test the performance of this? I think this would be even faster

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unlikely that a Regex test will be faster.

Possibly this is better:

if (StringPrototypeIndexOf(str, '\u001B') === -1 &&
    StringPrototypeIndexOf(str, '\u009B') === -1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added a comment in bacd1a7 explaining why the short-circuit is there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gurgunday Thanks for the suggestion! I benchmarked a single RegExpPrototypeTest(/[\u001B\u009B]/, str) call (one pass) vs two StringPrototypeIndexOf calls (two passes). Even with the extra pass, two indexOf calls are faster — especially for longer strings:

=== Short (~50 chars) ===
  2x indexOf                  17.6 ns/op
  1x RegExp.test              29.5 ns/op
  RegExp.test / indexOf = 1.67x

=== Long (~9991 chars) ===
  2x indexOf                 542.6 ns/op
  1x RegExp.test            3991.4 ns/op
  RegExp.test / indexOf = 7.36x

Single-char indexOf is a simpler operation than regex character class matching, so two indexOf passes still wins over one regex pass.

Switched to StringPrototypeIndexOf per @RafaelGSS's suggestion.

Benchmark code
const short = 'Hello, World! This is a test string without ANSI.';
const long = 'Long plain text without ANSI. '.repeat(333);
const re = /[\u001B\u009B]/;
const n = 2_000_000;

const shortArr = Array.from({length: 100}, (_, i) => short + String(i));
const longArr = Array.from({length: 100}, (_, i) => long + String(i));

function bench(label, fn) {
  fn();
  const runs = [];
  for (let r = 0; r < 5; r++) {
    const start = performance.now();
    fn();
    runs.push(performance.now() - start);
  }
  runs.sort((a, b) => a - b);
  const median = runs[Math.floor(runs.length / 2)];
  const perOp = (median / n * 1e6).toFixed(1);
  console.log(label.padEnd(30) + median.toFixed(0).padStart(6) + ' ms  (' + perOp + ' ns/op)');
  return median;
}

console.log('=== Short (~' + shortArr[0].length + ' chars) ===');
const a = bench('  indexOf', () => { let r; for (let i = 0; i < n; i++) { const s = shortArr[i % 100]; r = s.indexOf('\u001B') === -1 && s.indexOf('\u009B') === -1; } return r; });
const b = bench('  RegExp.test', () => { let r; for (let i = 0; i < n; i++) { const s = shortArr[i % 100]; r = !re.test(s); } return r; });
console.log('  RegExp.test / indexOf = ' + (b / a).toFixed(2) + 'x');

console.log('\n=== Long (~' + longArr[0].length + ' chars) ===');
const c = bench('  indexOf', () => { let r; for (let i = 0; i < n; i++) { const s = longArr[i % 100]; r = s.indexOf('\u001B') === -1 && s.indexOf('\u009B') === -1; } return r; });
const d = bench('  RegExp.test', () => { let r; for (let i = 0; i < n; i++) { const s = longArr[i % 100]; r = !re.test(s); } return r; });
console.log('  RegExp.test / indexOf = ' + (d / c).toFixed(2) + 'x');


return RegExpPrototypeSymbolReplace(ansi, str, '');
}

Expand Down
10 changes: 10 additions & 0 deletions test/parallel/test-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,13 @@ assert.throws(() => {
message: 'The "str" argument must be of type string.' +
common.invalidArgTypeHelper({})
});

// stripVTControlCharacters: fast path returns input when no ANSI codes
assert.strictEqual(util.stripVTControlCharacters('hello'), 'hello');
assert.strictEqual(util.stripVTControlCharacters(''), '');

// stripVTControlCharacters: strips 7-bit ESC sequences
assert.strictEqual(util.stripVTControlCharacters('\u001B[31mfoo\u001B[39m'), 'foo');

// stripVTControlCharacters: strips 8-bit CSI sequences
assert.strictEqual(util.stripVTControlCharacters('\u009B31mfoo\u009B39m'), 'foo');
Loading