Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Factor out Matcher class into its own file.
  • Loading branch information
cscott committed Feb 6, 2016
commit 3e8bc4ecc50db59b94e3fe12f430d3a03516c1fa
321 changes: 321 additions & 0 deletions src/css/Matcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
/*global ValidationTypes, StringReader*/

/**
* This class implements a combinator library for matcher functions.
* The combinators are described at:
* https://developer.mozilla.org/en-US/docs/Web/CSS/Value_definition_syntax#Component_value_combinators
*/
function Matcher(matchFunc, toString) {
this.match = function(expression) {
// Save/restore marks to ensure that failed matches always restore
// the original location in the expression.
var result;
expression.mark();
result = matchFunc(expression);
if (result) {
expression.drop();
} else {
expression.restore();
}
return result;
};
this.toString = typeof toString === "function" ? toString : function() {
return toString;
};
}

/** Precedence table of combinators. */
Matcher.prec = {
MOD: 5,
SEQ: 4,
ANDAND: 3,
OROR: 2,
ALT: 1
};

/** Simple recursive-descent grammar to build matchers from strings. */
Matcher.parse = function(str) {
var reader, eat, expr, oror, andand, seq, mod, term, result;
reader = new StringReader(str);
eat = function(matcher) {
var result = reader.readMatch(matcher);
if (result === null) {
throw new SyntaxError(
"Expected "+matcher, reader.getLine(), reader.getCol());
}
return result;
};
expr = function() {
// expr = oror (" | " oror)*
var m = [ oror() ];
while (reader.readMatch(" | ") !== null) {
m.push(oror());
}
return m.length === 1 ? m[0] : Matcher.alt.apply(Matcher, m);
};
oror = function() {
// oror = andand ( " || " andand)*
var m = [ andand() ];
while (reader.readMatch(" || ") !== null) {
m.push(andand());
}
return m.length === 1 ? m[0] : Matcher.oror.apply(Matcher, m);
};
andand = function() {
// andand = seq ( " && " seq)*
var m = [ seq() ];
while (reader.readMatch(" && ") !== null) {
m.push(seq());
}
return m.length === 1 ? m[0] : Matcher.andand.apply(Matcher, m);
};
seq = function() {
// seq = mod ( " " mod)*
var m = [ mod() ];
while (reader.readMatch(/^ (?![&|\]])/) !== null) {
m.push(mod());
}
return m.length === 1 ? m[0] : Matcher.seq.apply(Matcher, m);
};
mod = function() {
// mod = term ( "?" | "*" | "+" | "#" | "{<num>,<num>}" )?
var m = term();
if (reader.readMatch("?") !== null) {
return m.question();
} else if (reader.readMatch("*") !== null) {
return m.star();
} else if (reader.readMatch("+") !== null) {
return m.plus();
} else if (reader.readMatch("#") !== null) {
return m.hash();
} else if (reader.readMatch(/^\{\s*/) !== null) {
var min = eat(/^\d+/);
eat(/^\s*,\s*/);
var max = eat(/^\d+/);
eat(/^\s*\}/);
return m.braces(+min, +max);
}
return m;
};
term = function() {
// term = <nt> | literal | "[ " expression " ]"
if (reader.readMatch("[ ") !== null) {
var m = expr();
eat(" ]");
return m;
}
return Matcher.fromType(eat(/^[^ ?*+#{]+/));
};
result = expr();
if (!reader.eof()) {
throw new SyntaxError(
"Expected end of string", reader.getLine(), reader.getCol());
}
return result;
};

/**
* Convert a string to a matcher (parsing simple alternations),
* or do nothing if the argument is already a matcher.
*/
Matcher.cast = function(m) {
if (m instanceof Matcher) {
return m;
}
return Matcher.parse(m);
};

/**
* Create a matcher for a single type.
*/
Matcher.fromType = function(type) {
return new Matcher(function(expression) {
return expression.hasNext() && ValidationTypes.isType(expression, type);
}, type);
};

/**
* Create a matcher for one or more juxtaposed words, which all must
* occur, in the given order.
*/
Matcher.seq = function() {
var ms = Array.prototype.slice.call(arguments).map(Matcher.cast);
if (ms.length === 1) { return ms[0]; }
return new Matcher(function(expression) {
var i, result = true;
for (i = 0; result && i < ms.length; i++) {
result = ms[i].match(expression);
}
return result;
}, function(prec) {
var p = Matcher.prec.SEQ;
var s = ms.map(function(m) { return m.toString(p); }).join(" ");
if (prec > p) { s = "[ " + s + " ]"; }
return s;
});
};

/**
* Create a matcher for one or more alternatives, where exactly one
* must occur.
*/
Matcher.alt = function() {
var ms = Array.prototype.slice.call(arguments).map(Matcher.cast);
if (ms.length === 1) { return ms[0]; }
return new Matcher(function(expression) {
var i, result = false;
for (i = 0; !result && i < ms.length; i++) {
result = ms[i].match(expression);
}
return result;
}, function(prec) {
var p = Matcher.prec.ALT;
var s = ms.map(function(m) { return m.toString(p); }).join(" | ");
if (prec > p) { s = "[ " + s + " ]"; }
return s;
});
};

/**
* Create a matcher for two or more options. This implements the
* double bar (||) and double ampersand (&&) operators, as well as
* variants of && where some of the alternatives are optional.
* This will backtrack through even successful matches to try to
* maximize the number of items matched.
*/
Matcher.many = function(required) {
var ms = Array.prototype.slice.call(arguments, 1).reduce(function(acc, v) {
if (v.expand) {
// Insert all of the options for the given complex rule as
// individual options.
acc.push.apply(acc, ValidationTypes.complex[v.expand].options);
} else {
acc.push(Matcher.cast(v));
}
return acc;
}, []);
if (required === true) { required = ms.map(function() { return true; }); }
var result = new Matcher(function(expression) {
var seen = [], max = 0, pass = 0;
var success = function(matchCount) {
if (pass === 0) {
max = Math.max(matchCount, max);
return matchCount === ms.length;
} else {
return matchCount === max;
}
};
var tryMatch = function(matchCount) {
for (var i = 0; i < ms.length; i++) {
if (seen[i]) { continue; }
expression.mark();
if (ms[i].match(expression)) {
seen[i] = true;
// Increase matchCount iff this was a required element
// (or if all the elements are optional)
if (tryMatch(matchCount + ((required === false || required[i]) ? 1 : 0))) {
expression.drop();
return true;
}
// Backtrack: try *not* matching using this rule, and
// let's see if it leads to a better overall match.
expression.restore();
seen[i] = false;
} else {
expression.drop();
}
}
return success(matchCount);
};
if (!tryMatch(0)) {
// Couldn't get a complete match, retrace our steps to make the
// match with the maximum # of required elements.
pass++;
tryMatch(0);
}

if (required === false) {
return (max > 0);
}
// Use finer-grained specification of which matchers are required.
for (var i = 0; i < ms.length; i++) {
if (required[i] && !seen[i]) {
return false;
}
}
return true;
}, function(prec) {
var p = (required === false) ? Matcher.prec.OROR : Matcher.prec.ANDAND;
var s = ms.map(function(m, i) {
if (required !== false && !required[i]) {
return m.toString(Matcher.prec.MOD) + "?";
}
return m.toString(p);
}).join(required === false ? " || " : " && ");
if (prec > p) { s = "[ " + s + " ]"; }
return s;
});
result.options = ms;
return result;
};

/**
* Create a matcher for two or more options, where all options are
* mandatory but they may appear in any order.
*/
Matcher.andand = function() {
var args = Array.prototype.slice.call(arguments);
args.unshift(true);
return Matcher.many.apply(Matcher, args);
};

/**
* Create a matcher for two or more options, where options are
* optional and may appear in any order, but at least one must be
* present.
*/
Matcher.oror = function() {
var args = Array.prototype.slice.call(arguments);
args.unshift(false);
return Matcher.many.apply(Matcher, args);
};

/** Instance methods on Matchers. */
Matcher.prototype = {
constructor: Matcher,
// These are expected to be overridden in every instance.
match: function(expression) { throw new Error("unimplemented"); },
toString: function() { throw new Error("unimplemented"); },
// This returns a standalone function to do the matching.
func: function() { return this.match.bind(this); },
// Basic combinators
then: function(m) { return Matcher.seq(this, m); },
or: function(m) { return Matcher.alt(this, m); },
andand: function(m) { return Matcher.many(true, this, m); },
oror: function(m) { return Matcher.many(false, this, m); },
// Component value multipliers
star: function() { return this.braces(0, Infinity, "*"); },
plus: function() { return this.braces(1, Infinity, "+"); },
question: function() { return this.braces(0, 1, "?"); },
hash: function() {
return this.braces(1, Infinity, "#", Matcher.cast(","));
},
braces: function(min, max, marker, optSep) {
var m1 = this, m2 = optSep ? optSep.then(this) : this;
if (!marker) {
marker = "{" + min + "," + max + "}";
}
return new Matcher(function(expression) {
var result = true, i;
for (i = 0; i < max; i++) {
if (i > 0 && optSep) {
result = m2.match(expression);
} else {
result = m1.match(expression);
}
if (!result) { break; }
}
return (i >= min);
}, function() { return m1.toString(Matcher.prec.MOD) + marker; });
}
};
Loading