diff --git a/benchmark/util/style-text.js b/benchmark/util/style-text.js index f04a26646e052d..c50a225fd39331 100644 --- a/benchmark/util/style-text.js +++ b/benchmark/util/style-text.js @@ -5,9 +5,22 @@ const common = require('../common.js'); const { styleText } = require('node:util'); const assert = require('node:assert'); +// 1000 distinct hex colors to exercise the cache under high-miss conditions. +// Spread evenly across hue space so colors are valid and maximally varied. +const kHexColorCount = 1000; +const toHex = (n) => n.toString(16).padStart(2, '0'); +const hexColors = Array.from({ length: kHexColorCount }, (_, i) => { + const r = (i * 37) & 0xff; + const g = (i * 73) & 0xff; + const b = (i * 137) & 0xff; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +}); + const bench = common.createBenchmark(main, { messageType: ['string', 'number', 'boolean', 'invalid'], - format: ['red', 'italic', 'invalid', '#ff0000'], + // '#rotating' cycles through kHexColorCount distinct colors to simulate + // the high-miss-rate / large-cache scenario (e.g. user-randomised colors). + format: ['red', 'italic', 'invalid', '#ff0000', '#rotating'], validateStream: [1, 0], n: [1e3], }); @@ -31,9 +44,10 @@ function main({ messageType, format, validateStream, n }) { bench.start(); for (let i = 0; i < n; i++) { + const fmt = format === '#rotating' ? hexColors[i % kHexColorCount] : format; let colored = ''; try { - colored = styleText(format, str, { validateStream }); + colored = styleText(fmt, str, { validateStream }); assert.ok(colored); // Attempt to avoid dead-code elimination } catch { // eslint-disable no-empty diff --git a/lib/util.js b/lib/util.js index 9601593eaf404a..b9ce892f96bd3f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -38,6 +38,7 @@ const { ObjectValues, ReflectApply, RegExpPrototypeExec, + SafeMap, StringPrototypeSlice, StringPrototypeToWellFormed, } = primordials; @@ -114,8 +115,34 @@ const kEscapeEnd = 'm'; const kDimCode = 2; const kBoldCode = 1; +// Close sequence for 24-bit foreground colors (reset to default foreground) +const kHexCloseSeq = kEscape + '39' + kEscapeEnd; + let styleCache; +const kHexStyleCacheMax = 256; + +class HexStyleFIFOCache { + #cache = new SafeMap(); + + get(key) { + return this.#cache.get(key); + } + + set(key, value) { + if (this.#cache.size >= kHexStyleCacheMax) + this.#cache.delete(this.#cache.keys().next().value); + this.#cache.set(key, value); + } +} + +let hexStyleCache; + +function getHexStyleCache() { + hexStyleCache ??= new HexStyleFIFOCache(); + return hexStyleCache; +} + function getStyleCache() { if (styleCache === undefined) { styleCache = { __proto__: null }; @@ -137,6 +164,26 @@ function getStyleCache() { return styleCache; } +/** + * Returns the cached ANSI escape sequences for a hex color. + * Computes and caches on first use to avoid repeated Buffer allocations. + * @param {string} hex A valid hex color string (#RGB or #RRGGBB) + * @returns {{openSeq: string, closeSeq: string}} + */ +function getHexStyle(hex) { + const cache = getHexStyleCache(); + const cached = cache.get(hex); + if (cached !== undefined) return cached; + const { 0: r, 1: g, 2: b } = hexToRgb(hex); + const style = { + __proto__: null, + openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, + closeSeq: kHexCloseSeq, + }; + cache.set(hex, style); + return style; +} + function replaceCloseCode(str, closeSeq, openSeq, keepClose) { const closeLen = closeSeq.length; let index = str.indexOf(closeSeq); @@ -163,15 +210,6 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) { // Matches #RGB or #RRGGBB const hexColorRegExp = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; -/** - * Validates whether a string is a valid hex color code. - * @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff') - * @returns {boolean} True if valid hex color, false otherwise - */ -function isValidHexColor(hex) { - return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null; -} - /** * Parses a hex color string into RGB components. * Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats. @@ -225,6 +263,17 @@ function styleText(format, text, options) { const processed = replaceCloseCode(text, style.closeSeq, style.openSeq, style.keepClose); return style.openSeq + processed + style.closeSeq; } + + if (format[0] === '#') { + let hexStyle = getHexStyleCache().get(format); + if (hexStyle === undefined && RegExpPrototypeExec(hexColorRegExp, format) !== null) { + hexStyle = getHexStyle(format); + } + if (hexStyle !== undefined) { + const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false); + return hexStyle.openSeq + processed + hexStyle.closeSeq; + } + } } validateString(text, 'text'); @@ -255,24 +304,26 @@ function styleText(format, text, options) { for (const key of formatArray) { if (key === 'none') continue; - if (isValidHexColor(key)) { - if (skipColorize) continue; - const { 0: r, 1: g, 2: b } = hexToRgb(key); - const openSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd; - const closeSeq = kEscape + '39' + kEscapeEnd; - openCodes += openSeq; - closeCodes = closeSeq + closeCodes; - processedText = replaceCloseCode(processedText, closeSeq, openSeq, false); + if (typeof key === 'string' && key[0] === '#') { + let hexStyle = getHexStyleCache().get(key); + if (hexStyle === undefined) { + if (RegExpPrototypeExec(hexColorRegExp, key) === null) { + throw new ERR_INVALID_ARG_VALUE('format', key, + 'must be a valid hex color (#RGB or #RRGGBB)'); + } + if (skipColorize) continue; + hexStyle = getHexStyle(key); + } else if (skipColorize) { + continue; + } + openCodes += hexStyle.openSeq; + closeCodes = hexStyle.closeSeq + closeCodes; + processedText = replaceCloseCode(processedText, hexStyle.closeSeq, hexStyle.openSeq, false); continue; } const style = cache[key]; if (style === undefined) { - // Check if it looks like an invalid hex color (starts with #) - if (typeof key === 'string' && key[0] === '#') { - throw new ERR_INVALID_ARG_VALUE('format', key, - 'must be a valid hex color (#RGB or #RRGGBB)'); - } validateOneOf(key, 'format', ObjectGetOwnPropertyNames(inspect.colors)); } openCodes += style.openSeq;