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.
196 lines
6.6 KiB
JavaScript
196 lines
6.6 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)";
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
};
|