Skip to content

Commit

Permalink
Rewrite templating system (closes #186, fixes #184)
Browse files Browse the repository at this point in the history
  • Loading branch information
Qix- committed Aug 7, 2017
1 parent f0c0178 commit 106f086
Show file tree
Hide file tree
Showing 2 changed files with 156 additions and 145 deletions.
222 changes: 94 additions & 128 deletions templates.js
@@ -1,162 +1,128 @@
'use strict';
const TEMPLATE_REGEX = /(?:\\(u[a-f0-9]{4}|x[a-f0-9]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi;
const STYLE_REGEX = /(?:^|\.)(\w+)(?:\(([^)]*)\))?/g;
const STRING_REGEX = /^(['"])((?:\\.|(?!\1)[^\\])*)\1$/;
const ESCAPE_REGEX = /\\(u[0-9a-f]{4}|x[0-9a-f]{2}|.)|([^\\])/gi;

const ESCAPES = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
v: '\v',
0: '\0',
'\\': '\\',
e: '\u001b',
a: '\u0007'
};

function data(parent) {
return {
styles: [],
parent,
contents: []
};
}

const zeroBound = n => n < 0 ? 0 : n;
const lastIndex = a => zeroBound(a.length - 1);
function unescape(c) {
if ((c[0] === 'u' && c.length === 5) || (c[0] === 'x' && c.length === 3)) {
return String.fromCharCode(parseInt(c.slice(1), 16));
}

const last = a => a[lastIndex(a)];
return ESCAPES[c] || c;
}

const takeWhileReverse = (array, predicate, start) => {
const out = [];
function parseArguments(name, args) {
const results = [];
const chunks = args.trim().split(/\s*,\s*/g);
let matches;

for (let i = start; i >= 0 && i <= start; i--) {
if (predicate(array[i])) {
out.unshift(array[i]);
for (const chunk of chunks) {
if (!isNaN(chunk)) {
results.push(Number(chunk));
} else if ((matches = chunk.match(STRING_REGEX))) {
results.push(matches[2].replace(ESCAPE_REGEX, (m, escape, chr) => escape ? unescape(escape) : chr));
} else {
break;
throw new Error(`Invalid Chalk template style argument: ${chunk} (in style '${name}')`);
}
}

return out;
};

// Check if the character at position `i` in string is a normal character (non-control character)
const isNormalCharacter = (string, i) => {
const char = string[i];
const backslash = '\\';

if (!(char === backslash || char === '{' || char === '}')) {
return true;
}

const n = i === 0 ? 0 : takeWhileReverse(string, x => x === '\\', zeroBound(i - 1)).length;
return results;
}

return n % 2 === 1;
};
function parseStyle(style) {
STYLE_REGEX.lastIndex = 0;

const collectStyles = data => data ? collectStyles(data.parent).concat(data.styles) : ['reset'];
const results = [];
let matches;

// Compute the style for a given data based on its style and the style of its parent.
// Also accounts for `!style` styles which remove a style from the list if present.
const sumStyles = data => {
const negateRegex = /^~.+/;
let out = [];
while ((matches = STYLE_REGEX.exec(style)) !== null) {
const name = matches[1];

for (const style of collectStyles(data)) {
if (negateRegex.test(style)) {
const exclude = style.slice(1);
out = out.filter(x => x !== exclude);
if (matches[2]) {
const args = parseArguments(name, matches[2]);
results.push([name].concat(args));
} else {
out.push(style);
results.push([name]);
}
}

return out;
};

// Take a string and parse it into a tree of data objects which inherit styles from their parent
function parse(string) {
const root = data(null);
let pushingStyle = false;
let current = root;
return results;
}

for (let i = 0; i < string.length; i++) {
const char = string[i];
function buildStyle(chalk, styles) {
const enabled = {};

const addNormalCharacter = () => {
const lastChunk = last(current.contents);
for (const layer of styles) {
for (const style of layer.styles) {
enabled[style[0]] = layer.inverse ? null : style.slice(1);
}
}

if (typeof lastChunk === 'string') {
current.contents[lastIndex(current.contents)] = lastChunk + char;
} else {
current.contents.push(char);
let current = chalk;
for (const styleName of Object.keys(enabled)) {
if (Array.isArray(enabled[styleName])) {
if (!(styleName in current)) {
throw new Error(`Unknown Chalk style: ${styleName}`);
}
};

if (pushingStyle) {
if (' \t'.includes(char)) {
pushingStyle = false;
} else if (char === '\n') {
pushingStyle = false;
addNormalCharacter();
} else if (char === '.') {
current.styles.push('');

if (enabled[styleName].length > 0) {
current = current[styleName].apply(current, enabled[styleName]);
} else {
current.styles[lastIndex(current.styles)] = (last(current.styles) || '') + char;
current = current[styleName];
}
} else if (isNormalCharacter(string, i)) {
addNormalCharacter();
} else if (char === '{') {
pushingStyle = true;
const nCurrent = data(current);
current.contents.push(nCurrent);
current = nCurrent;
} else if (char === '}') {
current = current.parent;
}
}

if (current !== root) {
throw new Error('Template literal has an unclosed block');
}

return root;
return current;
}

// Take a tree of data objects and flatten it to a list of data
// objects with the inherited and negations styles accounted for
function flatten(data) {
let flat = [];

for (const content of data.contents) {
if (typeof content === 'string') {
flat.push({
styles: sumStyles(data),
content
});
module.exports = (chalk, tmp) => {
const styles = [];
const chunks = [];
let chunk = [];

// eslint-disable-next-line max-params
tmp.replace(TEMPLATE_REGEX, (m, escapeChar, inverse, style, close, chr) => {
if (escapeChar) {
chunk.push(unescape(escapeChar));
} else if (style) {
const str = chunk.join('');
chunk = [];
chunks.push(styles.length === 0 ? str : buildStyle(chalk, styles)(str));
styles.push({inverse, styles: parseStyle(style)});
} else if (close) {
if (styles.length === 0) {
throw new Error('Found extraneous } in Chalk template literal');
}

chunks.push(buildStyle(chalk, styles)(chunk.join('')));
chunk = [];
styles.pop();
} else {
flat = flat.concat(flatten(content));
chunk.push(chr);
}
}
});

return flat;
}
chunks.push(chunk.join(''));

function assertStyle(chalk, style) {
if (!chalk[style]) {
throw new Error(`Invalid Chalk style: ${style}`);
if (styles.length > 0) {
const errMsg = `Chalk template literal is missing ${styles.length} closing bracket${styles.length === 1 ? '' : 's'} (\`}\`)`;
throw new Error(errMsg);
}
}

// Check if a given style is valid and parse style functions
function parseStyle(chalk, style) {
const fnMatch = style.match(/^\s*(\w+)\s*\(\s*([^)]*)\s*\)\s*/);
if (!fnMatch) {
assertStyle(chalk, style);
return chalk[style];
}

const name = fnMatch[1].trim();
const args = fnMatch[2].split(/,/g).map(s => s.trim());

assertStyle(chalk, name);

return chalk[name].apply(chalk, args);
}

// Perform the actual styling of the string
function style(chalk, flat) {
return flat.map(data => {
const fn = data.styles.reduce(parseStyle, chalk);
return fn(data.content.replace(/\n$/, ''));
}).join('');
}

module.exports = (chalk, string) => style(chalk, flatten(parse(string)));
return chunks.join('');
};
79 changes: 62 additions & 17 deletions test/template-literal.js
Expand Up @@ -32,20 +32,20 @@ test('correctly perform template substitutions', t => {
test('correctly parse and evaluate color-convert functions', t => {
const ctx = m.constructor({level: 3});
t.is(ctx`{bold.rgb(144,10,178).inverse Hello, {~inverse there!}}`,
'\u001B[0m\u001B[1m\u001B[38;2;144;10;178m\u001B[7mHello, ' +
'\u001B[27m\u001B[39m\u001B[22m\u001B[0m\u001B[0m\u001B[1m' +
'\u001B[38;2;144;10;178mthere!\u001B[39m\u001B[22m\u001B[0m');
'\u001B[1m\u001B[38;2;144;10;178m\u001B[7mHello, ' +
'\u001B[27m\u001B[39m\u001B[22m\u001B[1m' +
'\u001B[38;2;144;10;178mthere!\u001B[39m\u001B[22m');

t.is(ctx`{bold.bgRgb(144,10,178).inverse Hello, {~inverse there!}}`,
'\u001B[0m\u001B[1m\u001B[48;2;144;10;178m\u001B[7mHello, ' +
'\u001B[27m\u001B[49m\u001B[22m\u001B[0m\u001B[0m\u001B[1m' +
'\u001B[48;2;144;10;178mthere!\u001B[49m\u001B[22m\u001B[0m');
'\u001B[1m\u001B[48;2;144;10;178m\u001B[7mHello, ' +
'\u001B[27m\u001B[49m\u001B[22m\u001B[1m' +
'\u001B[48;2;144;10;178mthere!\u001B[49m\u001B[22m');
});

test('properly handle escapes', t => {
const ctx = m.constructor({level: 3});
t.is(ctx`{bold hello \{in brackets\}}`,
'\u001B[0m\u001B[1mhello {in brackets}\u001B[22m\u001B[0m');
'\u001B[1mhello {in brackets}\u001B[22m');
});

test('throw if there is an unclosed block', t => {
Expand All @@ -54,7 +54,14 @@ test('throw if there is an unclosed block', t => {
console.log(ctx`{bold this shouldn't appear ever\}`);
t.fail();
} catch (err) {
t.is(err.message, 'Template literal has an unclosed block');
t.is(err.message, 'Chalk template literal is missing 1 closing bracket (`}`)');
}

try {
console.log(ctx`{bold this shouldn't {inverse appear {underline ever\} :) \}`);
t.fail();
} catch (err) {
t.is(err.message, 'Chalk template literal is missing 3 closing brackets (`}`)');
}
});

Expand All @@ -64,7 +71,7 @@ test('throw if there is an invalid style', t => {
console.log(ctx`{abadstylethatdoesntexist this shouldn't appear ever}`);
t.fail();
} catch (err) {
t.is(err.message, 'Invalid Chalk style: abadstylethatdoesntexist');
t.is(err.message, 'Unknown Chalk style: abadstylethatdoesntexist');
}
});

Expand All @@ -78,13 +85,13 @@ test('properly style multiline color blocks', t => {
} {underline
I hope you enjoy
}`,
'\u001B[0m\u001B[1m\u001B[22m\u001B[0m\n' +
'\u001B[0m\u001B[1m\t\t\tHello! This is a\u001B[22m\u001B[0m\n' +
'\u001B[0m\u001B[1m\t\t\tmultiline block!\u001B[22m\u001B[0m\n' +
'\u001B[0m\u001B[1m\t\t\t:)\u001B[22m\u001B[0m\n' +
'\u001B[0m\u001B[1m\t\t\u001B[22m\u001B[0m\u001B[0m \u001B[0m\u001B[0m\u001B[4m\u001B[24m\u001B[0m\n' +
'\u001B[0m\u001B[4m\t\t\tI hope you enjoy\u001B[24m\u001B[0m\n' +
'\u001B[0m\u001B[4m\t\t\u001B[24m\u001B[0m'
'\u001B[1m\u001B[22m\n' +
'\u001B[1m\t\t\tHello! This is a\u001B[22m\n' +
'\u001B[1m\t\t\tmultiline block!\u001B[22m\n' +
'\u001B[1m\t\t\t:)\u001B[22m\n' +
'\u001B[1m\t\t\u001B[22m \u001B[4m\u001B[24m\n' +
'\u001B[4m\t\t\tI hope you enjoy\u001B[24m\n' +
'\u001B[4m\t\t\u001B[24m'
);
});

Expand All @@ -97,7 +104,7 @@ test('escape interpolated values', t => {
test('allow custom colors (themes) on custom contexts', t => {
const ctx = m.constructor({level: 3});
ctx.rose = ctx.hex('#F6D9D9');
t.is(ctx`Hello, {rose Rose}.`, '\u001b[0mHello, \u001b[38;2;246;217;217mRose\u001b[38m.\u001b[0m');
t.is(ctx`Hello, {rose Rose}.`, 'Hello, \u001B[38;2;246;217;217mRose\u001B[39m.');
});

test('correctly parse newline literals (bug #184)', t => {
Expand All @@ -116,3 +123,41 @@ test('correctly parse escape in parameters (bug #177 comment 318622809)', t => {
const str = '\\';
t.is(ctx`{blue ${str}}`, '\\');
});

test('correctly parses unicode/hex escapes', t => {
const ctx = m.constructor({level: 0});
t.is(ctx`\u0078ylophones are fo\x78y! {magenta.inverse \u0078ylophones are fo\x78y!}`,
'xylophones are foxy! xylophones are foxy!');
});

test('correctly parses string arguments', t => {
const ctx = m.constructor({level: 3});
t.is(ctx`{keyword('black').bold can haz cheezburger}`, '\u001B[38;2;0;0;0m\u001B[1mcan haz cheezburger\u001B[22m\u001B[39m');
t.is(ctx`{keyword('blac\x6B').bold can haz cheezburger}`, '\u001B[38;2;0;0;0m\u001B[1mcan haz cheezburger\u001B[22m\u001B[39m');
t.is(ctx`{keyword('blac\u006B').bold can haz cheezburger}`, '\u001B[38;2;0;0;0m\u001B[1mcan haz cheezburger\u001B[22m\u001B[39m');
});

test('throws if a bad argument is encountered', t => {
const ctx = m.constructor({level: 3}); // Keep level at least 1 in case we optimize for disabled chalk instances
try {
console.log(ctx`{keyword(????) hi}`);
t.fail();
} catch (err) {
t.is(err.message, 'Invalid Chalk template style argument: ???? (in style \'keyword\')');
}
});

test('throws if an extra unescaped } is found', t => {
const ctx = m.constructor({level: 0});
try {
console.log(ctx`{red hi!}}`);
t.fail();
} catch (err) {
t.is(err.message, 'Found extraneous } in Chalk template literal');
}
});

test('should not parse upper-case escapes', t => {
const ctx = m.constructor({level: 0});
t.is(ctx`\N\n\T\t\X07\x07\U000A\u000A\U000a\u000a`, 'N\nT\tX07\x07U000A\u000AU000a\u000A');
});

0 comments on commit 106f086

Please sign in to comment.