From 9bd39f6d80da25bc56febd8677bbbf7810a3f16d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 26 Aug 2016 21:27:32 -0600 Subject: [PATCH] format() that works with RA pipe codes & ANSI ESC seqs --- core/string_format.js | 327 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 core/string_format.js diff --git a/core/string_format.js b/core/string_format.js new file mode 100644 index 00000000..70b5b813 --- /dev/null +++ b/core/string_format.js @@ -0,0 +1,327 @@ +/* jslint node: true */ +'use strict'; + +const EnigError = require('./enig_error.js').EnigError; +const pad = require('./string_util.js').pad; +const stylizeString = require('./string_util.js').stylizeString; + +// deps +const _ = require('lodash'); + +/* + String formatting HEAVILY inspired by David Chambers string-format library + and the mini-language branch specifically which was gratiously released + under the DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE. + + We need some extra functionality. Namely, support for RA style pipe codes + and ANSI escape sequences. +*/ + +class ValueError extends EnigError { } +class KeyError extends EnigError { } + +const SpecRegExp = { + FillAlign : /^(.)?([<>=^])/, + Sign : /^[ +-]/, + Width : /^\d*/, + Precision : /^\d+/, +}; + +function tokenizeFormatSpec(spec) { + const tokens = { + fill : '', + align : '', + sign : '', + '#' : false, + '0' : false, + width : '', + ',' : false, + precision : '', + type : '', + }; + + let index = 0; + let match; + + function incIndexByMatch() { + index += match[0].length; + } + + match = SpecRegExp.FillAlign.exec(spec); + if(match) { + if(match[1]) { + tokens.fill = match[1]; + } + tokens.align = match[2]; + incIndexByMatch(); + } + + match = SpecRegExp.Sign.exec(spec.slice(index)); + if(match) { + tokens.sign = match[0]; + incIndexByMatch(); + } + + if('#' === spec.charAt(index)) { + tokens['#'] = true; + ++index; + } + + if('0' === spec.charAt(index)) { + tokens['0'] = true; + ++index; + } + + match = SpecRegExp.Width.exec(spec.slice(index)); + tokens.width = match[0]; + incIndexByMatch(); + + if(',' === spec.charAt(index)) { + tokens[','] = true; + ++index; + } + + if('.' === spec.charAt(index)) { + ++index; + + match = SpecRegExp.Precision.exec(spec.slice(index)); + if(!match) { + throw new ValueError('Format specifier missing precision'); + } + + tokens.precision = match[0]; + incIndexByMatch(); + } + + if(index < spec.length) { + tokens.type = spec.charAt(index); + ++index; + } + + if(index < spec.length) { + throw new ValueError('Invalid conversion specification'); + } + + if(tokens[','] && 's' === tokens.type) { + throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes + } + + return tokens; +} + +function quote(s) { + return `"${s.replace(/"/g, '\\"')}"`; +} + +function getPadAlign(align) { + return { + '<' : 'right', + '>' : 'left', + '^' : 'center', + }[align] || '<'; +} + +function formatString(value, tokens) { + const fill = tokens.fill || (tokens['0'] ? '0' : ' '); + const align = tokens.align || (tokens['0'] ? '=' : '<'); + const precision = Number(tokens.precision || value.length); // :TODO: consider pipe/ANSI length + + if('' !== tokens.type && 's' !== tokens.type) { + throw new ValueError(`Unknown format code "${tokens.type}" for String object`); + } + + if(tokens[',']) { + throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes + } + + if(tokens.sign) { + throw new ValueError('Sign not allowed in string format specifier'); + } + + if(tokens['#']) { + throw new ValueError('Alternate form (#) not allowed in string format specifier'); + } + + if('=' === align) { + throw new ValueError('"=" alignment not allowed in string format specifier'); + } + + return pad(value.slice(0, precision), parseInt(tokens.width), fill, getPadAlign(align)); +} + +const FormatNumRegExp = { + UpperType : /[A-Z]/, + ExponentRep : /e[+-](?=\d$)/, +}; + +function formatNumberHelper(n, precision, type) { + if(FormatNumRegExp.UpperType.test(type)) { + return formatNumberHelper(n, precision, type.toLowerCase()).toUpperCase(); + } + + switch(type) { + case 'c' : return String.fromCharCode(n); + case 'd' : return n.toString(10); + case 'b' : return n.toString(2); + case 'o' : return n.toString(8); + case 'x' : return n.toString(16); + case 'e' : return n.toExponential(precision).replace(FormatNumRegExp.ExponentRep, '$&0'); + case 'f' : return n.toFixed(precision); + case 'g' : return n.toPrecision(precision || 1); + case '%' : return formatNumberHelper(n * 100, precision, 'f') + '%'; + case '' : return formatNumberHelper(n, precision, 'd'); + + default : + throw new ValueError(`Unknown format code "${type}" for object of type 'float'`); + } +} + +function formatNumber(value, tokens) { + const fill = tokens.fill || (tokens['0'] ? '0' : ' '); + const align = tokens.align || (tokens['0'] ? '=' : '>'); + const width = Number(tokens.width); + const type = tokens.type || (tokens.precision ? 'g' : ''); + + if( [ 'c', 'd', 'b', 'o', 'x', 'X' ].indexOf(type) > -1) { + if(0 !== value % 1) { + throw new ValueError(`Cannot format non-integer with format specifier "${type}"`); + } + + if('' !== tokens.sign && 'c' !== type) { + throw new ValueError(`Sign not allowed with integer format specifier 'c'`); // eslint-disable-line quotes + } + + if(tokens[','] && 'd' !== type) { + throw new ValueError(`Cannot specify ',' with '${type}'`); + } + + if('' !== tokens.precision) { + throw new ValueError('Precision not allowed in integer format specifier'); + } + } else if( [ 'e', 'E', 'f', 'F', 'g', 'G', '%' ].indexOf(type) > - 1) { + if(tokens['#']) { + throw new ValueError('Alternate form (#) not allowed in float format specifier'); + } + } + + const s = formatNumberHelper(Math.abs(value), Number(tokens.precision || 6), type); + const sign = value < 0 || 1 / value < 0 ? + '-' : + '-' === tokens.sign ? '' : tokens.sign; + + const prefix = tokens['#'] && ( [ 'b', 'o', 'x', 'X' ].indexOf(type) > -1 ) ? '0' + type : ''; + + if(tokens[',']) { + const match = /^(\d*)(.*)$/.exec(s); + const separated = match[1].replace(/.(?=(...)+$)/g, '$&,') + match[2]; + + if('=' !== align) { + return pad(sign + separated, width, fill, getPadAlign(align)); + } + + if('0' === fill) { + const shortfall = Math.max(0, width - sign.length - separated.length); + const digits = /^\d*/.exec(separated)[0].length; + let padding = ''; + // :TODO: do this differntly... + for(let n = 0; n < shortfall; n++) { + padding = ((digits + n) % 4 === 3 ? ',' : '0') + padding; + } + + return sign + (/^,/.test(padding) ? '0' : '') + padding + separated; + } + + return sign + pad(separated, width - sign.length, fill, getPadAlign('>')); + } + + if(0 === width) { + return sign + prefix + s; + } + + if('=' === align) { + return sign + prefix + pad(s, width - sign.length - prefix.length, fill, getPadAlign('>')); + } + + return pad(sign + prefix + s, width, fill, getPadAlign(align)); +} + +const transformers = { + // String standard + toUpperCase : String.prototype.toUpperCase, + toLowerCase : String.prototype.toLowerCase, + + // some super l33b BBS styles!! + styleUpper : (s) => stylizeString(s, 'upper'), + styleLower : (s) => stylizeString(s, 'lower'), + styleTitle : (s) => stylizeString(s, 'title'), + styleFirstLower : (s) => stylizeString(s, 'first lower'), + styleSmallVowels : (s) => stylizeString(s, 'small vowels'), + styleBigVowels : (s) => stylizeString(s, 'big vowels'), + styleSmallI : (s) => stylizeString(s, 'small i'), + styleMixed : (s) => stylizeString(s, 'mixed'), + styleL33t : (s) => stylizeString(s, 'l33t'), +}; + +function transformValue(transformerName, value) { + if(transformerName in transformers) { + const transformer = transformers[transformerName]; + value = transformer.apply(value, [ value ] ); + } + + return value; +} + +// :TODO: Use explicit set of chars for paths & function/transforms such that } is allowed as fill/etc. +const REGEXP_BASIC_FORMAT = /{([^.!:}]+(?:\.[^.!:}]+)*)(?:\!([^:}]+))?(?:\:([^}]+))?}/g; + +function getValue(obj, path) { + const value = _.get(obj, path); + if(value) { + return _.isFunction(value) ? value() : value; + } + + throw new KeyError(quote(path)); +} + +module.exports = function format(fmt, obj) { + + const re = REGEXP_BASIC_FORMAT; + let match; + let pos; + let out = ''; + do { + pos = re.lastIndex; + match = re.exec(fmt); + + if(match) { + if(match.index > pos) { + out += fmt.slice(pos, match.index); + } + + const objPath = match[1]; + const transformer = match[2]; + const formatSpec = match[3]; + + let value = getValue(obj, objPath); + if(transformer) { + value = transformValue(transformer, value); + } + + const tokens = tokenizeFormatSpec(formatSpec || ''); + + if(!isNaN(parseInt(value))) { + out += formatNumber(value, tokens); + } else { + out += formatString(value, tokens); + } + } + + } while(0 !== re.lastIndex); + + // remainder + if(pos < fmt.length) { + out += fmt.slice(pos); + } + + return out; +};