Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance greatly #337

Merged
merged 12 commits into from Jul 12, 2019
42 changes: 34 additions & 8 deletions benchmark.js
Expand Up @@ -3,22 +3,48 @@
const chalk = require('.');

suite('chalk', () => {
set('iterations', 100000);
set('iterations', 1000000);

bench('single style', () => {
const chalkRed = chalk.red;
const chalkBgRed = chalk.bgRed;
const chalkBlueBgRed = chalk.blue.bgRed;
const chalkBlueBgRedBold = chalk.blue.bgRed.bold;

const blueStyledString = 'the fox jumps' + chalk.blue('over the lazy dog') + '!';

bench('1 style', () => {
chalk.red('the fox jumps over the lazy dog');
});

bench('several styles', () => {
bench('2 styles', () => {
chalk.blue.bgRed('the fox jumps over the lazy dog');
});

bench('3 styles', () => {
chalk.blue.bgRed.bold('the fox jumps over the lazy dog');
});

const cached = chalk.blue.bgRed.bold;
bench('cached styles', () => {
cached('the fox jumps over the lazy dog');
bench('cached: 1 style', () => {
chalkRed('the fox jumps over the lazy dog');
});

bench('cached: 2 styles', () => {
chalkBlueBgRed('the fox jumps over the lazy dog');
});

bench('cached: 3 styles', () => {
chalkBlueBgRedBold('the fox jumps over the lazy dog');
});

bench('cached: 1 style with newline', () => {
chalkRed('the fox jumps\nover the lazy dog');
});

bench('cached: 1 style nested intersecting', () => {
chalkRed(blueStyledString);
});

bench('nested styles', () => {
chalk.red('the fox jumps', chalk.underline.bgBlue('over the lazy dog') + '!');
bench('cached: 1 style nested non-intersecting', () => {
chalkBgRed(blueStyledString);
});
});
2 changes: 1 addition & 1 deletion examples/screenshot.js
@@ -1,6 +1,6 @@
'use strict';
const chalk = require('..');
const styles = require('ansi-styles');
const chalk = require('..');

// Generates screenshot
for (const key of Object.keys(styles)) {
Expand Down
133 changes: 83 additions & 50 deletions index.js
@@ -1,9 +1,13 @@
'use strict';
const escapeStringRegexp = require('escape-string-regexp');
const ansiStyles = require('ansi-styles');
const {stdout: stdoutColor} = require('supports-color');
const template = require('./templates.js');

const {
stringReplaceAll,
stringEncaseCRLFWithFirstIndex
} = require('./lib/util');

// `supportsColor.level` → `ansiStyles.color[name]` mapping
const levelMapping = [
'ansi',
Expand Down Expand Up @@ -57,22 +61,23 @@ function Chalk(options) {
}

for (const [styleName, style] of Object.entries(ansiStyles)) {
style.closeRe = new RegExp(escapeStringRegexp(style.close), 'g');

styles[styleName] = {
get() {
return createBuilder(this, [...(this._styles || []), style], this._isEmpty);
const builder = createBuilder(this, createStyler(style.open, style.close, this._styler), this._isEmpty);
Object.defineProperty(this, styleName, {value: builder});
return builder;
}
};
}

styles.visible = {
get() {
return createBuilder(this, this._styles || [], true);
const builder = createBuilder(this, this._styler, true);
Object.defineProperty(this, 'visible', {value: builder});
return builder;
}
};

ansiStyles.color.closeRe = new RegExp(escapeStringRegexp(ansiStyles.color.close), 'g');
for (const model of Object.keys(ansiStyles.color.ansi)) {
if (skipModels.has(model)) {
continue;
Expand All @@ -82,19 +87,13 @@ for (const model of Object.keys(ansiStyles.color.ansi)) {
get() {
const {level} = this;
return function (...arguments_) {
const open = ansiStyles.color[levelMapping[level]][model](...arguments_);
const codes = {
open,
close: ansiStyles.color.close,
closeRe: ansiStyles.color.closeRe
};
return createBuilder(this, [...(this._styles || []), codes], this._isEmpty);
const styler = createStyler(ansiStyles.color[levelMapping[level]][model](...arguments_), ansiStyles.color.close, this._styler);
return createBuilder(this, styler, this._isEmpty);
};
}
};
}

ansiStyles.bgColor.closeRe = new RegExp(escapeStringRegexp(ansiStyles.bgColor.close), 'g');
for (const model of Object.keys(ansiStyles.bgColor.ansi)) {
if (skipModels.has(model)) {
continue;
Expand All @@ -105,72 +104,106 @@ for (const model of Object.keys(ansiStyles.bgColor.ansi)) {
get() {
const {level} = this;
return function (...arguments_) {
const open = ansiStyles.bgColor[levelMapping[level]][model](...arguments_);
const codes = {
open,
close: ansiStyles.bgColor.close,
closeRe: ansiStyles.bgColor.closeRe
};
return createBuilder(this, [...(this._styles || []), codes], this._isEmpty);
const styler = createStyler(ansiStyles.bgColor[levelMapping[level]][model](...arguments_), ansiStyles.bgColor.close, this._styler);
return createBuilder(this, styler, this._isEmpty);
};
}
};
}

const proto = Object.defineProperties(() => {}, styles);

const createBuilder = (self, _styles, _isEmpty) => {
const builder = (...arguments_) => applyStyle(builder, ...arguments_);
builder._styles = _styles;
builder._isEmpty = _isEmpty;

Object.defineProperty(builder, 'level', {
const proto = Object.defineProperties(() => {}, {
...styles,
level: {
enumerable: true,
get() {
return self.level;
return this._generator.level;
},
set(level) {
self.level = level;
this._generator.level = level;
}
});

Object.defineProperty(builder, 'enabled', {
},
enabled: {
enumerable: true,
get() {
return self.enabled;
return this._generator.enabled;
},
set(enabled) {
self.enabled = enabled;
this._generator.enabled = enabled;
}
});
}
});

const createStyler = (open, close, parent) => {
let openAll;
let closeAll;
if (parent === undefined) {
openAll = open;
closeAll = close;
} else {
openAll = parent.openAll + open;
closeAll = close + parent.closeAll;
}

return {
open,
close,
openAll,
closeAll,
parent
};
};

const createBuilder = (self, _styler, _isEmpty) => {
const builder = (...arguments_) => {
// Single argument is hot path, implicit coercion is faster than anything
// eslint-disable-next-line no-implicit-coercion
return applyStyle(builder, (arguments_.length === 1) ? ('' + arguments_[0]) : arguments_.join(' '));
};

// `__proto__` is used because we must return a function, but there is
// no way to create a function with a different prototype
builder.__proto__ = proto; // eslint-disable-line no-proto

builder._generator = self;
builder._styler = _styler;
builder._isEmpty = _isEmpty;

return builder;
};

const applyStyle = (self, ...arguments_) => {
let string = arguments_.join(' ');

const applyStyle = (self, string) => {
if (!self.enabled || self.level <= 0 || !string) {
return self._isEmpty ? '' : string;
}

for (const code of self._styles.slice().reverse()) {
// Replace any instances already present with a re-opening code
// otherwise only the part of the string until said closing code
// will be colored, and the rest will simply be 'plain'.
string = code.open + string.replace(code.closeRe, code.open) + code.close;
let styler = self._styler;

if (styler === undefined) {
return string;
}

const {openAll, closeAll} = styler;
if (string.indexOf('\u001B') !== -1) {
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved
while (styler !== undefined) {
// Replace any instances already present with a re-opening code
// otherwise only the part of the string until said closing code
// will be colored, and the rest will simply be 'plain'.
string = stringReplaceAll(string, styler.close, styler.open);

styler = styler.parent;
}
}

// Close the styling before a linebreak and reopen
// after next line to fix a bleed issue on macOS
// https://github.com/chalk/chalk/pull/92
string = string.replace(/\r?\n/g, `${code.close}$&${code.open}`);
// We can move both next actions out of loop, because remaining actions in loop won't have any/visible effect on parts we add here
// Close the styling before a linebreak and reopen
// after next line to fix a bleed issue on macOS
// https://github.com/chalk/chalk/pull/92
const lfIndex = string.indexOf('\n');
if (lfIndex !== -1) {
string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
}

return string;
return openAll + string + closeAll;
};

const chalkTag = (chalk, ...strings) => {
Expand Down
37 changes: 37 additions & 0 deletions lib/util.js
@@ -0,0 +1,37 @@
const stringReplaceAll = (string, substring, replacer) => {
let index = string.indexOf(substring);
if (index === -1) {
return string;
}

const subLen = substring.length;
let end = 0;
let res = '';
do {
res += string.substr(end, index - end) + replacer;
end = index + subLen;
index = string.indexOf(substring, end);
} while (index !== -1);

res += string.substr(end);
return res;
};

const stringEncaseCRLFWithFirstIndex = (string, prefix, postfix, index) => {
let end = 0;
let res = '';
do {
const gotCR = string[index - 1] === '\r';
res += string.substr(end, (gotCR ? index - 1 : index) - end) + prefix + (gotCR ? '\r\n' : '\n') + postfix;
end = index + 1;
index = string.indexOf('\n', end);
} while (index !== -1);

res += string.substr(end);
return res;
};

module.exports = {
stringReplaceAll,
stringEncaseCRLFWithFirstIndex
};
1 change: 0 additions & 1 deletion package.json
Expand Up @@ -41,7 +41,6 @@
],
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^6.1.0"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions test/chalk.js
Expand Up @@ -82,6 +82,10 @@ test('line breaks should open and close colors', t => {
t.is(chalk.grey('hello\nworld'), '\u001B[90mhello\u001B[39m\n\u001B[90mworld\u001B[39m');
});

test('line breaks should open and close colors with CRLF', t => {
t.is(chalk.grey('hello\r\nworld'), '\u001B[90mhello\u001B[39m\r\n\u001B[90mworld\u001B[39m');
});

test('properly convert RGB to 16 colors on basic color terminals', t => {
t.is(new chalk.Instance({level: 1}).hex('#FF0000')('hello'), '\u001B[91mhello\u001B[39m');
t.is(new chalk.Instance({level: 1}).bgHex('#FF0000')('hello'), '\u001B[101mhello\u001B[49m');
Expand Down