Skip to content

Commit 29f76a4

Browse files
authored
Improve performance (#38)
1 parent d9f402c commit 29f76a4

File tree

2 files changed

+140
-77
lines changed

2 files changed

+140
-77
lines changed

index.js

Lines changed: 134 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,164 @@
1-
import isFullwidthCodePoint from 'is-fullwidth-code-point';
21
import ansiStyles from 'ansi-styles';
2+
import isFullwidthCodePoint from 'is-fullwidth-code-point';
33

4-
const astralRegex = /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/;
4+
// \x1b and \x9b
5+
const ESCAPES = new Set([27, 155]);
56

6-
const ESCAPES = [
7-
'\u001B',
8-
'\u009B'
9-
];
7+
const CHAR_CODE_0 = '0'.charCodeAt(0);
8+
const CHAR_CODE_9 = '9'.charCodeAt(0);
109

11-
const wrapAnsi = code => `${ESCAPES[0]}[${code}m`;
10+
const endCodesSet = new Set();
11+
const endCodesMap = new Map();
12+
for (const [start, end] of ansiStyles.codes) {
13+
endCodesSet.add(ansiStyles.color.ansi(end));
14+
endCodesMap.set(ansiStyles.color.ansi(start), ansiStyles.color.ansi(end));
15+
}
1216

13-
const checkAnsi = (ansiCodes, isEscapes, endAnsiCode) => {
14-
let output = [];
15-
ansiCodes = [...ansiCodes];
17+
function getEndCode(code) {
18+
if (endCodesSet.has(code)) {
19+
return code;
20+
}
1621

17-
for (let ansiCode of ansiCodes) {
18-
const ansiCodeOrigin = ansiCode;
19-
if (ansiCode.includes(';')) {
20-
ansiCode = ansiCode.split(';')[0][0] + '0';
21-
}
22+
if (endCodesMap.has(code)) {
23+
return endCodesMap.get(code);
24+
}
2225

23-
const item = ansiStyles.codes.get(Number.parseInt(ansiCode, 10));
24-
if (item) {
25-
const indexEscape = ansiCodes.indexOf(item.toString());
26-
if (indexEscape === -1) {
27-
output.push(wrapAnsi(isEscapes ? item : ansiCodeOrigin));
28-
} else {
29-
ansiCodes.splice(indexEscape, 1);
30-
}
31-
} else if (isEscapes) {
32-
output.push(wrapAnsi(0));
33-
break;
34-
} else {
35-
output.push(wrapAnsi(ansiCodeOrigin));
36-
}
26+
code = code.slice(2);
27+
if (code.includes(';')) {
28+
code = code[0] + '0';
3729
}
3830

39-
if (isEscapes) {
40-
output = output.filter((element, index) => output.indexOf(element) === index);
31+
const returnValue = ansiStyles.codes.get(Number.parseInt(code, 10));
32+
if (returnValue) {
33+
return ansiStyles.color.ansi(returnValue);
34+
}
35+
36+
return ansiStyles.reset.open;
37+
}
4138

42-
if (endAnsiCode !== undefined) {
43-
const fistEscapeCode = wrapAnsi(ansiStyles.codes.get(Number.parseInt(endAnsiCode, 10)));
44-
// TODO: Remove the use of `.reduce` here.
45-
// eslint-disable-next-line unicorn/no-array-reduce
46-
output = output.reduce((current, next) => next === fistEscapeCode ? [next, ...current] : [...current, next], []);
39+
function findNumberIndex(string) {
40+
for (let index = 0; index < string.length; index++) {
41+
const charCode = string.charCodeAt(index);
42+
if (charCode >= CHAR_CODE_0 && charCode <= CHAR_CODE_9) {
43+
return index;
4744
}
4845
}
4946

50-
return output.join('');
51-
};
47+
return -1;
48+
}
5249

53-
export default function sliceAnsi(string, begin, end) {
54-
const characters = [...string];
55-
const ansiCodes = [];
50+
function parseAnsiCode(string, offset) {
51+
string = string.slice(offset, offset + 19);
52+
const startIndex = findNumberIndex(string);
53+
if (startIndex !== -1) {
54+
let endIndex = string.indexOf('m', startIndex);
55+
if (endIndex === -1) {
56+
endIndex = string.length;
57+
}
5658

57-
let stringEnd = typeof end === 'number' ? end : characters.length;
58-
let isInsideEscape = false;
59-
let ansiCode;
60-
let visible = 0;
61-
let output = '';
59+
return string.slice(0, endIndex + 1);
60+
}
61+
}
6262

63-
for (const [index, character] of characters.entries()) {
64-
let leftEscape = false;
63+
function tokenize(string, endChar = Number.POSITIVE_INFINITY) {
64+
const returnValue = [];
65+
66+
let index = 0;
67+
let visibleCount = 0;
68+
while (index < string.length) {
69+
const codePoint = string.codePointAt(index);
70+
71+
if (ESCAPES.has(codePoint)) {
72+
const code = parseAnsiCode(string, index);
73+
if (code) {
74+
returnValue.push({
75+
type: 'ansi',
76+
code,
77+
endCode: getEndCode(code)
78+
});
79+
index += code.length;
80+
continue;
81+
}
82+
}
6583

66-
if (ESCAPES.includes(character)) {
67-
const code = /\d[^m]*/.exec(string.slice(index, index + 18));
68-
ansiCode = code && code.length > 0 ? code[0] : undefined;
84+
const isFullWidth = isFullwidthCodePoint(codePoint);
85+
const character = String.fromCodePoint(codePoint);
86+
87+
returnValue.push({
88+
type: 'character',
89+
value: character,
90+
isFullWidth
91+
});
92+
index += character.length;
93+
visibleCount += isFullWidth ? 2 : character.length;
94+
if (visibleCount >= endChar) {
95+
break;
96+
}
97+
}
6998

70-
if (visible < stringEnd) {
71-
isInsideEscape = true;
99+
return returnValue;
100+
}
72101

73-
if (ansiCode !== undefined) {
74-
ansiCodes.push(ansiCode);
75-
}
76-
}
77-
} else if (isInsideEscape && character === 'm') {
78-
isInsideEscape = false;
79-
leftEscape = true;
102+
function reduceAnsiCodes(codes) {
103+
let returnValue = [];
104+
for (const code of codes) {
105+
if (code.code === ansiStyles.reset.open) {
106+
// Reset code, disable all codes
107+
returnValue = [];
108+
} else if (endCodesSet.has(code.code)) {
109+
// This is an end code, disable all matching start codes
110+
returnValue = returnValue.filter(returnValueCode => returnValueCode.endCode !== code.code);
111+
} else {
112+
// This is a start code. Disable all styles this "overrides", then enable it
113+
returnValue = returnValue.filter(returnValueCode => returnValueCode.endCode !== code.endCode);
114+
returnValue.push(code);
80115
}
116+
}
117+
118+
return returnValue;
119+
}
81120

82-
if (!isInsideEscape && !leftEscape) {
83-
visible++;
121+
function undoAnsiCodes(codes) {
122+
const reduced = reduceAnsiCodes(codes);
123+
const endCodes = reduced.map(({endCode}) => endCode);
124+
return endCodes.reverse().join('');
125+
}
126+
127+
export default function sliceAnsi(string, begin, end) {
128+
const tokens = tokenize(string, end);
129+
let activeCodes = [];
130+
let position = 0;
131+
let returnValue = '';
132+
let include = false;
133+
134+
for (const token of tokens) {
135+
if (end !== undefined && position >= end) {
136+
break;
84137
}
85138

86-
if (!astralRegex.test(character) && isFullwidthCodePoint(character.codePointAt())) {
87-
visible++;
139+
if (token.type === 'ansi') {
140+
activeCodes.push(token);
141+
if (include) {
142+
returnValue += token.code;
143+
}
144+
} else {
145+
// Char
146+
if (!include && position >= begin) {
147+
include = true;
148+
// Simplify active codes
149+
activeCodes = reduceAnsiCodes(activeCodes);
150+
returnValue = activeCodes.map(({code}) => code).join('');
151+
}
88152

89-
if (typeof end !== 'number') {
90-
stringEnd++;
153+
if (include) {
154+
returnValue += token.value;
91155
}
92-
}
93156

94-
if (visible > begin && visible <= stringEnd) {
95-
output += character;
96-
} else if (visible === begin && !isInsideEscape && ansiCode !== undefined) {
97-
output = checkAnsi(ansiCodes);
98-
} else if (visible >= stringEnd) {
99-
output += checkAnsi(ansiCodes, true, ansiCode);
100-
break;
157+
position += token.isFullWidth ? 2 : token.value.length;
101158
}
102159
}
103160

104-
return output;
161+
// Disable active codes at the end
162+
returnValue += undoAnsiCodes(activeCodes);
163+
return returnValue;
105164
}

test.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,26 @@ test('weird null issue', t => {
8383
});
8484

8585
test('support true color escape sequences', t => {
86-
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');
86+
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');
8787
});
8888

8989
// See https://github.com/chalk/slice-ansi/issues/24
9090
test('doesn\'t add extra escapes', t => {
9191
const output = `${chalk.black.bgYellow(' RUNS ')} ${chalk.green('test')}`;
9292
t.is(sliceAnsi(output, 0, 7), `${chalk.black.bgYellow(' RUNS ')} `);
9393
t.is(sliceAnsi(output, 0, 8), `${chalk.black.bgYellow(' RUNS ')} `);
94-
t.is(JSON.stringify(sliceAnsi('\u001B[31m' + output, 0, 4)), JSON.stringify(`\u001B[31m${chalk.black.bgYellow(' RUN')}`));
94+
t.is(JSON.stringify(sliceAnsi('\u001B[31m' + output, 0, 4)), JSON.stringify(chalk.black.bgYellow(' RUN')));
9595
});
9696

9797
// See https://github.com/chalk/slice-ansi/issues/26
9898
test('does not lose fullwidth characters', t => {
9999
t.is(sliceAnsi('古古test', 0), '古古test');
100100
});
101101

102+
test('can create empty slices', t => {
103+
t.is(sliceAnsi('test', 0, 0), '');
104+
});
105+
102106
test.failing('slice links', t => {
103107
const link = '\u001B]8;;https://google.com\u0007Google\u001B]8;;\u0007';
104108
t.is(sliceAnsi(link, 0, 6), link);

0 commit comments

Comments
 (0)