const Utils = require('./utils');
const CaseParser = require('./caseParser');
const FormattedError = require('./formattedError');
/**
* The core object of Parserror. A case is like a "service" that validates if an error message
* matches its `condition` and, if defined, runs multiple parsers in order to generate a new
* error.
*/
class ErrorCase {
/**
* @param {ErrorCaseDefinition} definition The case definition settings.
* @param {ErrorCaseOptions} [options={}] The options to customize how the class
* behaves.
* @throws {Error} If the definition is missing the `name`, the `condition` or the
* `message`.
* @throws {TypeError} If the definition `message` is not a string nor a function.
* @throws {TypeError} If the definition `condition` is not a RegExp nor a string.
* @throws {TypeError} If the definition includes `parsers` and it's not an object.
* @throws {TypeError} If a parser is not an object, a function or an instance of
* {@link CaseParser}.
* @throws {TypeError} If the definition includes `parse` and it's not an `array` nor
* an object.
* @throws {TypeError} If the definition includes `parse` and an item is not an
* `array`, a function or an object.
*/
constructor(definition, options = {}) {
/**
* The options to customize how the class behaves.
*
* @type {ErrorCaseOptions}
* @access protected
* @ignore
*/
this._options = {
CaseParserClass: CaseParser,
FormattedErrorClass: FormattedError,
...options,
};
this._validateMissingProperties(definition);
/**
* The case name.
*
* @type {string}
* @access protected
* @ignore
*/
this._name = this._validateName(definition.name);
/**
* Whether or not the case should use the original message when matched.
*
* @type {boolean}
* @access protected
* @ignore
*/
this._useOriginal = !!definition.useOriginal;
/**
* The function that generates the formatted message. If the case should use the
* original message, then the property will be `null`.
*
* @type {?ErrorCaseMessage}
* @access protected
* @ignore
*/
this._message = this._useOriginal ? null : this._validateMessage(definition.message);
/**
* The expression to validate if an error matches the case.
*
* @type {RegExp}
* @access protected
* @ignore
*/
this._condition = this._validateCondition(definition.condition);
/**
* An object with all the parsers the case can make use of.
*
* @type {Object}
* @access protected
* @ignore
*/
this._parsers = this._validateParsers(definition.parsers);
/**
* A list of the parse instructions the case can use on extracted parameters.
*
* @type {Array}
* @access protected
* @ignore
*/
this._parse = this._validateParseInstructions(definition.parse);
/**
* A flag to know whether the parse instructions where defined as an object (`true`)
* or an array (`false`). This is for when the condition uses named groups to extract
* parameters.
*
* @type {boolean}
* @access protected
* @ignore
*/
this._parseAsGroups = Utils.isObject(this._parse);
}
/**
* Validates an error message against the case condition and if it matches, it parses it
* in order to return a formatted error.
*
* @param {string} errorMessage The error message to validate and, possibly, parse.
* @param {Scope[]} [scopes=[]] A list of scopes from where the case can try to find
* reusable parsers.
* @param {?Object} [context=null] Custom context information about the error that can
* be sent to the formatted error.
* @returns {?FormattedError} If the condition doesn't match, it will return `null`.
* @throws {Error} If the condition matches, parameters are extracted as named groups
* but the case's `parse` instructions were defined as an array.
* @throws {Error} If the condition matches, parameters are extracted as a list but
* the case's `parse` instructions were defined as an object.
* @throws {Error} If the condition matches, one of the parsers the case wants to use
* is suppoused to be on one of the scopes but it can't be found.
* @throws {Error} If the condition has a mix of named and unnamed groups.
*/
parse(errorMessage, scopes = [], context = null) {
let result;
if (errorMessage.match(this._condition)) {
result = this._useOriginal
? this._createError(errorMessage, [], {
...context,
original: true,
})
: this._parseError(errorMessage, scopes, context);
} else {
result = null;
}
return result;
}
/**
* The case name.
*
* @type {string}
*/
get name() {
return this._name;
}
/**
* Creates a new instance of the {@link FormattedError} using the class sent on the
* case's `constructor` options.
*
* @param {string} message The error message.
* @param {Object | Array} params The parsed parameters Parserror found. When parsing
* a case that uses named groups, the parameters are
* stored on an `object`; otherwise, they'll be an
* `array`.
* @param {?Object} context Any extra context information for the error.
* @returns {FormattedError}
* @access protected
* @ignore
*/
_createError(message, params, context) {
const { FormattedErrorClass } = this._options;
return new FormattedErrorClass(message, params, context);
}
/**
* Extracts the parameters from an error message using the case condition.
*
* @param {string} errorMessage The message from where the parameters will be
* extracted.
* @property {?Array} matches If the expression extracted unnamed groups, this will be
* a list of them.
* @property {?Object} groups If the expression extracted named groups, this will be
* the dictionary with them.
* @returns {Object} Only one of the properties will be returned.
* @throws {Error} If there's a mix of named and unnamed groups on the condition.
* @access protected
* @ignore
*/
_extractParameters(errorMessage) {
const match = Utils.execRegExp(this._condition, errorMessage);
let result;
const matches = match.slice().filter((item) => typeof item !== 'undefined');
matches.shift();
if (match.groups) {
const groups = { ...match.groups };
const groupsLength = Object.keys(groups).length;
if (groupsLength) {
if (groupsLength !== matches.length) {
throw new Error(
`The condition for the case '${this._name}' is trying to extract parameters as ` +
'named and unnamed groups, only one method is allowed',
);
} else {
result = { groups };
}
} else {
result = { matches };
}
} else {
result = { matches };
}
return result;
}
/**
* The actual method that parses an error message once it matches the case condition.
*
* @param {string} errorMessage The error message to validate and, possibly, parse.
* @param {Scope[]} scopes A list of scopes from where the case can try to find
* reusable parsers.
* @param {?Object} context Custom context information about the error that can be
* sent to the formatted error.
* @returns {FormattedError}
* @throws {Error} If the parameters are extracted as named groups but the case's
* `parse`
* instructions were defined as an array.
* @throws {Error} If the parameters are extracted as a list but the case's `parse`
* instructions were defined as an object.
* @throws {Error} If the condition has a mix of named and unnamed groups.
* @access protected
* @ignore
*/
_parseError(errorMessage, scopes, context) {
let result;
const extracted = this._extractParameters(errorMessage);
if (extracted.groups) {
if (this._parseAsGroups) {
result = this._parseGroups(extracted.groups, scopes, context);
} else {
throw new Error(
`The condition for the case '${this._name}' returned groups, but the 'parse' ` +
"instructions were set on an 'array' format",
);
}
} else if (extracted.matches.length) {
if (this._parseAsGroups) {
throw new Error(
`The condition for the case '${this._name}' didn't return groups, but the 'parse' ` +
"instructions were set on an 'object' format",
);
} else {
result = this._parseList(extracted.matches, scopes, context);
}
} else {
result = this._createError(this._message(), [], context);
}
return result;
}
/**
* Parses named groups extracted from the case condition expression.
*
* @param {Object} groups The named groups.
* @param {Scope[]} scopes A list of scopes from where the case can try to find
* reusable parsers.
* @param {?Object} context Custom context information about the error that can be sent
* to the formatted error.
* @returns {Object} The new parameters, also named.
* @access protected
* @ignore
*/
_parseGroups(groups, scopes, context) {
const params = Object.keys(groups).reduce((newParams, name) => {
const value = groups[name];
const parsers = this._parse[name];
let newValue;
if (parsers) {
newValue = parsers.reduce(
(currentValue, parser) => this._parseValue(parser, currentValue, scopes),
value,
);
} else {
newValue = value;
}
return {
...newParams,
[name]: newValue,
};
}, {});
const message = this._message(params);
return this._createError(message, params, context);
}
/**
* Parses a list of parameters extracted from the case condition expression.
*
* @param {string[]} list The list of parameters to parse.
* @param {Scope[]} scopes A list of scopes from where the case can try to find
* reusable parsers.
* @param {?Object} context Custom context information about the error that can be
* sent to the formatted error.
* @returns {Array}
* @access protected
* @ignore
*/
_parseList(list, scopes, context) {
const params = list.map((value, index) => {
const parsers = this._parse[index];
let newValue;
if (parsers) {
newValue = parsers.reduce(
(currentValue, parser) => this._parseValue(parser, currentValue, scopes),
value,
);
} else {
newValue = value;
}
return newValue;
});
const message = this._message(...params);
return this._createError(message, params, context);
}
/**
* Parses a single value using a given parser. The reason this is wrapped in a method is
* because this functionality is independant of the type of parameters extracted (named
* or unnamed groups).
*
* @param {string | CaseParser} parser The name of a parser the needs to be found on
* the scopes or an actual parser to format the
* value.
* @param {*} value The value to parse.
* @param {Scope[]} scopes A list of scopes where parsers can be found.
* @returns {*} The parsed value.
* @throws {Error} If the parser is a `string` and a parser with that name can't be
* found in any of the scopes.
* @access protected
* @ignore
*/
_parseValue(parser, value, scopes) {
let result;
if (typeof parser === 'string') {
let scopeParser;
scopes.some((scope) => {
scopeParser = scope.getParser(parser, false);
return scopeParser;
});
if (scopeParser) {
result = scopeParser.parse(value);
} else {
throw new Error(
`No parser with the name of '${parser}' could be found for the ` +
`case '${this._name}'`,
);
}
} else {
result = parser.parse(value);
}
return result;
}
/**
* Validates whether something can be used as the case's condition.
*
* @param {string | RegExp} condition The value intended to be the case's condition.
* @returns {RegExp}
* @throws {Error} If the condition is not a `string` nor a `RegExp`.
* @access protected
* @ignore
*/
_validateCondition(condition) {
let result;
if (typeof condition === 'string') {
result = new RegExp(Utils.escapeForRegExp(condition));
} else if (condition instanceof RegExp) {
result = condition;
} else {
throw new TypeError(
`'${this._name}': 'condition' can only be a 'string' or a 'RegExp'`,
);
}
return result;
}
/**
* Validates whether something can be used as the case's message.
*
* @param {string | ErrorCaseMessage} message The value intended to be the case's
* message.
* @returns {ErrorCaseMessage}
* @throws {Error} If the message is not a `function` nor a `string`.
* @access protected
* @ignore
*/
_validateMessage(message) {
let result;
const type = typeof message;
if (type === 'string') {
/**
* Dummy wrapper to maintain the signature.
*
* @returns {string}
* @ignore
*/
result = () => message;
} else if (type === 'function') {
result = message;
} else {
throw new TypeError(
`'${this._name}': 'message' can only be a 'string' or a 'function'`,
);
}
return result;
}
/**
* Validates if one on the case's definition required properties is missing.
*
* @param {ErrorCaseDefinition} definition The case definition settings.
* @throws {Error} If one of the properties is missing.
* @access protected
* @ignore
*/
_validateMissingProperties(definition) {
const missing = ['name', 'condition', 'message'].find(
(property) => typeof definition[property] === 'undefined',
);
if (missing && (missing !== 'message' || !definition.useOriginal)) {
throw new Error(`The '${missing}' property is required on a case definition`);
}
}
/**
* Validates that the name the class intends to use is a `string`.
*
* @param {string} name The name to validate.
* @returns {string}
* @throws {TypeError} If the `name` is not a string.
* @access protected
* @ignore
*/
_validateName(name) {
if (typeof name !== 'string') {
throw new TypeError("The 'name' can only be a 'string'");
}
return name;
}
/**
* Validates and normalizes a single parse instruction.
*
* @param {string} id
* The ID of the instruction. Internally generated by the case in order to have some
* reference for error messages.
* @param {Function | string | Array} instruction
* The instruction to validate.
* @param {Class<CaseParser>} CaseParserClass
* The class used to create new parsers. If the instruction is a function, it will be
* converted into a parser.
* @returns {Function | string | Array}
* @throws {Error}
* If the instruction is not a `function`, a `string` or an `array`.
* @access protected
* @ignore
*/
_validateParseInstruction(id, instruction, CaseParserClass) {
let result;
const type = typeof instruction;
if (type === 'function') {
result = new CaseParserClass(id, instruction);
} else if (type === 'string') {
if (this._parsers[instruction]) {
result = this._parsers[instruction];
} else {
result = instruction;
}
} else if (Array.isArray(instruction)) {
result = instruction.map((item, index) =>
this._validateParseInstruction(`${id}-sub-${index}`, item, CaseParserClass),
);
} else {
throw new TypeError(
`'${this._name}': a 'parse' instruction can only be ` +
"an 'array', a 'function' or a 'string'",
);
}
return result;
}
/**
* Validates and normalizes the parse instructions for the case.
*
* @param {?InstructionListLike} parse The list/map of instructions to validate.
* @returns {InstructionListLike}
* @throws {Error} If the instructions are not an `array` nor an `object`.
* @throws {Error} If an instruction is not a `function`, a `string` or an `array`.
* @access protected
* @ignore
*/
_validateParseInstructions(parse) {
let result;
if (parse) {
const { CaseParserClass } = this._options;
if (Array.isArray(parse)) {
result = parse.map((instruction, index) =>
Utils.ensureArray(
this._validateParseInstruction(
`${this._name}-parser-${index}`,
instruction,
CaseParserClass,
),
),
);
} else if (Utils.isObject(parse)) {
result = Object.keys(parse).reduce(
(newParse, parameterName) => ({
...newParse,
[parameterName]: Utils.ensureArray(
this._validateParseInstruction(
`${this._name}-parser-${parameterName}`,
parse[parameterName],
CaseParserClass,
),
),
}),
{},
);
} else {
throw new TypeError(
`'${this._name}': 'parse' can only be an 'array' or an 'object'`,
);
}
} else {
result = [];
}
return result;
}
/**
* Validates and normalizes a parser intended to be used in the case.
*
* @param {string} name
* The name of the parser.
* @param {CaseParser | Object | Function} parser
* The parser definition.
* @param {Class<CaseParser>} CaseParserClass
* To compare if the parser definition is `instaceof`.
* @returns {CaseParser}
* @throws {Error}
* If the `parser` is not an instance of {@link CaseParserClass}, an `object`
* nor a `function`.
* @access protected
* @ignore
*/
_validateParser(name, parser, CaseParserClass) {
let result;
if (parser instanceof CaseParserClass) {
result = parser;
} else {
const isObject = Utils.isObject(parser);
if (isObject || typeof parser === 'function') {
result = new CaseParserClass(name, parser);
} else {
throw new TypeError(
`'${this._name}' - '${name}': a parser can only be a 'function' or an 'object'`,
);
}
}
return result;
}
/**
* Validates a dictionary of parsers so it can be used by the case.
*
* @param {?Object} parsers A dictionary of reusable parsers.
* @returns {Object.<string, CaseParser>}
* @throws {Error} If `parsers` is not an object.
* @throws {Error} If a a value insde a parser is not an instance of
* {@link CaseParserClass},
* an `object` nor a `function`.
* @access protected
* @ignore
*/
_validateParsers(parsers) {
let result;
if (parsers) {
if (!Utils.isObject(parsers)) {
throw new TypeError(`'${this._name}': 'parsers' can only be an 'object'`);
}
const { CaseParserClass } = this._options;
result = Object.keys(parsers).reduce(
(newParsers, name) => ({
...newParsers,
[name]: this._validateParser(name, parsers[name], CaseParserClass),
}),
{},
);
} else {
result = {};
}
return result;
}
}
module.exports = ErrorCase;