You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
core/src/aggregrate-errors.js

197 lines
6.7 KiB
JavaScript

"use strict";
const indentString = require("indent-string");
const matchVirtualProperty = require("@validatem/match-virtual-property");
const asExpression = require("as-expression");
const syncpipe = require("syncpipe");
const AggregrateValidationError = require("./aggregrate-validation-error");
const parseStacktrace = require("./parse-stacktrace");
const { dim, dimBold, highlight, highlightBold } = require("./colors");
// TODO: Omit the "At (root)" for path-less errors, to avoid confusion when singular values are being compared?
// TODO: Move out the path generating logic into a separate module, to better support custom error formatting code
// FIXME: Remove duplicate subError-less error messages, when heuristics are enabled? eg. multiple "Must be an array" for different arrayOf combinators
function joinPathSegments(segments) {
return (segments.length > 0)
? segments.join(" -> ")
: "(root)";
}
// FIXME: Render error codes (grayed out) after error messages, as a stable identifier
function renderErrorList(errors, subErrorLevels = 0) {
let rephrasedErrors = errors.map((error, i) => {
let pathSegments = error.path.map((segment) => {
if (segment == null) {
throw new Error(`Unexpected empty path segment encountered; this is a bug, please report it!`);
} else if (typeof segment === "string" || typeof segment === "number") {
return highlight(String(segment));
} else if (matchVirtualProperty(segment)) {
return dim(`(${segment.name})`);
} else {
throw new Error(`Unexpected path segment encountered: ${segment}; this is a bug, please report it!`);
}
});
let lineCharacter = (i < errors.length - 1)
? "├─"
: "└─";
let mainLine = asExpression(() => {
if (subErrorLevels > 0) {
let message = (pathSegments.length > 0)
? `${lineCharacter} ${joinPathSegments(pathSegments)}: ${error.message}`
: `${lineCharacter} ${error.message}`;
return message;
} else {
return (pathSegments.length > 0)
? ` - At ${joinPathSegments(pathSegments)}: ${error.message}`
: ` - ${error.message}`;
}
});
if (error.subErrors != null && error.subErrors.length > 0) {
let renderedSubErrors = renderErrorList(error.subErrors, subErrorLevels + 1);
let isLastError = (i === errors.length - 1);
if (subErrorLevels > 0 && !isLastError) {
return syncpipe(renderedSubErrors, [
(_) => indentString(_, 3),
(_) => indentString(_, 1, { indent: "│" }),
(_) => mainLine + "\n" + _
]);
} else {
return mainLine + "\n" + indentString(renderedSubErrors, 4);
}
} else {
return mainLine;
}
});
return rephrasedErrors.map((error) => {
return `${error}`;
}).join("\n");
}
function determineLocation() {
try {
throw new Error(`Dummy error to obtain a stacktrace`);
} catch (error) {
try {
let externalFrames = syncpipe(error, [
(_) => parseStacktrace(_),
(_) => removeInternalFrames(_),
]);
if (externalFrames.length > 0) {
return syncpipe(externalFrames, [
(_) => _[0],
(_) => {
return {
... _,
shortPath: abbreviatePath(_.location.path)
};
}
]);
} else {
return null;
}
} catch (parsingError) {
// If *anything at all* went wrong, we will just give up and return nothing, because the stacktrace parsing code is fragile, and we don't want that to be a reason for someone not to get a validation error displayed to them.
// FIXME: Do we want to have this visible as a warning in 1.0.0? Or should this warning be opt-in, for when the user wants more detail about *why* the location of the error could not be determined? Since there may be legitimate reasons for that to occur, eg. in bundled code without source maps.
console.warn("An error occurred during stacktrace parsing; please report this as a bug in @validatem/core!", parsingError);
}
}
}
// NOTE: This must be changed if aggregrate-errors.js is ever moved within the module!
let internalBasePathRegex = /(.+)src$/;
function getInternalBasePath() {
let match = internalBasePathRegex.exec(__dirname);
if (match != null) {
return match[1];
} else {
throw new Error(`Did not find expected basePath, instead got: ${__dirname}`);
}
}
function removeInternalFrames(stack) {
let internalBasePath = getInternalBasePath();
if (stack[0].location != null && stack[0].location.path != null && stack[0].location.path.startsWith(internalBasePath)) {
// We are running a normal environment with sensible stacktraces.
return stack.filter((frame) => {
return (
!frame.location.anonymous
&& !frame.location.path.startsWith(internalBasePath)
);
});
} else {
// We're probably in a bundled environment, with errors not being sourcemapped. Use an alternate, less reliable strategy. This will still break when code is minified.
let lastValidationFrame = stack.findIndex((frame) => {
return (frame.functionName != null && frame.functionName.includes("createValidationMethod"));
});
if (lastValidationFrame === -1) {
// Welp, this didn't work either. We'll just return an empty stack then, treating every frame as (possibly) internal, to cause the origin to be displayed as unknown.
return [];
} else {
return stack.slice(lastValidationFrame + 1);
}
}
}
function abbreviatePath(path) {
// TODO: Maybe add a special case for paths within node_modules? For when an error originates from a package the user is depending on.
let segments = path.split(/[\\\/]/);
let [ thirdLast, secondLast, last ] = segments.slice(-3);
if (last != null) {
let isIndexFile = /^index\.[a-z0-9]+$/.test(last);
let relevantSegments = (isIndexFile)
? [ thirdLast, secondLast, last ]
: [ secondLast, last ];
return relevantSegments.join("/");
} else {
// This path is extremely short, so we'll just return it as-is.
return segments.join("/");
}
}
module.exports = function aggregrateAndThrowErrors(errors) {
if (errors.length > 0) {
let detailLines = renderErrorList(errors);
let frame = determineLocation();
let locationString = asExpression(() => {
if (frame != null) {
let functionString = asExpression(() => {
if (frame.alias != null && frame.functionName != null) {
return `${frame.alias} [${frame.functionName}]`;
} else if (frame.functionName != null) {
return `'${frame.functionName}'`;
} else {
return dimBold("(unnamed function)");
}
});
return `${highlightBold(functionString)} in ${highlightBold(frame.shortPath)}, at line ${highlightBold(frame.location.line)} (${frame.location.path})`;
} else {
return dimBold(`(could not determine location)`);
}
});
return new AggregrateValidationError(`One or more validation errors occurred at ${locationString}:\n${detailLines}`, {
errors: errors
});
}
};