/**
* An API that provides various options for formatting and highlighting Errors. May be useful for logging and test
* frameworks for example.
*
* Stack lines can be filtered in and out based on patterns and limited by range (e.g. lines 2 through 10). Stack lines
* and error message can have highlights applied based on patterns. Finally stack lines can be formatted to include or
* exclude available fields.
*
* The API is quite flexible with a range of methods varying in level with means to specify custom highlights and
* formats.
* @module formaterrors
* @class formaterrors
* @requires diffMatchPatch, stack-trace
*/
var util = require("util");
var diffMatchPatch = new (require("diff_match_patch")).diff_match_patch();
var stackTrace = require("stack-trace");
var LONG_EXPECTED = 40;
var LONG_ACTUAL = 40;
var DEFAULT_FORMAT = new StackFormat();
/**
* Format the stack part (i.e. the stack lines not the message part in the stack) according to a specified StackFormat.
* (See exports.StackFormat for available stack line fields.)
*
* @param {Error} error the error whose stack to format
* @param {StackFormat} stackFormat the specification for the required format
* @return {Error} the given error with its stack modified according to the given StackFormat
*/
exports.formatStack = function (error, stackFormat) {
return formatStackInternal(error, getMessages(error).join(" ") + "\n", stackFormat);
};
/**
* @param {AssertionError} assertionError an AssertionError
* @param {StackTheme} stackTheme the theme for the error
* @return {Error} the given assertionError with stack hightlighted according to the StackTheme specification
*/
exports.highlightAssertionError = function (assertionError, stackTheme) {
if (isActualExpectedError(assertionError) && assertionError.expected.length >= LONG_EXPECTED &&
assertionError.actual.length >= LONG_ACTUAL) {
var diff = diffMatchPatch.diff_main(assertionError.expected.toString(), assertionError.actual.toString());
diffMatchPatch.diff_cleanupSemantic(diff);
assertionError.diff = diff;
var message = diffToMessage(assertionError);
assertionError = formatStackInternal(assertionError, message, DEFAULT_FORMAT);
}
assertionError.stack = exports.applyStackTheme(assertionError.stack, stackTheme);
return assertionError;
};
/**
* Convenience method that highlights the message line and all module related lines in bold.
*
* @param {String} stack an Error stack (i.e. error.stack)
* @param {String} moduleName the name of a module whose stack lines to highlight in bold
* @return {String} a new error stack with bold message and module entries.
*/
exports.boldMessageBoldModuleStack = function (stack, moduleName) {
var format = new exports.StackTheme();
format.messageLineHighlights = [exports.STYLES.BOLD];
format.stackHighlights = [exports.STYLES.BOLD];
format.stackHighlightPatterns = [moduleName];
return exports.applyStackTheme(stack, format);
};
/**
* Convenience method to apply a given set of highlights to a an error stack.
*
* @param {String} stack an Error stack (i.e. error.stack)
* @param {String[]} messageLineHighlights an array of prefixes to be applied to the first line of the stack
* (e.g. [exports.styles.RED, exports.styles.BOLD])
* @param {String[]} stackHighlights an array of prefixes to be applied to each line (e.g. [exports.styles.RED,
* exports.styles.BOLD]) matching one or more of the provided "stackPatterns"
* @param {String[]} stackPatterns an array of regular expressions against which to perform match operations on each line of the stack
* @param {Boolean} inclusive use the patterns to include or exclude from the stack. Defaults to true.
* @return {String} a new error stack String highlighted as specified by the parameters
*/
exports.applyStackHighlights = function (stack, messageLineHighlights, stackHighlights, stackPatterns, inclusive) {
var format = new exports.StackTheme();
format.messageLineHighlights = messageLineHighlights;
format.stackHighlights = stackHighlights;
format.stackHighlightPatterns = stackPatterns;
format.highlightInclusive = inclusive;
return exports.applyStackTheme(stack, format);
};
/**
* Convenience method to apply multiple transformations to an error stack.
*
* @param {String} stack an error stack (i.e. error.stack)
* @param {StackTheme} theme the theme for the stack
* @return {String} a new error stack String transformed according to the specified StackFormat
*/
exports.applyStackTheme = function (stack, theme) {
var newStack = stack;
if (theme.stackRange.start) {
newStack = exports.stackRange(newStack, theme.stackRange.start, theme.stackRange.depth);
}
if (theme.stackFilters) {
newStack = exports.stackFilter(newStack, theme.stackFilters, theme.filterInclusive);
}
if (theme.stackHighlights) {
newStack = exports.stackHighlight(newStack, theme.stackHighlightPatterns, theme.stackHighlights,
theme.highlightInclusive);
}
if (theme.messageLineHighlights) {
newStack = exports.highlightStackMessage(newStack, theme.messageLineHighlights);
}
return newStack;
};
/**
* Highlight just the first line of an error stack - i.e. the message part
*
* @param {String} stack an Error stack (i.e. error.stack)
* @param {String[]} highlights an array of prefixes to be applied to each matching line (e.g. [exports.styles.RED,
* exports.styles.BOLD])
* @return {String} a new error stack with the given highlights applied to the message part
*/
exports.highlightStackMessage = function (stack, highlights) {
if (!highlights) {
return stack;
}
var newStack = "";
var lines = stack.split('\n');
var messagePrefix = "";
for (var i = 0; i < highlights.length; i++) {
messagePrefix += highlights[i];
}
for (i = 0; i < lines.length; i++) {
var line = lines[i];
if (isMessageLine(line, i)) {
line = messagePrefix + line + exports.STYLES.NORMAL;
}
newStack += line;
if (i < lines.length - 1) {
newStack += '\n';
}
}
return newStack;
};
/**
* Given an stack from an Error return a subset of the lines in the stack. The first line (aka the message) is always
* included.
*
* @param {String} stack the Error stack (i.e. error.stack)
* @param {Number} start the first line of the stack to include in the range. Note that the message lines are always included
* as the real first lines regardless of the value of 'start'.
* @param {Number} depthFromStart optional number of lines from 'start' to include in the returned stack. If not provided the full
* stack depth starting from 'start' is provided.
* @return {String} a new error stack containing the specified range of lines from the provided stack.
*/
exports.stackRange = function (stack, start, depthFromStart) {
var origLines = stack.split('\n');
var newStack = "";
for (var i = 0; i < origLines.length && isMessageLine(origLines[i]); i++) {
newStack += origLines[i] + "\n";
}
var end = depthFromStart ? i + start + depthFromStart : origLines.length;
for (i += start; i < origLines.length && i < end; i++) {
newStack += origLines[i];
if (i < origLines.length - 1 && i < end - 1) {
newStack += "\n";
}
}
return newStack;
};
/**
* Filter lines of a stack in or out of the stack based on an array of regexp values. If a line matches a regexp then
* it is either included or excluded in the returned stack based on the value of 'inclusive'.
*
* @param {String} stack a stack from an Error (i.e. error.stack)
* @param {String[]} filters an array of regular expressions against which to perform match operations on each line of the
* stack
* @param {Boolean} inclusive use the filters to include or exclude from the stack. Defaults to true.
* @return {String} a new error stack filtered according to the 'filters' and 'inclusive' values
*/
exports.stackFilter = function (stack, filters, inclusive) {
var includedAction = function (newStack, includedLine) {
return newStack + includedLine;
};
var excludedAction = function (newStack) {
return newStack;
};
return exports.applyFilters(includedAction, excludedAction, stack, filters, inclusive, false);
};
/**
* Apply highlights to an error stack including the message part (line 0 of error.stack) based on matching patterns.
*
* @param {String} stack a stack from an Error (i.e. error.stack)
* @param {String[]} patterns an array of regular expressions against which to perform match operations on each line of the stack
* @param highlights an array of prefixes to be applied to each matching line (e.g. [exports.styles.RED,
* exports.styles.BOLD])
* @param {Boolean} inclusive use the patterns to include or exclude from the stack. Defaults to true.
* @return {String} a new error stack highlighted with the specified highlights according to the provided patterns
*/
exports.stackHighlight = function (stack, patterns, highlights, inclusive) {
if (!highlights || highlights.length < 1) {
return stack;
}
var includedAction = function (newStack, includedLine) {
var newLine = highlights[0];
for (var i = 1; i < highlights.length; i++) {
newLine += highlights[i];
}
newLine += includedLine + exports.STYLES.NORMAL;
return newStack + newLine;
};
var excludedAction = function (newStack, excludedLine) {
return newStack + excludedLine;
};
return exports.applyFilters(includedAction, excludedAction, stack, patterns, inclusive, true);
};
/**
* Apply filters to the lines of an error.stack and call the includedAction or the excludedAction functions based on
* the result of the match and the value of the 'inclusive' parameter. If based on the filter a stack line is included
* includedAction is invoked with the current value of the stack under construction and the current stack line. Otherwise
* excludedAction is called with the same arguments.
*
* This function is common to higher level functions that operate based on stack line filtering and should only be
* required to meet bespoke behaviour that cannot be achieved through the higher level functions (e.g.
* exports.stackHighlight and exports.stackFilter).
*
* Normally there should be no need to call this function directly.
*
* @param {Function(stack, stackLine)} includedAction the function to call for stack lines that are included based on filters and inclusive parameters.
* Function signature is: includedAction(stackUnderConstruction, includedStackLine) returning the updated
* stackUnderConstruction.
* @param {Function(stack, stackLine)} excludedAction the function to call for stack lines that are excluded based on filters and inclusive parameters.
* Function signature is: excludedAction(stackUnderConstruction, excludedStackLine) returning the updated
* stackUnderConstruction.
* @param {String} stack a stack from an Error (i.e. error.stack)
* @param {String[]} filters an array of regular expressions against which to perform match operations on each line of the
* stack
* @param {Boolean} inclusive use the filters to include or exclude from the stack. Defaults to true.
* @param {Boolean} includeMessage include the message part of the stack in the filtering operation
* @return {String} a new error stack modified according to the results of calls to includedAction and excludedAction based on
* filters provided and the inclusive parameter.
*/
exports.applyFilters = function (includedAction, excludedAction, stack, filters, inclusive, includeMessage) {
var origLines = stack.split('\n');
var newStack = "";
if (inclusive !== true && inclusive !== false) {
inclusive = true;
}
for (var i = 0; i < origLines.length; i++) {
if (!includeMessage && isMessageLine(origLines[i])) {
newStack += origLines[i];
} else {
var filter = filterMatch(origLines[i], filters);
if ((inclusive && filter) || (!inclusive && !filter)) {
newStack = includedAction(newStack, origLines[i]);
} else {
newStack = excludedAction(newStack, origLines[i]);
}
}
if (i < origLines.length - 1 && newStack.charAt(newStack.length - 1) !== '\n') {
newStack += "\n";
}
}
return newStack;
};
/**
* Determine if a provided array of regular expressions includes a match for a provided String.
*
* @method filterMatch
* @private
* @param {String} s the String
* @param {String[]} regExps an array of reg. exp. Strings
* @return {Boolean} true if a match is found; false otherwise
*/
function filterMatch(s, regExps) {
if (!regExps) {
return false;
}
var match = false;
for (var i = 0; i < regExps.length && !match; i++) {
match = s.match(regExps[i]) !== null;
}
return match;
}
/**
* Enhance an Error by adding a stackLines property that contains only the stack lines of the provided error
* (i.e. no message lines). The stackLines property is an array of V8 CallStack objects.
*
* (Would have preferred to clone the given error but it seems that Object.keys(error) is always empty - so that
* does not work.)
*
* @method enhanceError
* @private
* @param {Error} error an Error
* @return {Error} the given error with an added stackLines property as an array of V8 CallStack objects
*/
function enhanceError(error) {
if (!isError(error)) {
throw new TypeError("Expected 'error' to be an Error");
}
error.stackLines = stackTrace.parse(error);
return error;
}
/**
* Determine if a given line is a line from the stack part of a stack trace (as opposed to the message part)
*
* @method isStackLine
* @private
* @param {String} line the line String
* @return {Boolean} true if the given line is deemed to be a stack line; false otherwise
*/
function isStackLine(line) {
return line.match(/at (?:([^\s]+)\s+)?\(?(?:(.+?):(\d+):(\d+)|([^)]+))\)?/) !== null;
}
/**
* Determine if a given line is a line from the message part of a stack trace (as opposed to the stack part).
*
* @method isMessageLine
* @private
* @param {String} line the line String
* @param {Number} lineNumber the line number of the given line within the stack from which it originated
* @return {Boolean} true is the given line is deemed to be a stack line; false otherwise
*/
function isMessageLine(line, lineNumber) {
return lineNumber === 0 || !isStackLine(line);
}
/**
* Determine if a given Error has actual and expected fields.
*
* @method isActualExpectedError
* @private
* @param {Error} error the Error
* @return {Boolean} true if the given Error contains values for both actual and expected
*/
function isActualExpectedError(error) {
return error.expected !== undefined && error.actual !== undefined;
}
/**
* Determine if a given parameter is an Error.
*
* @method isError
* @private
* @param {Error} error the prospective Error
* @return {Boolean} true is 'error' is an Error; false otherwise
*/
function isError(error) {
return error && (Object.prototype.toString.call(error).slice(8, -1) === "Error" ||
(typeof error.stack !== "undefined" && typeof error.name !== "undefined"));
}
/**
* Get the messages part of an error.stack and return these as an array. (The returned array will only contain
* multiple items if the message part consists of multiple lines.)
*
* @method getMessages
* @private
* @param {Error} error the error whose stack messages to provide
* @return {String[]} the messages from the given error stack as an array
*/
function getMessages(error) {
var stackLines = error.stack.split('\n');
var messageComplete = false;
var messageLines = [];
for (var i = 0; i < stackLines.length && !messageComplete; i++) {
var line = stackLines[i];
if (isMessageLine(line, i)) {
messageLines.push(line);
} else {
messageComplete = true;
}
}
return messageLines;
}
/**
* Format the stack part (i.e. the stack lines not the message part in the stack) according to a specified StackFormat.
* (See exports.StackFormat for available stack line fields.)
*
* @method formatStackInternal
* @private
* @param {Error} error the error whose stack to format
* @param {String} message the message to include within the formatted stack
* @param {StackFormat} stackFormat the StackFormat specification
* @return {Error} the given error with its stack modified according to the given StackFormat
*/
function formatStackInternal(error, message, stackFormat) {
var format = stackFormat || DEFAULT_FORMAT;
var enhanced = enhanceError(error);
var stack = message;
for (var i1 = 0; enhanced.stackLines && i1 < enhanced.stackLines.length; i1 += 1) {
var stackLines = enhanced.stackLines;
var line = format.prefix + " ";
var typeName = null;
var fileName = null;
var functionName = null;
var methodName = null;
var lineNumber = null;
var wrapFileDetails = false;
for (var i2 = 0; i2 < format.components.length; i2 += 1) {
var component = format.components[i2];
switch (component) {
case "typeName":
typeName = stackLines[i1].getTypeName();
if (typeName && typeName.length > 0) {
line += typeName;
wrapFileDetails = true;
}
break;
case "functionName":
functionName = stackLines[i1].getFunctionName();
if (functionName && functionName.length > 0) {
if (functionName.indexOf(typeName) === 0) {
functionName = functionName.slice(typeName.length + 1);
}
if (typeName && typeName.length > 0) {
line += ".";
}
line += functionName;
wrapFileDetails = true;
}
break;
case "methodName":
methodName = stackLines[i1].getMethodName();
if (methodName && methodName.length > 0 && methodName.indexOf(functionName) == -1) {
if (typeName && typeNmae.length > 0) {
line += ".";
}
line += methodName;
wrapFileDetails = true;
}
break;
case "fileName":
fileName = stackLines[i1].getFileName();
if (typeName || functionName || methodName) {
line += " ";
}
if (fileName && fileName.length > 0) {
if (wrapFileDetails) {
line += "(";
}
line += fileName;
}
break;
case "lineNumber":
lineNumber = stackLines[i1].getLineNumber();
if (lineNumber) {
if (fileName) {
line += ":";
}
line += lineNumber;
}
break;
case "columnNumber":
var columnNumber = stackLines[i1].getColumnNumber();
if (columnNumber) {
if (fileName || lineNumber) {
line += ":";
}
line += columnNumber;
}
}
}
if (fileName && wrapFileDetails) {
line += ")";
}
if (i1 < stackLines.length - 1) {
line += "\n";
}
stack += line;
}
enhanced.stack = stack;
return enhanced;
}
/**
* Given an AssertionError that has had diffs applied - and that means it has a diff property - provide the message
* for the AssertionError including details of the diffs.
*
* @method diffToMessage
* @private
* @param {AssertionError} diffedAssertionError an AssertionError that has a diff property containing diffs between the expected and
* actual values
* @return {String} the message that includes diff details
*/
function diffToMessage(diffedAssertionError) {
var diff = diffedAssertionError.diff;
var actual = "";
var expected = "";
for (var i = 0; i < diff.length; i++) {
var diffType = diff[i][0];
if (diffType === 1) {
if (actual.length > 0) {
actual += ", ";
}
actual += "\"" + diff[i][1] + "\"";
} else if (diffType === -1) {
if (expected.length > 0) {
expected += ", ";
}
expected += "\"" + diff[i][1] + "\"";
}
}
var message = "Differences: ";
if (expected.length > 0) {
message += "'expected': " + expected;
}
if (actual.length > 0) {
if (expected.length > 0) {
message += ", ";
}
message += "'actual': " + actual;
}
message += "\n";
return getMessages(diffedAssertionError).join(" ") + "\n" + message;
}
/**
* An object that describes the format of a stack line.
* @class StackFormat
* @for formaterrors
* @constructor
*/
function StackFormat() {
this.prefix = " at";
this.components = ["typeName", "functionName", "methodName", "fileName", "lineNumber", "columnNumber"];
}
exports.StackFormat = StackFormat;
/**
* An object that may be used to define a theme for a a set operations (transformations) to apply to an error stack.
* @class StackTheme
* @for formaterrors
* @constructor
*/
exports.StackTheme = function () {
this.messageLineHighlights = undefined;
this.stackHighlights = undefined;
this.stackHighlightPatterns = undefined;
this.highlightInclusive = undefined;
this.stackFilters = undefined;
this.filterInclusive = undefined;
this.stackRange = {
start: undefined,
depth: undefined
};
};
/**
* Some provided styles for stackHighlight. These may be overridden or alternatives may be used as required.
* @class STYLES
* @static
*/
exports.STYLES = {
"RED": "\u001B[31m",
"GREEN": "\u001B[32m",
"YELLOW": "\u001B[33m",
"BLUE": "\u001B[34m",
"PURPLE": "\u001B[35m",
"CYAN": "\u001B[36m",
"BOLD": "\u001B[1m",
"NORMAL": "\u001B[39m\u001B[22m"
};