From 29f76a451d64757b8b833c7e34b25f139000857c Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 24 Mar 2023 13:46:27 +0100 Subject: [PATCH 1/8] Improve performance (#38) --- index.js | 209 +++++++++++++++++++++++++++++++++++-------------------- test.js | 8 ++- 2 files changed, 140 insertions(+), 77 deletions(-) diff --git a/index.js b/index.js index e10af34..72be526 100755 --- a/index.js +++ b/index.js @@ -1,105 +1,164 @@ -import isFullwidthCodePoint from 'is-fullwidth-code-point'; import ansiStyles from 'ansi-styles'; +import isFullwidthCodePoint from 'is-fullwidth-code-point'; -const astralRegex = /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/; +// \x1b and \x9b +const ESCAPES = new Set([27, 155]); -const ESCAPES = [ - '\u001B', - '\u009B' -]; +const CHAR_CODE_0 = '0'.charCodeAt(0); +const CHAR_CODE_9 = '9'.charCodeAt(0); -const wrapAnsi = code => `${ESCAPES[0]}[${code}m`; +const endCodesSet = new Set(); +const endCodesMap = new Map(); +for (const [start, end] of ansiStyles.codes) { + endCodesSet.add(ansiStyles.color.ansi(end)); + endCodesMap.set(ansiStyles.color.ansi(start), ansiStyles.color.ansi(end)); +} -const checkAnsi = (ansiCodes, isEscapes, endAnsiCode) => { - let output = []; - ansiCodes = [...ansiCodes]; +function getEndCode(code) { + if (endCodesSet.has(code)) { + return code; + } - for (let ansiCode of ansiCodes) { - const ansiCodeOrigin = ansiCode; - if (ansiCode.includes(';')) { - ansiCode = ansiCode.split(';')[0][0] + '0'; - } + if (endCodesMap.has(code)) { + return endCodesMap.get(code); + } - const item = ansiStyles.codes.get(Number.parseInt(ansiCode, 10)); - if (item) { - const indexEscape = ansiCodes.indexOf(item.toString()); - if (indexEscape === -1) { - output.push(wrapAnsi(isEscapes ? item : ansiCodeOrigin)); - } else { - ansiCodes.splice(indexEscape, 1); - } - } else if (isEscapes) { - output.push(wrapAnsi(0)); - break; - } else { - output.push(wrapAnsi(ansiCodeOrigin)); - } + code = code.slice(2); + if (code.includes(';')) { + code = code[0] + '0'; } - if (isEscapes) { - output = output.filter((element, index) => output.indexOf(element) === index); + const returnValue = ansiStyles.codes.get(Number.parseInt(code, 10)); + if (returnValue) { + return ansiStyles.color.ansi(returnValue); + } + + return ansiStyles.reset.open; +} - if (endAnsiCode !== undefined) { - const fistEscapeCode = wrapAnsi(ansiStyles.codes.get(Number.parseInt(endAnsiCode, 10))); - // TODO: Remove the use of `.reduce` here. - // eslint-disable-next-line unicorn/no-array-reduce - output = output.reduce((current, next) => next === fistEscapeCode ? [next, ...current] : [...current, next], []); +function findNumberIndex(string) { + for (let index = 0; index < string.length; index++) { + const charCode = string.charCodeAt(index); + if (charCode >= CHAR_CODE_0 && charCode <= CHAR_CODE_9) { + return index; } } - return output.join(''); -}; + return -1; +} -export default function sliceAnsi(string, begin, end) { - const characters = [...string]; - const ansiCodes = []; +function parseAnsiCode(string, offset) { + string = string.slice(offset, offset + 19); + const startIndex = findNumberIndex(string); + if (startIndex !== -1) { + let endIndex = string.indexOf('m', startIndex); + if (endIndex === -1) { + endIndex = string.length; + } - let stringEnd = typeof end === 'number' ? end : characters.length; - let isInsideEscape = false; - let ansiCode; - let visible = 0; - let output = ''; + return string.slice(0, endIndex + 1); + } +} - for (const [index, character] of characters.entries()) { - let leftEscape = false; +function tokenize(string, endChar = Number.POSITIVE_INFINITY) { + const returnValue = []; + + let index = 0; + let visibleCount = 0; + while (index < string.length) { + const codePoint = string.codePointAt(index); + + if (ESCAPES.has(codePoint)) { + const code = parseAnsiCode(string, index); + if (code) { + returnValue.push({ + type: 'ansi', + code, + endCode: getEndCode(code) + }); + index += code.length; + continue; + } + } - if (ESCAPES.includes(character)) { - const code = /\d[^m]*/.exec(string.slice(index, index + 18)); - ansiCode = code && code.length > 0 ? code[0] : undefined; + const isFullWidth = isFullwidthCodePoint(codePoint); + const character = String.fromCodePoint(codePoint); + + returnValue.push({ + type: 'character', + value: character, + isFullWidth + }); + index += character.length; + visibleCount += isFullWidth ? 2 : character.length; + if (visibleCount >= endChar) { + break; + } + } - if (visible < stringEnd) { - isInsideEscape = true; + return returnValue; +} - if (ansiCode !== undefined) { - ansiCodes.push(ansiCode); - } - } - } else if (isInsideEscape && character === 'm') { - isInsideEscape = false; - leftEscape = true; +function reduceAnsiCodes(codes) { + let returnValue = []; + for (const code of codes) { + if (code.code === ansiStyles.reset.open) { + // Reset code, disable all codes + returnValue = []; + } else if (endCodesSet.has(code.code)) { + // This is an end code, disable all matching start codes + returnValue = returnValue.filter(returnValueCode => returnValueCode.endCode !== code.code); + } else { + // This is a start code. Disable all styles this "overrides", then enable it + returnValue = returnValue.filter(returnValueCode => returnValueCode.endCode !== code.endCode); + returnValue.push(code); } + } + + return returnValue; +} - if (!isInsideEscape && !leftEscape) { - visible++; +function undoAnsiCodes(codes) { + const reduced = reduceAnsiCodes(codes); + const endCodes = reduced.map(({endCode}) => endCode); + return endCodes.reverse().join(''); +} + +export default function sliceAnsi(string, begin, end) { + const tokens = tokenize(string, end); + let activeCodes = []; + let position = 0; + let returnValue = ''; + let include = false; + + for (const token of tokens) { + if (end !== undefined && position >= end) { + break; } - if (!astralRegex.test(character) && isFullwidthCodePoint(character.codePointAt())) { - visible++; + if (token.type === 'ansi') { + activeCodes.push(token); + if (include) { + returnValue += token.code; + } + } else { + // Char + if (!include && position >= begin) { + include = true; + // Simplify active codes + activeCodes = reduceAnsiCodes(activeCodes); + returnValue = activeCodes.map(({code}) => code).join(''); + } - if (typeof end !== 'number') { - stringEnd++; + if (include) { + returnValue += token.value; } - } - if (visible > begin && visible <= stringEnd) { - output += character; - } else if (visible === begin && !isInsideEscape && ansiCode !== undefined) { - output = checkAnsi(ansiCodes); - } else if (visible >= stringEnd) { - output += checkAnsi(ansiCodes, true, ansiCode); - break; + position += token.isFullWidth ? 2 : token.value.length; } } - return output; + // Disable active codes at the end + returnValue += undoAnsiCodes(activeCodes); + return returnValue; } diff --git a/test.js b/test.js index 07eb8d7..3a7e2ab 100755 --- a/test.js +++ b/test.js @@ -83,7 +83,7 @@ test('weird null issue', t => { }); test('support true color escape sequences', t => { - t.is(sliceAnsi('\u001B[1m\u001B[48;2;255;255;255m\u001B[38;2;255;0;0municorn\u001B[39m\u001B[49m\u001B[22m', 0, 3), '\u001B[1m\u001B[48;2;255;255;255m\u001B[38;2;255;0;0muni\u001B[22m\u001B[49m\u001B[39m'); + t.is(sliceAnsi('\u001B[1m\u001B[48;2;255;255;255m\u001B[38;2;255;0;0municorn\u001B[39m\u001B[49m\u001B[22m', 0, 3), '\u001B[1m\u001B[48;2;255;255;255m\u001B[38;2;255;0;0muni\u001B[39m\u001B[49m\u001B[22m'); }); // See https://github.com/chalk/slice-ansi/issues/24 @@ -91,7 +91,7 @@ test('doesn\'t add extra escapes', t => { const output = `${chalk.black.bgYellow(' RUNS ')} ${chalk.green('test')}`; t.is(sliceAnsi(output, 0, 7), `${chalk.black.bgYellow(' RUNS ')} `); t.is(sliceAnsi(output, 0, 8), `${chalk.black.bgYellow(' RUNS ')} `); - t.is(JSON.stringify(sliceAnsi('\u001B[31m' + output, 0, 4)), JSON.stringify(`\u001B[31m${chalk.black.bgYellow(' RUN')}`)); + t.is(JSON.stringify(sliceAnsi('\u001B[31m' + output, 0, 4)), JSON.stringify(chalk.black.bgYellow(' RUN'))); }); // See https://github.com/chalk/slice-ansi/issues/26 @@ -99,6 +99,10 @@ test('does not lose fullwidth characters', t => { t.is(sliceAnsi('古古test', 0), '古古test'); }); +test('can create empty slices', t => { + t.is(sliceAnsi('test', 0, 0), ''); +}); + test.failing('slice links', t => { const link = '\u001B]8;;https://google.com\u0007Google\u001B]8;;\u0007'; t.is(sliceAnsi(link, 0, 6), link); From 424b42a6b47effd5c1c0f7e72c5874048d0a4e0b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 24 Mar 2023 19:48:13 +0700 Subject: [PATCH 2/8] Require Node.js 14 --- .github/workflows/main.yml | 7 ++++--- index.js | 25 ++++++++++++++----------- package.json | 14 +++++++------- readme.md | 22 +++++----------------- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d36e1a8..d50ada6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,11 +10,12 @@ jobs: fail-fast: false matrix: node-version: + - 18 + - 16 - 14 - - 12 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/index.js b/index.js index 72be526..eb2b42d 100755 --- a/index.js +++ b/index.js @@ -4,8 +4,8 @@ import isFullwidthCodePoint from 'is-fullwidth-code-point'; // \x1b and \x9b const ESCAPES = new Set([27, 155]); -const CHAR_CODE_0 = '0'.charCodeAt(0); -const CHAR_CODE_9 = '9'.charCodeAt(0); +const CODE_POINT_0 = '0'.codePointAt(0); +const CODE_POINT_9 = '9'.codePointAt(0); const endCodesSet = new Set(); const endCodesMap = new Map(); @@ -38,8 +38,8 @@ function getEndCode(code) { function findNumberIndex(string) { for (let index = 0; index < string.length; index++) { - const charCode = string.charCodeAt(index); - if (charCode >= CHAR_CODE_0 && charCode <= CHAR_CODE_9) { + const codePoint = string.codePointAt(index); + if (codePoint >= CODE_POINT_0 && codePoint <= CODE_POINT_9) { return index; } } @@ -60,7 +60,7 @@ function parseAnsiCode(string, offset) { } } -function tokenize(string, endChar = Number.POSITIVE_INFINITY) { +function tokenize(string, endCharacter = Number.POSITIVE_INFINITY) { const returnValue = []; let index = 0; @@ -74,7 +74,7 @@ function tokenize(string, endChar = Number.POSITIVE_INFINITY) { returnValue.push({ type: 'ansi', code, - endCode: getEndCode(code) + endCode: getEndCode(code), }); index += code.length; continue; @@ -87,11 +87,13 @@ function tokenize(string, endChar = Number.POSITIVE_INFINITY) { returnValue.push({ type: 'character', value: character, - isFullWidth + isFullWidth, }); + index += character.length; visibleCount += isFullWidth ? 2 : character.length; - if (visibleCount >= endChar) { + + if (visibleCount >= endCharacter) { break; } } @@ -101,6 +103,7 @@ function tokenize(string, endChar = Number.POSITIVE_INFINITY) { function reduceAnsiCodes(codes) { let returnValue = []; + for (const code of codes) { if (code.code === ansiStyles.reset.open) { // Reset code, disable all codes @@ -124,7 +127,7 @@ function undoAnsiCodes(codes) { return endCodes.reverse().join(''); } -export default function sliceAnsi(string, begin, end) { +export default function sliceAnsi(string, start, end) { const tokens = tokenize(string, end); let activeCodes = []; let position = 0; @@ -142,8 +145,8 @@ export default function sliceAnsi(string, begin, end) { returnValue += token.code; } } else { - // Char - if (!include && position >= begin) { + // Character + if (!include && position >= start) { include = true; // Simplify active codes activeCodes = reduceAnsiCodes(activeCodes); diff --git a/package.json b/package.json index f5c9945..f524ceb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "module", "exports": "./index.js", "engines": { - "node": ">=12" + "node": ">=14.16" }, "scripts": { "test": "xo && ava" @@ -40,14 +40,14 @@ "text" ], "dependencies": { - "ansi-styles": "^6.0.0", + "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" }, "devDependencies": { - "ava": "^3.15.0", - "chalk": "^4.1.0", - "random-item": "^4.0.0", - "strip-ansi": "^7.0.0", - "xo": "^0.38.2" + "ava": "^5.2.0", + "chalk": "^5.2.0", + "random-item": "^4.0.1", + "strip-ansi": "^7.0.1", + "xo": "^0.53.1" } } diff --git a/readme.md b/readme.md index b92d2e5..783a4ec 100644 --- a/readme.md +++ b/readme.md @@ -4,8 +4,8 @@ ## Install -``` -$ npm install slice-ansi +```sh +npm install slice-ansi ``` ## Usage @@ -22,7 +22,7 @@ console.log(sliceAnsi(string, 20, 30)); ## API -### sliceAnsi(string, beginSlice, endSlice?) +### sliceAnsi(string, startSlice, endSlice?) #### string @@ -30,11 +30,11 @@ Type: `string` String with ANSI escape codes. Like one styled by [`chalk`](https://github.com/chalk/chalk). -#### beginSlice +#### startSlice Type: `number` -Zero-based index at which to begin the slice. +Zero-based index at which to start the slice. #### endSlice @@ -52,15 +52,3 @@ Zero-based index at which to end the slice. - [Sindre Sorhus](https://github.com/sindresorhus) - [Josh Junon](https://github.com/qix-) - ---- - -
- - Get professional support for this package with a Tidelift subscription - -
- - Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies. -
-
From d0e08a8f80ba9bbcccd60a3f879e95904a6e2d5b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 24 Mar 2023 19:53:02 +0700 Subject: [PATCH 3/8] 6.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f524ceb..f8cf32f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slice-ansi", - "version": "5.0.0", + "version": "6.0.0", "description": "Slice a string with ANSI escape codes", "license": "MIT", "repository": "chalk/slice-ansi", From 1d8c446bd5662a97a1980c9a3966ea553dadbec0 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 28 Oct 2023 20:34:38 +0700 Subject: [PATCH 4/8] Require Node.js 18 --- .github/workflows/main.yml | 7 +++---- package.json | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d50ada6..346585c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,11 @@ jobs: fail-fast: false matrix: node-version: + - 20 - 18 - - 16 - - 14 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/package.json b/package.json index f8cf32f..11f5a3a 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,13 @@ ], "dependencies": { "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" + "is-fullwidth-code-point": "^5.0.0" }, "devDependencies": { - "ava": "^5.2.0", - "chalk": "^5.2.0", + "ava": "^5.3.1", + "chalk": "^5.3.0", "random-item": "^4.0.1", - "strip-ansi": "^7.0.1", - "xo": "^0.53.1" + "strip-ansi": "^7.1.0", + "xo": "^0.56.0" } } From 8c0653649528ffb2a2a1e5467005289186d8076b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 28 Oct 2023 23:28:53 +0700 Subject: [PATCH 5/8] 7.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 11f5a3a..1eb6ccd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slice-ansi", - "version": "6.0.0", + "version": "7.0.0", "description": "Slice a string with ANSI escape codes", "license": "MIT", "repository": "chalk/slice-ansi", From 55ed49f13be93faefd82c06edb4c8be15080641e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 30 Oct 2023 02:43:55 +0700 Subject: [PATCH 6/8] Add TypeScript types --- index.d.ts | 19 +++++++++++++++++++ package.json | 12 ++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 index.d.ts diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..9b375be --- /dev/null +++ b/index.d.ts @@ -0,0 +1,19 @@ +/** +Slice a string with [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors_and_Styles) + +@param string - A string with ANSI escape codes. Like one styled by [`chalk`](https://github.com/chalk/chalk). +@param startSlice - Zero-based index at which to start the slice. +@param endSlice - Zero-based index at which to end the slice. + +@example +``` +import chalk from 'chalk'; +import sliceAnsi from 'slice-ansi'; + +const string = 'The quick brown ' + chalk.red('fox jumped over ') + + 'the lazy ' + chalk.green('dog and then ran away with the unicorn.'); + +console.log(sliceAnsi(string, 20, 30)); +``` +*/ +export default function sliceAnsi(string: string, startSlice: number, endSlice?: number): string; diff --git a/package.json b/package.json index 1eb6ccd..391e9aa 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,19 @@ "repository": "chalk/slice-ansi", "funding": "https://github.com/chalk/slice-ansi?sponsor=1", "type": "module", - "exports": "./index.js", + "exports": { + "types": "./index.d.ts", + "default": "./index.js" + }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "scripts": { - "test": "xo && ava" + "test": "xo && ava && tsc index.d.ts" }, "files": [ - "index.js" + "index.js", + "index.d.ts" ], "keywords": [ "slice", From a083b9586b576cf098b8895239a9b4a11f23aba9 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 30 Oct 2023 02:45:27 +0700 Subject: [PATCH 7/8] 7.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 391e9aa..3baa97a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slice-ansi", - "version": "7.0.0", + "version": "7.1.0", "description": "Slice a string with ANSI escape codes", "license": "MIT", "repository": "chalk/slice-ansi", From 400a6ca5c23db8e71bf62d9ebf6082796ce5a7c6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 21 May 2024 10:13:28 +0200 Subject: [PATCH 8/8] Meta tweaks --- index.js | 4 +++- package.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index eb2b42d..6008790 100755 --- a/index.js +++ b/index.js @@ -7,6 +7,8 @@ const ESCAPES = new Set([27, 155]); const CODE_POINT_0 = '0'.codePointAt(0); const CODE_POINT_9 = '9'.codePointAt(0); +const MAX_ANSI_SEQUENCE_LENGTH = 19; + const endCodesSet = new Set(); const endCodesMap = new Map(); for (const [start, end] of ansiStyles.codes) { @@ -48,7 +50,7 @@ function findNumberIndex(string) { } function parseAnsiCode(string, offset) { - string = string.slice(offset, offset + 19); + string = string.slice(offset, offset + MAX_ANSI_SEQUENCE_LENGTH); const startIndex = findNumberIndex(string); if (startIndex !== -1) { let endIndex = string.indexOf('m', startIndex); diff --git a/package.json b/package.json index 3baa97a..edb3392 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "types": "./index.d.ts", "default": "./index.js" }, + "sideEffects": false, "engines": { "node": ">=18" },