// Adapted and simplified from https://github.com/adaltas/node-printf /** * Tokenizes a string by a regular expression with the ability to capture the delimiters */ const tokenize = function ( str: string, re: RegExp, parseDelim?: ( ...args: any[] ) => any, instance?: any, ): Array { // Split a string by a regular expression with the ability to capture the delimiters // parseDelim: Each group (excluding the 0 group) is passed as a parameter. If the function returns // a value, it's added to the list of tokens. // instance: Used as the "this' instance when calling parseDelim const tokens: Array = []; let match: RegExpExecArray | null; let content: string; let lastIndex = 0; while ((match = re.exec(str))) { content = str.slice(lastIndex, re.lastIndex - match[0].length); if (content.length) { tokens.push(content); } if (parseDelim) { const parsed = parseDelim.apply( instance, [...match.slice(1), tokens.length], ); if (typeof parsed !== "undefined") { if (parsed.specifier === "%") { tokens.push("%"); } else { tokens.push(parsed); } } } lastIndex = re.lastIndex; } content = str.slice(lastIndex); if (content.length) { tokens.push(content); } return tokens; }; interface TokenInfo { mapping?: string; intmapping?: string; flags: string; _minWidth?: string; period?: string; _precision?: string; specifier: string; arg?: string; compiled?: boolean; sign?: string; zeroPad?: boolean; rightJustify?: boolean; alternative?: boolean; minWidth?: number; maxWidth?: number; toUpper?: boolean; isUnsigned?: boolean; isInt?: boolean; isDouble?: boolean; isObject?: boolean; precision?: number; base?: number; doubleNotation?: string; setArg?: (token: TokenInfo) => void; setMaxWidth?: (token: TokenInfo) => void; extend?: string[]; [key: string]: any; // Allow dynamic property access } interface SpecifierInfo { base?: number; isInt?: boolean; extend?: string[]; toUpper?: boolean; isUnsigned?: boolean; setArg?: (token: TokenInfo) => void; setMaxWidth?: (token: TokenInfo) => void; isDouble?: boolean; doubleNotation?: string; isObject?: boolean; [key: string]: any; // Allow dynamic property access } class Formatter { private _mapped: boolean; private _format: string; private _tokens: Array; private _zeros10: string = "0000000000"; private _spaces10: string = " "; private _re: RegExp = /\%(?:\(([\w_.]+)\)|([1-9]\d*)\$)?([0 +\-\#]*)(\*|\d+)?(?:(\.)(\*|\d+)?)?[hlL]?([\%bscdeEfFgGioOuxX])/g; private _specifiers: Record = { b: { base: 2, isInt: true, }, o: { base: 8, isInt: true, }, x: { base: 16, isInt: true, }, X: { extend: ["x"], toUpper: true, }, d: { base: 10, isInt: true, }, i: { extend: ["d"], }, u: { extend: ["d"], isUnsigned: true, }, c: { setArg: function (token: TokenInfo): void { if (!isNaN(Number(token.arg))) { const num = parseInt(token.arg as string); if (num < 0 || num > 127) { throw new Error("invalid character code passed to %c in printf"); } token.arg = isNaN(num) ? "" + num : String.fromCharCode(num); } }, }, s: { setMaxWidth: function (token: TokenInfo): void { token.maxWidth = (token.period === ".") ? token.precision ?? -1 : -1; }, }, e: { isDouble: true, doubleNotation: "e", }, E: { extend: ["e"], toUpper: true, }, f: { isDouble: true, doubleNotation: "f", }, F: { extend: ["f"], }, g: { isDouble: true, doubleNotation: "g", }, G: { extend: ["g"], toUpper: true, }, O: { isObject: true, }, }; constructor(format: string) { this._mapped = false; this._format = format; this._tokens = tokenize(format, this._re, this._parseDelim, this); } // The old regexp `/\%(?:\(([\w_.]+)\)|([1-9]\d*)\$)?([0 +\-\#]*)(\*|\d+)?(\.)?(\*|\d+)?[hlL]?([\%bscdeEfFgGioOuxX])/` has a cubic worst-case time complexity behavior due to overlapping capture groups `([0 +\-\#]*)(\*|\d+)?(\.)?(\*|\d+)?`. And a pump string of 0 can be consumed by `([0 +\-\#]*), (\*|\d+)?, or (\*|\d+)?`. // The solution replace the sub-regexp (\*|\d+)?(\.)?(\*|\d+)? with the sub-regexp `(\*|\d+)?(?:(\.)(\*|\d+)?)?`, see the figure in [#32](https://github.com/adaltas/node-printf/pull/32) // There are also performance improvement, see in [#31](https://github.com/adaltas/node-printf/issues/31#issuecomment-776731490) format(...args: any[]): string { if (this._mapped && typeof args[0] !== "object") { throw new Error("format requires a mapping"); } let str = ""; let position = 0; for (let i = 0; i < this._tokens.length; i++) { const token = this._tokens[i]; if (typeof token === "string") { str += token; } else { if (this._mapped) { // Identify value of property defined in `token.mapping` const tokens = token.mapping!.split("."); let value = args[0]; for (let j = 0, c = tokens.length; j < c; j++) { value = value[tokens[j]]; if (typeof value === "undefined") { break; } } if (typeof value === "undefined") { throw new Error("missing key '" + token.mapping + "'"); } token.arg = String(value); } else { if (token.intmapping) { position = parseInt(token.intmapping) - 1; } if (position >= args.length) { throw new Error( "got " + args.length + " printf arguments, insufficient for '" + this._format + "'", ); } token.arg = String(args[position++]); } if (!token.compiled) { token.compiled = true; token.sign = ""; token.zeroPad = false; token.rightJustify = false; token.alternative = false; const flags: Record = {}; for (let fi = token.flags.length; fi--;) { const flag = token.flags.charAt(fi); flags[flag] = true; switch (flag) { case " ": token.sign = " "; break; case "+": token.sign = "+"; break; case "0": token.zeroPad = (flags["-"]) ? false : true; break; case "-": token.rightJustify = true; token.zeroPad = false; break; case "#": token.alternative = true; break; default: throw Error( "bad formatting flag '" + token.flags.charAt(fi) + "'", ); } } token.minWidth = (token._minWidth) ? parseInt(token._minWidth) : 0; token.maxWidth = -1; token.toUpper = false; token.isUnsigned = false; token.isInt = false; token.isDouble = false; token.isObject = false; token.precision = 1; if (token.period === ".") { if (token._precision) { token.precision = parseInt(token._precision); } else { token.precision = 0; } } const mixins = this._specifiers[token.specifier]; if (typeof mixins === "undefined") { throw new Error("unexpected specifier '" + token.specifier + "'"); } if (mixins.extend) { const s = this._specifiers[mixins.extend[0]]; for (const k in s) { mixins[k] = s[k]; } delete mixins.extend; } for (const l in mixins) { token[l] = mixins[l]; } } if (typeof token.setArg === "function") { token.setArg(token); } if (typeof token.setMaxWidth === "function") { token.setMaxWidth(token); } if (token._minWidth === "*") { if (this._mapped) { throw new Error("* width not supported in mapped formats"); } token.minWidth = parseInt(String(args[position++])); if (isNaN(token.minWidth)) { throw new Error( "the argument for * width at position " + position + " is not a number in " + this._format, ); } // negative width means rightJustify if (token.minWidth < 0) { token.rightJustify = true; token.minWidth = -token.minWidth; } } if (token._precision === "*" && token.period === ".") { if (this._mapped) { throw new Error("* precision not supported in mapped formats"); } token.precision = parseInt(String(args[position++])); if (isNaN(token.precision)) { throw Error( "the argument for * precision at position " + position + " is not a number in " + this._format, ); } // negative precision means unspecified if (token.precision < 0) { token.precision = 1; token.period = ""; } } if (token.isInt) { // a specified precision means no zero padding if (token.period === ".") { token.zeroPad = false; } this.formatInt(token); } else if (token.isDouble) { if (token.period !== ".") { token.precision = 6; } this.formatDouble(token); } else if (token.isObject) { this.formatObject(token); } this.fitField(token); str += "" + token.arg; } } return str; } formatInt(token: TokenInfo): void { let i = parseInt(token.arg as string); if (!isFinite(i)) { // isNaN(f) || f == Number.POSITIVE_INFINITY || f == Number.NEGATIVE_INFINITY) // allow this only if arg is number if (typeof token.arg !== "number" && typeof token.arg !== "string") { throw new Error( "format argument '" + token.arg + "' not an integer; parseInt returned " + i, ); } //return '' + i; i = 0; } // if not base 10, make negatives be positive // otherwise, (-10).toString(16) is '-a' instead of 'fffffff6' if (i < 0 && (token.isUnsigned || token.base !== 10)) { i = 0xffffffff + i + 1; } if (i < 0) { token.arg = (-i).toString(token.base); this.zeroPad(token); token.arg = "-" + token.arg; } else { token.arg = i.toString(token.base); // need to make sure that argument 0 with precision==0 is formatted as '' if (!i && !token.precision) { token.arg = ""; } else { this.zeroPad(token); } if (token.sign) { token.arg = token.sign + token.arg; } } if (token.base === 16) { if (token.alternative) { token.arg = "0x" + token.arg; } token.arg = token.toUpper ? token.arg.toUpperCase() : token.arg.toLowerCase(); } if (token.base === 8) { if (token.alternative && token.arg.charAt(0) !== "0") { token.arg = "0" + token.arg; } } } formatDouble(token: TokenInfo): void { let f = parseFloat(token.arg as string); if (!isFinite(f)) { // isNaN(f) || f == Number.POSITIVE_INFINITY || f == Number.NEGATIVE_INFINITY) // allow this only if arg is number if (typeof token.arg !== "number" && typeof token.arg !== "string") { throw new Error( "format argument '" + token.arg + "' not a float; parseFloat returned " + f, ); } // C99 says that for 'f': // infinity -> '[-]inf' or '[-]infinity' ('[-]INF' or '[-]INFINITY' for 'F') // NaN -> a string starting with 'nan' ('NAN' for 'F') // this is not commonly implemented though. //return '' + f; f = 0; } switch (token.doubleNotation) { case "e": { token.arg = f.toExponential(token.precision ?? 1); break; } case "f": { token.arg = f.toFixed(token.precision ?? 1); break; } case "g": { // C says use 'e' notation if exponent is < -4 or is >= prec // ECMAScript for toPrecision says use exponential notation if exponent is >= prec, // though step 17 of toPrecision indicates a test for < -6 to force exponential. if (Math.abs(f) < 0.0001) { //print('forcing exponential notation for f=' + f); token.arg = f.toExponential( (token.precision ?? 1) > 0 ? (token.precision ?? 1) - 1 : (token.precision ?? 1), ); } else { token.arg = f.toPrecision(token.precision ?? 1); } // In C, unlike 'f', 'gG' removes trailing 0s from fractional part, unless alternative format flag ('#'). // But ECMAScript formats toPrecision as 0.00100000. So remove trailing 0s. if (!token.alternative) { //print('replacing trailing 0 in \'' + s + '\''); token.arg = token.arg.replace(/(\..*[^0])0*e/, "$1e"); // if fractional part is entirely 0, remove it and decimal point token.arg = token.arg.replace(/\.0*e/, "e").replace(/\.0$/, ""); } break; } default: throw new Error( "unexpected double notation '" + token.doubleNotation + "'", ); } // C says that exponent must have at least two digits. // But ECMAScript does not; toExponential results in things like '1.000000e-8' and '1.000000e+8'. // Note that s.replace(/e([\+\-])(\d)/, 'e$10$2') won't work because of the '$10' instead of '$1'. // And replace(re, func) isn't supported on IE50 or Safari1. token.arg = token.arg.replace(/e\+(\d)$/, "e+0$1").replace( /e\-(\d)$/, "e-0$1", ); // if alt, ensure a decimal point if (token.alternative) { token.arg = token.arg.replace(/^(\d+)$/, "$1."); token.arg = token.arg.replace(/^(\d+)e/, "$1.e"); } if (f >= 0 && token.sign) { token.arg = token.sign + token.arg; } token.arg = token.toUpper ? token.arg.toUpperCase() : token.arg.toLowerCase(); } formatObject(token: TokenInfo): void { // Simple object formatting without util.inspect if (token.arg === null) { token.arg = "null"; return; } if (token.arg === undefined) { token.arg = "undefined"; return; } // Use JSON.stringify with indentation for objects try { // Limit depth based on precision if specified const maxDepth = token.period === "." && token.precision !== undefined ? token.precision : 2; // Create a simplified version of the object with limited depth const simplifiedObj = this.limitObjectDepth(token.arg, maxDepth); // Format with indentation if alternative flag is set token.arg = JSON.stringify( simplifiedObj, null, token.alternative ? 2 : 0, ); } catch { // Fallback for circular references or other JSON.stringify errors token.arg = String(token.arg); } } zeroPad(token: TokenInfo, length?: number): void { length = (arguments.length === 2) ? length : token.precision; let negative = false; if (typeof token.arg !== "string") { token.arg = "" + token.arg; } if (token.arg.substr(0, 1) === "-") { negative = true; token.arg = token.arg.substr(1); } const tenless = (length || 0) - 10; while (token.arg.length < tenless) { token.arg = (token.rightJustify) ? token.arg + this._zeros10 : this._zeros10 + token.arg; } const pad = (length || 0) - token.arg.length; token.arg = (token.rightJustify) ? token.arg + this._zeros10.substring(0, pad) : this._zeros10.substring(0, pad) + token.arg; if (negative) token.arg = "-" + token.arg; } fitField(token: TokenInfo): void { if ( token.maxWidth !== undefined && token.maxWidth >= 0 && token.arg!.length > token.maxWidth ) { token.arg = token.arg!.substring(0, token.maxWidth); } if (token.zeroPad) { this.zeroPad(token, token.minWidth); return; } this.spacePad(token); } spacePad(token: TokenInfo, length?: number): void { length = (arguments.length === 2) ? length : token.minWidth; if (typeof token.arg !== "string") { token.arg = "" + token.arg; } const tenless = (length || 0) - 10; while (token.arg.length < tenless) { token.arg = (token.rightJustify) ? token.arg + this._spaces10 : this._spaces10 + token.arg; } const pad = (length || 0) - token.arg.length; token.arg = (token.rightJustify) ? token.arg + this._spaces10.substring(0, pad) : this._spaces10.substring(0, pad) + token.arg; } private _parseDelim( mapping?: string, intmapping?: string, flags?: string, minWidth?: string, period?: string, precision?: string, specifier?: string, ): TokenInfo { if (mapping) { this._mapped = true; } return { mapping, intmapping, flags: flags || "", _minWidth: minWidth, period, _precision: precision, specifier: specifier || "", }; } // Helper method to limit object depth for formatObject private limitObjectDepth(obj: any, maxDepth: number, currentDepth = 0): any { if (currentDepth >= maxDepth) { if (Array.isArray(obj)) { return "[Array]"; } else if (typeof obj === "object" && obj !== null) { return "[Object]"; } return obj; } if (Array.isArray(obj)) { return obj.map((item) => this.limitObjectDepth(item, maxDepth, currentDepth + 1) ); } else if (typeof obj === "object" && obj !== null) { const result: Record = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] = this.limitObjectDepth( obj[key], maxDepth, currentDepth + 1, ); } } return result; } return obj; } } /** * Printf-style string formatting function * @param format The format string * @param args Values to be formatted * @returns Formatted string */ export default function (format: string, ...args: any[]): string { const formatter = new Formatter(format); return formatter.format(...args); }