/* 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; };