/* eslint-disable max-classes-per-file */
/**
* A `Map` that has a capacity.
*/
export class MapWithCapacity extends Map {
/**
*
* @param {number} capacity
*/
constructor(capacity, ...args) {
if (capacity < 1) {
throw new Error(`Capacity must be greater than 0, received: ${capacity}`);
}
super(...args);
this.capacity = capacity;
this.adjustSize();
}
/**
* Associates supplied value with the provided key.
* If the capacity was reached, earliest item added to this map will be removed.
* Unlike with the standard `Map`, the latest set item will always be emitted
* last by the iterables, even in case it was already added and then overriden.
*
* @param key
* @param value
*/
set(key, value) {
// `Map` iterables produce items in the insertion order.
// Thus we have to remove a possibly existing item from the map to make the new entry emitted
// last by the iterators produced by calling `entries`/`keys`/`values` methods.
this.delete(key);
const res = super.set(key, value);
this.adjustSize();
return res;
}
/**
* Adjusts the size of the underlying map, so it will fit the capacity.
*/
adjustSize() {
while (this.size > this.capacity) { this.removeFirstAdded(); }
}
/**
* Removes the earliest item added to the map.
*/
removeFirstAdded() {
const { value: key, done } = this.keys().next();
return !done && this.delete(key);
}
}
/**
* Returns string containing comma-separated items of the provided iterable.
*
* @template V
* @param {Iterable<V>} iter
* @returns {string}
*/
export const fmtIter = (iter) => `\`[${[...iter].map(String).join(', ')}]\``;
/**
* Pattern matching error.
*
* @param message
* @param path
* @param pattern
* @param errors
*/
export class PatternError extends Error {
constructor(message, path, pattern, errors = []) {
super(message);
this.message = message;
this.path = path;
this.pattern = pattern;
this.errors = errors;
}
}
/**
* Entity used to ensure that provided value matches supplied pattern(s), throws error(s) otherwise.
*/
export class PatternMatcher {
/**
* Ensures that provided value matches supplied pattern(s), throws an error otherwise.
*
* @param pattern
* @param value
* @param {?Array} path
*/
check(pattern, value, path = []) {
for (const key of Object.keys(pattern)) {
if (!key.startsWith('$') || this[key] == null) {
throw new PatternError(`Invalid pattern key \`${key}\``, path, pattern);
}
try {
this[key](pattern, value, path);
} catch (error) {
if (error instanceof PatternError) {
throw error;
} else {
const message = path.length > 0
? `${error.message}, path: \`${path.join('.')}\``
: error.message;
throw new PatternError(message, path, pattern, error.errors);
}
}
}
}
/**
* Supplied value matches pattern's type.
*
* @param pattern
* @param value
*/
$matchType(pattern, value) {
// eslint-disable-next-line valid-typeof
if (typeof value !== pattern.$matchType) {
throw new Error(
`Invalid value provided, expected value with type \`${
pattern.$matchType
}\`, received value with type \`${typeof value}\``,
);
}
}
/**
* Supplied value matches pattern's value.
*
* @param pattern
* @param value
*/
$matchValue(pattern, value) {
if (value !== pattern.$matchValue) {
throw new Error(
`Unknown value \`${value}\`, expected ${pattern.$matchValue}`,
);
}
}
/**
* Supplied value is an object that matches pattern's object patterns.
*
* @param pattern
* @param value
* @param path
*/
$matchObject(pattern, value, path) {
for (const key of Object.keys(value)) {
if (!Object.hasOwnProperty.call(pattern.$matchObject, key)) {
throw new Error(
`Invalid property \`${key}\`, expected keys: ${fmtIter(
Object.keys(pattern.$matchObject),
)}`,
);
}
this.check(pattern.$matchObject[key], value[key], path.concat(key));
}
}
/**
* Supplied value is an iterable that matches the pattern's iterable's patterns.
*
* @param pattern
* @param value
* @param path
*/
$matchIterable(pattern, value, path) {
if (typeof value[Symbol.iterator] !== 'function') {
throw new Error(`Iterable expected, received: ${value}`);
}
const objectIter = value[Symbol.iterator]();
let idx = 0;
for (const pat of pattern.$matchIterable) {
const { value: item, done } = objectIter.next();
if (done) {
throw new Error(
`Value iterable is shorter than expected, received: ${fmtIter(value)}`,
);
}
this.check(pat, item, path.concat(idx++));
}
}
/**
* Supplied value is an instance of the pattern's specified constructor.
*
* @param pattern
* @param value
*/
$instanceOf(pattern, value) {
if (!(value instanceof pattern.$instanceOf)) {
throw new Error(
`Invalid value provided, expected instance of \`${pattern.$instanceOf.name}\`, received instance of \`${value?.constructor?.name}\``,
);
}
}
/**
* Supplied value is an iterable each item of which matches `pattern`'s pattern.
*
* @param pattern
* @param value
* @param path
*/
$iterableOf(pattern, value, path) {
if (typeof value?.[Symbol.iterator] !== 'function') {
throw new Error(`Iterable expected, received \`${value}\``);
}
let idx = 0;
for (const entry of value) {
this.check(pattern.$iterableOf, entry, path.concat(idx++));
}
}
/**
* Supplied value is a map in which keys and values match `pattern`'s patterns.
*
* @param pattern
* @param value
* @param path
*/
$mapOf(pattern, value, path) {
if (typeof value?.entries !== 'function') {
throw new Error(`Map expected, received \`${value}\``);
}
if (!Array.isArray(pattern.$mapOf) || pattern.$mapOf.length !== 2) {
throw new Error(
`\`$mapOf\` pattern should be an array with two items, received \`${JSON.stringify(
pattern,
)}\``,
);
}
for (const [key, item] of value.entries()) {
this.check(pattern.$mapOf[0], key, path.concat(`${key}#key`));
this.check(pattern.$mapOf[1], item, path.concat(key));
}
}
/**
* Supplied value matches at least one of `pattern`'s patterns.
*
* @param pattern
* @param value
* @param path
*/
$anyOf(pattern, value, path) {
let anySucceeded = false;
const errors = [];
for (const pat of pattern.$anyOf) {
if (anySucceeded) {
break;
} else {
try {
this.check(pat, value, path);
anySucceeded = true;
} catch (err) {
errors.push(err);
}
}
}
if (!anySucceeded) {
const error = new Error(`Neither of patterns succeeded for \`${value}\``);
error.errors = errors;
throw error;
}
}
/**
* Supplied value is an object with one key existing in `pattern` that matches the pattern under this key.
*
* @param pattern
* @param value
* @param path
*/
$objOf(pattern, value, path) {
const keys = Object.keys(value);
if (keys.length !== 1) {
throw new Error('Expected a single key');
}
const [key] = keys;
if (!Object.hasOwnProperty.call(pattern.$objOf, key)) {
throw new Error(
`Invalid value key provided, expected one of \`${fmtIter(
Object.keys(pattern.$objOf),
)}\`, received \`${key}\``,
);
}
this.check(pattern.$objOf[key], value[key], path.concat(key));
}
/**
* Ensures that supplied value satisfies provided function.
*
* @param pattern
* @param value
* @param path
*/
$ensure(pattern, value) {
pattern.$ensure(value);
}
}