UNPKG

loosely

Version:
747 lines (614 loc) 18.9 kB
/** * A path contains a node and the input that lead to it. */ class Path { /** * Initialize the path properties. * @param {Node} node - The last node in the path. * @param {String} value - The sequence of characters that forged this path. * @param {Number} score - The number of input characters in the value. */ constructor(node, value, score) { this.node = node; this.value = value; this.score = score; } /** * Find the paths that the given character leads to. * @param {String} character - The character to match tokens against. * @param {Path[]} results - The paths that the character matches. */ find(character, results) { var size = results.length; var paths = [this]; var history = {}; // Search until thre are no more paths left. var _loop = function _loop() { // Only try recursive paths once. paths = paths.filter(path => !history[path.node.id]); paths.forEach(path => { history[path.node.id] = true; }); // Find the nodes that this character leads to. var alternatePaths = []; paths.forEach(path => { var nextPaths = []; path.node.find(character, nextPaths); nextPaths.forEach((_ref) => { var { node, value, score } = _ref; var newPath = new Path(node, path.value + value, path.score + score); // If alternate characters were found, continue searching those paths. if (score === 0) { alternatePaths.push(newPath); } // If the character matched any tokens, add those paths to the results. else { results.push(newPath); } }); }); paths = alternatePaths; }; while (paths.length) { _loop(); } if (results.length === size) { results.push(this); } } /** * Get a random path that this path leads to. * @returns {Path} - The path. */ sample() { var path = this.node.sample(); if (path === null) return null; return new Path(path.node, this.value + path.value, this.score + path.score); } /** * Compare paths by highest score and use the shortest value to break ties. * @param {Path} a - The first path. * @param {Path} b - The second path. * @returns {Number} - A signed number that is positive number if a is better * than b, zero if they are equal, and negative if a is worse than b. */ static compare(a, b) { return a.score - b.score || b.value.length - a.value.length; } } /** * Find the maximum value in the set using the given comparison criteria. * @param {Any[]} set - The set to search. * @param {Function} compare - The comparison criteria. * @returns {Any} - The maximum item in the set. */ function max(set, compare) { return set.reduce((best, item) => compare(item, best) > 0 ? item : best, set[0]); } /** * Builds a regular expression that enforces full text matching. * @param {String|RegExp} mask - The regular expression source. * @returns {RegExp} - The full text regular expression. */ function maskToRegex(mask) { var source = mask.source || mask; if (source[0] !== '^') source = "^".concat(source); if (source[source.length - 1] !== '$') source = "".concat(source, "$"); return new RegExp(source); } function sample(set) { var index = Math.floor(Math.random() * Math.floor(set.length)); return set[index]; } var ASCII = ['\t', '\n']; for (var i = 32; i < 127; i += 1) { ASCII.push(String.fromCharCode(i)); } function reverse(string) { return string.split('').reverse().join(''); } /** * A node of tokens in a graph. Tokens represent a single characters or * character classes. */ class Node { /** * Create an empty node. * @param {Node} token - The token for this node. * @param {Node} parent - The parent node of this node. */ constructor(token, parent) { this.id = Node.counter; Node.counter += 1; this.token = token; this.parent = parent; this.children = []; } /** * Add a child. * @param {Node} node - The child node. * @return {Node} - The child node. */ add(node) { this.children.push(node); return node; } /** * Create a child. * @param {Token} [token] - The token for the child node. * @return {Node} - The child node. */ spawn(token) { return this.add(new Node(token, this)); } /** * Clone the node and its child. * @return {Node} - The clone. */ clone() { var clone = new Node(this.token, this.parent); clone.children = this.children.map(child => child.clone()); return clone; } /** * Add this node to any nodes that don't have children. * @return {Node} - The node to terminate other nodes to. */ terminate(node) { var history = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; if (history[this.id]) return; Object.assign(history, { [this.id]: true }); if (this.children.length) this.children.forEach(child => child.terminate(node, history));else this.add(node); } end() { var history = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; if (history[this.id]) return this; Object.assign(history, { [this.id]: true }); if (this.children.length) return this.children[0].end(history); return this; } list() { var history = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; if (history[this.id]) return []; Object.assign(history, { [this.id]: true }); var nodes = [this]; this.children.forEach(child => nodes.push(...child.list(history))); return nodes; } /** * Find paths to nodes that matches the given character. * @param {String} character - The character to match tokens against. * @returns {Path[]} - The paths that the character matches. */ find(character, results) { var emptyNodes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; this.children.forEach(child => { if (!child.token) { // Only visit empty loops once. if (emptyNodes[child.id]) return null; // Pass through nodes with no token. return child.find(character, results, Object.assign({ [child.id]: true }, emptyNodes)); } // If the character matches the token, use it and give it a score of one. if (child.token.match(character)) { return results.push(new Path(child, character, 1)); } // If there is only one path, use it and give it a score of zero. var onlyOnePath = child.token.choices === 1; if (onlyOnePath) { return results.push(new Path(child, child.token.value, 0)); } }); } /** * Find a random path that this node leads to. * @returns {Path} - The path. */ sample() { var paths = this.children.map(child => { // Pass through nodes with no token. if (!child.token) return child.sample(); // Use a random character that matches the token. return new Path(child, child.token.sample(), 1); }); if (paths.length === 0) return null; return sample(paths); } } Node.counter = 0; /** * An abstract class for matching characters. */ class Token { /** * Set the value of the token. * @param {Any} value - The value of the token. */ constructor(value, choices) { this.id = value; this.value = value; this.choices = choices; } /** * Compare a value to the token's value. * @name match * @method * @param {String} value - A character. * @returns {Boolean} - True if the character matches the token. */ /** * Selects a random character that matches the token. * @name sample * @method * @returns {String} - A character. */ } /** * A token for matching a character to a character. */ class CharacterToken extends Token { /** * Set the value of the token to a character. * @param {String} value - The token character. */ constructor(value) { super(value, 1); } /** * Compare a value to the token's character value. * @param {String} value - A character. * @returns {Boolean} - True if the character matches the token. */ match(value) { return value === this.value; } /** * Selects a random character that matches the token. * @returns {String} - A character. */ sample() { return this.value; } } /** * A token for matching a character to a single character regular expression. */ class ClassToken extends Token { /** * Set the value of the token to a regular expression. * @param {String} value - The source of the regular expression. */ constructor(value, charset) { var pattern = new RegExp(value); var values = charset.filter(c => pattern.test(c)); super(values, values.length); this.id = "/".concat(value, "/"); } /** * Compare a value to the token's character value. * @param {String} value - A character. */ match(value) { return this.value.includes(value); } /** * Selects a random character that matches the token. * @returns {String} - A character. */ sample() { return sample(this.value); } } /** * A graph provides access to the branching structure of a regular expression. * The nodes of the graph are tokens that either contain a single character or * a regular expression that only matches a single character. */ class Graph { /** * Build a graph of tokens from a regular expression. * @param {RegExp} regex - The regular expression to parse. * @param {RegExp} charset - The optional charset to use for regex classes. * Defaults to the set of ASCII characters. */ constructor(regex) { var charset = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ASCII; this.rootNode = new Node(); var currentNode = this.rootNode; var groupNodes = [currentNode]; // Parse the regex source into a graph of tokens. for (var i = 0; i < regex.source.length; i += 1) { switch (regex.source[i]) { /* Groups */ case '(': currentNode = currentNode.end().spawn(); groupNodes.push(currentNode); if (regex.source[i + 1] === '?') { i += 1; if (regex.source[i + 1] === ':') { i += 1; } else if (regex.source[i + 1] === '=') { i += 1; } else if (regex.source[i + 1] === '!') { // TODO: Handle negated assertions. i += 1; } else { i += 2; } } break; case ')': { currentNode = groupNodes.pop(); currentNode.terminate(new Node()); break; } case '[': { var setEnd = i + 1; while (setEnd < regex.source.length) { if (regex.source[setEnd] === ']' && regex.source[setEnd - 1] !== '\\') break; setEnd += 1; } var set = regex.source.substring(i, setEnd + 1); currentNode = currentNode.end().spawn(new ClassToken(set, charset)); i = setEnd; break; } case '{': { var rangeEnd = i + 1; while (rangeEnd < regex.source.length) { if (regex.source[rangeEnd] === '}' && regex.source[rangeEnd - 1] !== '\\') break; rangeEnd += 1; } var range = regex.source.substring(i + 1, rangeEnd).split(',').map(Number); var min = range[0]; var _max = range.length < 2 ? min : range[1] || Infinity; var node = currentNode.clone(); for (var n = 1; n < min; n += 1) { currentNode = currentNode.end().add(node.clone()); } if (_max === Infinity) currentNode.end().add(currentNode);else { (function () { var exitNodes = [currentNode.end()]; for (var _n = min; _n < _max; _n += 1) { currentNode = currentNode.end().add(node.clone()); exitNodes.push(currentNode.end()); } var endNode = new Node(); exitNodes.forEach(exitNode => exitNode.add(endNode)); currentNode = endNode; })(); } i = rangeEnd; if (regex.source[i + 1] === '?') { i += 1; } break; } /* Operators */ case '|': currentNode = groupNodes[groupNodes.length - 1].spawn(); break; case '+': currentNode.end().add(currentNode); currentNode = currentNode.spawn(); if (regex.source[i + 1] === '?') { i += 1; } break; case '*': { var nextNode = currentNode.parent.spawn(); currentNode.end().add(currentNode); currentNode.end().add(nextNode); currentNode = nextNode; if (regex.source[i + 1] === '?') { i += 1; } break; } case '?': { var _nextNode = currentNode.parent.spawn(); currentNode.end().add(_nextNode); currentNode = _nextNode; if (regex.source[i + 1] === '?') { i += 1; } break; } /* Escape Sequences */ case '\\': { var captureLength = Graph.ESCAPE_LENGTH[regex.source[i + 1]]; if (captureLength) { var sequence = regex.source.substring(i, i + captureLength + 1); currentNode = currentNode.end().spawn(new ClassToken(sequence, charset)); i += captureLength; } else { currentNode = currentNode.end().spawn(new CharacterToken(regex.source[i + 1])); i += 1; } break; } /* Delimeters */ // Start and end delimeters are implicitly represented as nodes without // parents or children (respectively). case '$': case '^': break; /* Wild cards */ case '.': currentNode = currentNode.end().spawn(new ClassToken('.', charset)); break; /* Text */ default: currentNode = currentNode.end().spawn(new CharacterToken(regex.source[i])); } } } /** * Find the paths that the given input leads to. * @param {String} input - A set of characters to run through the graph. * @returns {Path[]} - A set of paths through the graph. */ find(input) { var paths = [new Path(this.rootNode, '', 0)]; input.split('').forEach(character => { var nextPaths = []; paths.forEach(path => path.find(character, nextPaths)); var bestPath = max(nextPaths, Path.compare); paths = nextPaths.filter(path => Path.compare(path, bestPath) === 0); }); return paths; } /** * Generates a random path through the graph. * @returns {Path} - The path through the graph. */ sample() { var path = new Path(this.rootNode, '', 0); var nextPath = path.sample(); while (nextPath) { path = nextPath; nextPath = path.sample(); } return path; } reverse() { var reverseRoot = new Node(); this.rootNode.terminate(reverseRoot); var nodes = this.rootNode.list(); var clones = {}; nodes.forEach(node => { clones[node.id] = new Node(node.token); }); nodes.forEach(node => { node.children.forEach(child => { clones[child.id].add(clones[node.id]); }); }); this.rootNode = clones[reverseRoot.id]; } } // The number of characters that should get consumed by each escape sequence. Graph.ESCAPE_LENGTH = { c: 2, x: 3, u: 5, d: 1, D: 1, w: 1, W: 1, s: 1, S: 1, t: 1, r: 1, n: 1, v: 1, f: 1, 0: 1, b: 1, B: 1 }; /** * A convenience wrapper around the graph class. */ class Mask { /** * Build a graph from a regular expression. * @param {String|RegExp} mask - The regular expression to use. */ constructor(mask) { this.regex = maskToRegex(mask); this.graph = new Graph(this.regex); } /** * Determine if the input matches the mask completely. * @param {String} input - The text to match. * @returns {Boolean} - True if the input matches the mask completely. */ validate(input) { return this.regex.test(input); } /** * Modify the input to satisfy the mask. * @param {String} input - The text to mask. * @returns {String} - The masked text. */ filter(input) { var paths = this.graph.find(input); return paths[0].value; } /** * Monitor changes to the element value and apply the mask. * @param {Element} element - The element to monitor. */ watch(element) { element.addEventListener('input', () => { Object.assign(element, { value: this.filter(element.value) }); }); } /** * Generate text that satisfies the mask. * @returns {String} - The text. */ sample() { var path = this.graph.sample(); return path.value; } } /** * A mask that filters from right to left. */ class ReverseMask extends Mask { /** * Build a graph from a regular expression. * @param {String|RegExp} mask - The regular expression to use. */ constructor(mask) { super(mask); this.graph.reverse(); } /** * Modify the input to satisfy the mask. * @param {String} input - The text to mask. * @returns {String} - The masked text. */ filter(input) { return reverse(super.filter(reverse(input))); } /** * Generate text that satisfies the mask. * @returns {String} - The text. */ sample() { return reverse(super.sample()); } } /* istanbul ignore file */ class Debug { static trace(mask) { return Debug.traceNode(mask.graph.rootNode); } static traceNode(node) { var history = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; var level = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : '| '; var id = node.token ? "".concat(node.token.id, " ").concat(node.id) : "<group ".concat(node.id, ">"); Object.assign(history, { [node.id]: true }); var traces = node.children.map(child => { if (history[child.id]) return "".concat(level, "|\n").concat(level, "|---<").concat(child.id, ">\n"); return Debug.traceNode(child, history, "".concat(level, "| ")); }); return "".concat(level, "\n").concat(level.slice(0, -3), "---").concat(id, "\n").concat(traces.join('')); } } export { Debug, Mask, ReverseMask };