loosely
Version:
Text loosely based on input
747 lines (614 loc) • 18.9 kB
JavaScript
/**
* 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 };