From 106f086aaf00467b7161f8cec4b6cd255c30169f Mon Sep 17 00:00:00 2001 From: Josh Junon Date: Sun, 30 Jul 2017 22:36:59 -0700 Subject: [PATCH] Rewrite templating system (closes #186, fixes #184) --- templates.js | 222 +++++++++++++++++---------------------- test/template-literal.js | 79 +++++++++++--- 2 files changed, 156 insertions(+), 145 deletions(-) diff --git a/templates.js b/templates.js index 6c0a912..1015515 100644 --- a/templates.js +++ b/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; -function data(parent) { - return { - styles: [], - parent, - contents: [] - }; +const ESCAPES = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + 0: '\0', + '\\': '\\', + e: '\u001b', + a: '\u0007' +}; + +function unescape(c) { + if ((c[0] === 'u' && c.length === 5) || (c[0] === 'x' && c.length === 3)) { + return String.fromCharCode(parseInt(c.slice(1), 16)); + } + + return ESCAPES[c] || c; } -const zeroBound = n => n < 0 ? 0 : n; -const lastIndex = a => zeroBound(a.length - 1); +function parseArguments(name, args) { + const results = []; + const chunks = args.trim().split(/\s*,\s*/g); + let matches; -const last = a => a[lastIndex(a)]; - -const takeWhileReverse = (array, predicate, start) => { - const out = []; - - 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; -}; + return results; +} -// 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 = '\\'; +function parseStyle(style) { + STYLE_REGEX.lastIndex = 0; - if (!(char === backslash || char === '{' || char === '}')) { - return true; - } + const results = []; + let matches; - const n = i === 0 ? 0 : takeWhileReverse(string, x => x === '\\', zeroBound(i - 1)).length; + while ((matches = STYLE_REGEX.exec(style)) !== null) { + const name = matches[1]; - return n % 2 === 1; -}; - -const collectStyles = data => data ? collectStyles(data.parent).concat(data.styles) : ['reset']; - -// 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 = []; - - 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; -}; + return results; +} -// 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; +function buildStyle(chalk, styles) { + const enabled = {}; - for (let i = 0; i < string.length; i++) { - const char = string[i]; + for (const layer of styles) { + for (const style of layer.styles) { + enabled[style[0]] = layer.inverse ? null : style.slice(1); + } + } - const addNormalCharacter = () => { - const lastChunk = last(current.contents); - - 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 = []; +module.exports = (chalk, tmp) => { + const styles = []; + const chunks = []; + let chunk = []; - for (const content of data.contents) { - if (typeof content === 'string') { - flat.push({ - styles: sumStyles(data), - content - }); + // 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); } + }); + + chunks.push(chunk.join('')); + + if (styles.length > 0) { + const errMsg = `Chalk template literal is missing ${styles.length} closing bracket${styles.length === 1 ? '' : 's'} (\`}\`)`; + throw new Error(errMsg); } - return flat; -} - -function assertStyle(chalk, style) { - if (!chalk[style]) { - throw new Error(`Invalid Chalk style: ${style}`); - } -} - -// 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(''); +}; diff --git a/test/template-literal.js b/test/template-literal.js index bd36109..6ab17a6 100644 --- a/test/template-literal.js +++ b/test/template-literal.js @@ -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 => { @@ -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 (`}`)'); } }); @@ -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'); } }); @@ -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' ); }); @@ -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 => { @@ -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'); +});