loosely
Version:
Text loosely based on input
734 lines (603 loc) • 18.2 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
/**
* 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) {
const size = results.length;
let paths = [this];
const history = {}; // Search until thre are no more paths left.
while (paths.length) {
// 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.
const alternatePaths = [];
paths.forEach(path => {
const nextPaths = [];
path.node.find(character, nextPaths);
nextPaths.forEach(({
node,
value,
score
}) => {
const 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;
}
if (results.length === size) {
results.push(this);
}
}
/**
* Get a random path that this path leads to.
* @returns {Path} - The path.
*/
sample() {
const 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) {
let source = mask.source || mask;
if (source[0] !== '^') source = `^${source}`;
if (source[source.length - 1] !== '$') source = `${source}$`;
return new RegExp(source);
}
function sample(set) {
const index = Math.floor(Math.random() * Math.floor(set.length));
return set[index];
}
const ASCII = ['\t', '\n'];
for (let 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() {
const 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, history = {}) {
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(history = {}) {
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(history = {}) {
if (history[this.id]) return [];
Object.assign(history, {
[this.id]: true
});
const 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, emptyNodes = {}) {
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.
const 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() {
const 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) {
const pattern = new RegExp(value);
const values = charset.filter(c => pattern.test(c));
super(values, values.length);
this.id = `/${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, charset = ASCII) {
this.rootNode = new Node();
let currentNode = this.rootNode;
const groupNodes = [currentNode]; // Parse the regex source into a graph of tokens.
for (let 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 '[':
{
let setEnd = i + 1;
while (setEnd < regex.source.length) {
if (regex.source[setEnd] === ']' && regex.source[setEnd - 1] !== '\\') break;
setEnd += 1;
}
const set = regex.source.substring(i, setEnd + 1);
currentNode = currentNode.end().spawn(new ClassToken(set, charset));
i = setEnd;
break;
}
case '{':
{
let rangeEnd = i + 1;
while (rangeEnd < regex.source.length) {
if (regex.source[rangeEnd] === '}' && regex.source[rangeEnd - 1] !== '\\') break;
rangeEnd += 1;
}
const range = regex.source.substring(i + 1, rangeEnd).split(',').map(Number);
const min = range[0];
const max = range.length < 2 ? min : range[1] || Infinity;
const node = currentNode.clone();
for (let n = 1; n < min; n += 1) currentNode = currentNode.end().add(node.clone());
if (max === Infinity) currentNode.end().add(currentNode);else {
const exitNodes = [currentNode.end()];
for (let n = min; n < max; n += 1) {
currentNode = currentNode.end().add(node.clone());
exitNodes.push(currentNode.end());
}
const 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 '*':
{
const nextNode = currentNode.parent.spawn();
currentNode.end().add(currentNode);
currentNode.end().add(nextNode);
currentNode = nextNode;
if (regex.source[i + 1] === '?') {
i += 1;
}
break;
}
case '?':
{
const nextNode = currentNode.parent.spawn();
currentNode.end().add(nextNode);
currentNode = nextNode;
if (regex.source[i + 1] === '?') {
i += 1;
}
break;
}
/* Escape Sequences */
case '\\':
{
const captureLength = Graph.ESCAPE_LENGTH[regex.source[i + 1]];
if (captureLength) {
const 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) {
let paths = [new Path(this.rootNode, '', 0)];
input.split('').forEach(character => {
const nextPaths = [];
paths.forEach(path => path.find(character, nextPaths));
const 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() {
let path = new Path(this.rootNode, '', 0);
let nextPath = path.sample();
while (nextPath) {
path = nextPath;
nextPath = path.sample();
}
return path;
}
reverse() {
const reverseRoot = new Node();
this.rootNode.terminate(reverseRoot);
const nodes = this.rootNode.list();
const 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) {
const 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() {
const 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, history = {}, level = '| ') {
const id = node.token ? `${node.token.id} ${node.id}` : `<group ${node.id}>`;
Object.assign(history, {
[node.id]: true
});
const traces = node.children.map(child => {
if (history[child.id]) return `${level}|\n${level}|---<${child.id}>\n`;
return Debug.traceNode(child, history, `${level}| `);
});
return `${level}\n${level.slice(0, -3)}---${id}\n${traces.join('')}`;
}
}
exports.Debug = Debug;
exports.Mask = Mask;
exports.ReverseMask = ReverseMask;