UNPKG

polymorphic

Version:

Create functions with different argument signatures for different code flows

479 lines (407 loc) 14.1 kB
'use strict'; /** * Create polymorphic functions * @package polymorphic * @copyright Konfirm ⓒ 2015-2019 * @author Rogier Spieker (rogier+npm@konfirm.eu) * @license MIT */ function polymorphic() { var registry = []; /** * Determine if somewhere in the prototype chains the variable extends an Object with given name * @name isExtendOf * @access internal * @param string name * @param object variable * @return bool extends */ function isExtendOf(name, variable) { var offset = typeof variable === 'object' && variable ? Object.getPrototypeOf(variable) : null, pattern = offset ? new RegExp('^' + name + '$') : null; // It is not quite feasible to compare the inheritance using `instanceof` (all constructors would have to // be registered somehow then) we simply compare the constructor function names. // As a side effect, this enables polymorphic to compare against the exact type (unless a developer has // altered the constructor name, which is not protected from overwriting) while (offset && offset.constructor) { if (pattern.test(offset.constructor.name)) { return true; } offset = Object.getPrototypeOf(offset); } return false; } /** * Map the param property of given candidate to contain only the values and resolve any references to other arguments * @name parameterize * @access internal * @param Object candidate * @return Object candidate (with resolved params) */ function parameterize(candidate) { candidate.param = candidate.param.map(function(param) { var value; if ('value' in param) { value = param.value; } else if ('reference' in param) { value = candidate.param.reduce(function(p, c) { return c !== param && !p && param.reference === c.name && 'value' in c ? c.value : p; }, null); } return value; }); return candidate; } /** * Filter given list so only matching signatures are kept * @name matchSignature * @access internal * @param array candidates * @param array arguments * @return array filtered candidates */ function matchSignature(list, arg) { var types = arg.map(function(variable) { return new RegExp('^(' + type(variable) + ')'); }); return list.filter(function(config) { var variadic = false, result; // result is true if no more arguments are provided than the signature allows OR the last // argument in the signature is variadic result = arg.length <= config.arguments.length || (config.arguments[config.arguments.length - 1] && config.arguments[config.arguments.length - 1].type === '...'); // test each given argument agains the configured signatures if (result) { arg.forEach(function(value, index) { var expect = config.arguments[index] ? config.arguments[index].type : null; // look at ourself and ahead - if there is a following item, and it is variadic, it may be // left out entirely (zero or more) if (isTypeAtIndex('...', config.arguments, index)) { variadic = true; } // the result remains valid as long as the values match the given signature // (type matches or it is variadic) result = result && (variadic || types[index].test(expect) || (expect[expect.length - 1] !== '!' && isExtendOf(expect, value))); }); } return result; }); } /** * Map the registered values to a new object containing the specifics we use to determine the best * @name prepare * @access internal * @param array candidates * @param array arguments * @return array mapped candidates */ function prepare(list, arg) { return list.map(function(config) { var item = { // the function to call call: config.call, // all configured arguments arguments: config.arguments, // the calculated specificity specificity: config.arguments.map(function(argument, index) { var value = 'value' in argument, specificity = 0; // if a argument not a variadic one and the value is specified if (argument.type !== '...' && index < arg.length) { ++specificity; // bonus points if the exact type matches (explicit by type) // OR there is no default value (explicitly provided) if (Number(argument.type === type(arg[index], true) || isExtendOf(argument.type, arg[index]) || !value)) { ++specificity; } // extra bonus points if the type is explicity the same (in case of inheritance) if (new RegExp('^' + type(arg[index], true) + '!$').test(argument.type)){ ++specificity; } } return specificity; }).join(''), // the parameters with which the `call` may be executed param: config.arguments.map(function(argument, index) { var result = {}; result.name = argument.name; // if a variadic type is encountered, the remainder of the given arguments becomes the value if (argument.type === '...') { result.value = arg.slice(index); } else if (index < arg.length && typeof arg[index] !== 'undefined' && arg[index] !== null) { result.value = arg[index]; } else if ('value' in argument) { result.value = argument.value; } else if ('reference' in argument) { result.reference = argument.reference; } return result; }) }; return item; }); } /** * Prioritize the items in the list * @name prepare * @access internal * @param array candidates * @param array arguments * @return array prioritized candidates * @note the list should contain pre-mapped items (as it works on specificity mostly) */ function prioritize(list, arg) { return list.sort(function(a, b) { var typing = function(item, index) { return +(item.type === type(arg[index], true)); }; // if the calculated specificity is not equal it has precedence if (a.specificity !== b.specificity) { // the shortest specificity OR ELSE the highest specificity wins return a.specificity.length - b.specificity.length || b.specificity - a.specificity; } // if the specificity is equal, we want to prioritize on the more explicit types return b.arguments.map(typing).join('') - a.arguments.map(typing).join(''); }); } /** * Compare the type of the argument at a specific position within a collection * @name isTypeAtIndex * @access internal * @param string type * @param array arguments * @param int index * @return boolean type at index */ function isTypeAtIndex(type, list, index) { return list.length > index && 'type' in list[index] ? list[index].type === type : false; } /** * Determine the proper delegate handler for given arguments * @name delegate * @access internal * @param array arguments * @return mixed handler result */ function delegate(arg) { // create a list of possible candidates based on the given arguments var candidate = matchSignature(registry, arg); // prepare the configured signatures/arguments based on the arguments actually recieved candidate = prepare(candidate, arg); // prioritize the candidates candidate = prioritize(candidate, arg); // and finally, filter any candidate which does not fully comply with the signature based on the - now - parameters candidate = candidate.filter(function(item) { var variadic = false, min = item.arguments.map(function(argument, index) { variadic = isTypeAtIndex('...', item.arguments, index) || isTypeAtIndex('...', item.arguments, index + 1); return +(!(variadic || 'value' in argument || 'reference' in argument)); }).join('').match(/^1+/); return arg.length >= (min ? min[0].length : 0); }); return candidate.length ? parameterize(candidate[0]) : false; } /** * Cast variable to given type * @name cast * @access internal * @param string type * @param string value * @return mixed value */ function cast(type, variable) { var result = variable; switch (type) { case 'number': result = +result; break; case 'int': result = parseInt(result, 10); break; case 'float': result = parseFloat(result); break; case 'bool': case 'boolean': result = ['true', '1', 1].indexOf(result) >= 0; break; } return result; } /** * Create a string matching various number types depending on given variable * @name numberType * @access internal * @param string type * @param number variable * @param bool explicit typing * @return string types */ function numberType(type, variable, explicit) { // if the integer value is identical to the float value, it is an integer return (parseInt(variable, 10) === parseFloat(variable) ? 'int' : 'float') + (explicit ? '' : '|' + type); } /** * Create a string matching various object types (object constructor name if explicit) * @name objectType * @access internal * @param string type * @param object variable * @param bool explicit typing * @return string types */ function objectType(type, variable, explicit) { // array get some special treatment by indicating it is not an object but instead an array // this also goes for inherited types if (variable instanceof Array) { type = 'array'; } return variable ? variable.constructor.name + (explicit ? '' : '|' + type) : 'null'; } /** * Create a string matching 'boolean' type and - if not explicit - its shorthand version 'bool' * @name booleanType * @access internal * @param string type * @param bool explicit typing * @return string types */ function booleanType(type, explicit) { return type + (explicit ? '' : '|bool'); } /** * Create a string matching undefined (and any string having one or more alphatical characters if not explicit) * @name undefinedType * @access internal * @param string type * @param bool explicit typing * @return string types */ function undefinedType(type, explicit) { return type + (explicit ? '' : '|[a-z]+'); } /** * Determine the type and create a string ready for use in regular expressions * @name type * @access internal * @param mixed variable * @param bool explicit * @return string type */ function type(variable, explicit) { var result = typeof variable; switch (result) { case 'number': result = numberType(result, variable, explicit); break; case 'object': result = objectType(result, variable, explicit); break; case 'boolean': result = booleanType(result, explicit); break; case 'undefined': result = undefinedType(result, explicit); break; } return result; } /** * Process the expression match result and prepare the argument object * @name prepareArgument * @access internal * @param RegExpMatch match * @param string defaultname * @result Object argument */ function prepareArgument(match, name) { var result = { type: match ? match[1] : false, name: match ? match[2] : name }; if (match) { if (match[4] === '@') { result.reference = match[5]; } else if (match[3] === '=') { result.value = cast(result.type, match[5]); } } return result; } /** * Parse given signature string and create an array containing all argument options for the signature * @name parse * @access internal * @param string signature * @return array options */ function parse(signature) { var pattern = /^(?:void|([a-zA-Z]+!?|\.{3})(?:[:\s]+([a-zA-Z]+)(?:(=)(@)?(.*))?)?)?$/; return signature.split(/\s*,\s*/).map(function(argument, index, all) { var result = prepareArgument(argument.match(pattern), 'var' + (index + 1)); if (result.type === false) { throw new Error('polymorphic: invalid argument "' + argument + '" in signature "' + signature + '"'); } else if (result.type === '...' && index < all.length - 1) { throw new Error('polymorphic: variadic argument must be at end of signature "' + signature + '"'); } return result; }).filter(function(argument) { // a type is undefined if it was declared as 'void' or '' (an empty string) return argument.type !== undefined; }); } /** * The main result function, this is the function actually being returned by `polymorphic` * @name result * @access internal * @param * [one or more arguments] * @return mixed handler result * @throws polymorph: signature not found "<resolved pattern>" */ function polymorph() { var arg = Array.prototype.slice.call(arguments), candidate = delegate(arg); if (!candidate) { throw new Error('polymorph: signature not found "' + arg.map(function(variable) { return type(variable); }).join(', ') + '"'); } return candidate.call.apply(this, candidate.param); } /** * Add one or more signatures and a handler for those signatures * @name signature * @access public * @param string signature1 * @param string signatureN [optional - any number of signature can be provided for a handler] * @param function handler * @return void * @throws polymorphic.signature: expected one or more signatures * polymorphic.signature: expected final argument to be a callback */ polymorph.signature = function() { var arg = Array.prototype.slice.call(arguments), call = arg.length && typeof arg[arg.length - 1] === 'function' ? arg.pop() : null; if (!arg.length) { throw new Error('polymorphic.signature: expected one or more signatures'); } else if (!call) { throw new Error('polymorphic.signature: expected final argument to be a callback'); } arg.forEach(function(signature) { registry.push({ signature: signature, arguments: parse(signature), call: call }); }); }; return polymorph; } module.exports = polymorphic;