Fixes #829 - adds more strict selector merging rules.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Fri, 30 Dec 2016 21:23:29 +0000 (22:23 +0100)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Fri, 30 Dec 2016 21:44:27 +0000 (22:44 +0100)
Why:

* Instead of blacklisting unmergeable selectors we're now
  whitelisting the valid ones and reject all other.

History.md
lib/optimizer/is-mergeable.js [new file with mode: 0644]
lib/optimizer/is-special.js [deleted file]
lib/optimizer/merge-adjacent.js
lib/optimizer/merge-non-adjacent-by-body.js
lib/optimizer/reduce-non-adjacent.js
lib/optimizer/restructure.js
lib/utils/compatibility.js
test/optimizer/is-mergeable-test.js [new file with mode: 0644]
test/optimizer/merge-adjacent-test.js

index 2ddfe31..b610d29 100644 (file)
@@ -24,6 +24,7 @@
 * Fixed issue [#801](https://github.com/jakubpawlowicz/clean-css/issues/801) - smarter `@import` inlining.
 * Fixed issue [#817](https://github.com/jakubpawlowicz/clean-css/issues/817) - makes `off` disable rounding.
 * Fixed issue [#818](https://github.com/jakubpawlowicz/clean-css/issues/818) - disables `px` rounding by default.
+* Fixed issue [#829](https://github.com/jakubpawlowicz/clean-css/issues/829) - adds more strict selector merging rules.
 * Fixed issue [#834](https://github.com/jakubpawlowicz/clean-css/issues/834) - adds extra line break in nested blocks.
 * Fixed issue [#839](https://github.com/jakubpawlowicz/clean-css/issues/839) - allows URIs in import inlining rules.
 * Fixed issue [#840](https://github.com/jakubpawlowicz/clean-css/issues/840) - allows input source map as map object.
diff --git a/lib/optimizer/is-mergeable.js b/lib/optimizer/is-mergeable.js
new file mode 100644 (file)
index 0000000..ba46e23
--- /dev/null
@@ -0,0 +1,189 @@
+var Marker = require('../tokenizer/marker');
+var split = require('../utils/split');
+
+var DEEP_SELECTOR_PATTERN = /\/deep\//;
+var DOUBLE_COLON_PATTERN = /^::/;
+var NOT_PSEUDO = ':not';
+var PSEUDO_CLASSES_WITH_ARGUMENTS = [
+  ':dir',
+  ':lang',
+  ':not',
+  ':nth-child',
+  ':nth-last-child',
+  ':nth-last-of-type',
+  ':nth-of-type'
+];
+var RELATION_PATTERN = /[>\+~]/;
+
+var Level = {
+  DOUBLE_QUOTE: 'double-quote',
+  SINGLE_QUOTE: 'single-quote',
+  ROOT: 'root'
+};
+
+function isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements) {
+  return split(selector, Marker.COMMA).every(function (singleSelector) {
+    return singleSelector.length > 0 &&
+      !isDeepSelector(singleSelector) &&
+      areMergable(singleSelector, extractPseudoFrom(singleSelector), mergeablePseudoClasses, mergeablePseudoElements);
+  });
+}
+
+function isDeepSelector(selector) {
+  return DEEP_SELECTOR_PATTERN.test(selector);
+}
+
+function extractPseudoFrom(selector) {
+  var list = [];
+  var character;
+  var buffer = [];
+  var level = Level.ROOT;
+  var roundBracketLevel = 0;
+  var isQuoted;
+  var isEscaped;
+  var isPseudo = false;
+  var isRelation;
+  var wasColon = false;
+  var index;
+  var len;
+
+  for (index = 0, len = selector.length; index < len; index++) {
+    character = selector[index];
+
+    isRelation = !isEscaped && RELATION_PATTERN.test(character);
+    isQuoted = level == Level.DOUBLE_QUOTE || level == Level.SINGLE_QUOTE;
+
+    if (isEscaped) {
+      buffer.push(character);
+    } else if (isQuoted) {
+      buffer.push(character);
+    } else if (character == Marker.DOUBLE_QUOTE && level == Level.ROOT) {
+      buffer.push(character);
+      level = Level.DOUBLE_QUOTE;
+    } else if (character == Marker.DOUBLE_QUOTE && level == Level.DOUBLE_QUOTE) {
+      buffer.push(character);
+      level = Level.ROOT;
+    } else if (character == Marker.SINGLE_QUOTE && level == Level.ROOT) {
+      buffer.push(character);
+      level = Level.SINGLE_QUOTE;
+    } else if (character == Marker.SINGLE_QUOTE && level == Level.SINGLE_QUOTE) {
+      buffer.push(character);
+      level = Level.ROOT;
+    } else if (character == Marker.OPEN_ROUND_BRACKET) {
+      buffer.push(character);
+      roundBracketLevel++;
+    } else if (character == Marker.CLOSE_ROUND_BRACKET && roundBracketLevel == 1 && isPseudo) {
+      buffer.push(character);
+      list.push(buffer.join(''));
+      roundBracketLevel--;
+      buffer = [];
+      isPseudo = false;
+    } else if (character == Marker.CLOSE_ROUND_BRACKET) {
+      buffer.push(character);
+      roundBracketLevel--;
+    } else if (character == Marker.COLON && roundBracketLevel === 0 && isPseudo && !wasColon) {
+      list.push(buffer.join(''));
+      buffer = [];
+      buffer.push(character);
+    } else if (character == Marker.COLON && roundBracketLevel === 0 && !wasColon) {
+      buffer = [];
+      buffer.push(character);
+      isPseudo = true;
+    } else if (character == Marker.SPACE && roundBracketLevel === 0 && isPseudo) {
+      list.push(buffer.join(''));
+      buffer = [];
+      isPseudo = false;
+    } else if (isRelation && roundBracketLevel === 0 && isPseudo) {
+      list.push(buffer.join(''));
+      buffer = [];
+      isPseudo = false;
+    } else {
+      buffer.push(character);
+    }
+
+    isEscaped = character == Marker.BACK_SLASH;
+    wasColon = character == Marker.COLON;
+  }
+
+  if (buffer.length > 0 && isPseudo) {
+    list.push(buffer.join(''));
+  }
+
+  return list;
+}
+
+function areMergable(selector, matches, mergeablePseudoClasses, mergeablePseudoElements) {
+  return areAllowed(matches, mergeablePseudoClasses, mergeablePseudoElements) &&
+    needArguments(matches) &&
+    !someIncorrectlyChained(selector, matches) &&
+    !someMixed(matches);
+}
+
+function areAllowed(matches, mergeablePseudoClasses, mergeablePseudoElements) {
+  return matches.every(function (match) {
+    var name = match.indexOf(Marker.OPEN_ROUND_BRACKET) > -1 ?
+      match.substring(0, match.indexOf(Marker.OPEN_ROUND_BRACKET)) :
+      match;
+
+    return mergeablePseudoClasses.indexOf(name) > -1 || mergeablePseudoElements.indexOf(name) > -1;
+  });
+}
+
+function needArguments(matches) {
+  return matches.every(function (match) {
+    var bracketOpensAt = match.indexOf(Marker.OPEN_ROUND_BRACKET);
+    var hasArguments = bracketOpensAt > -1;
+    var name = hasArguments ?
+      match.substring(0, bracketOpensAt) :
+      match;
+
+    return hasArguments ?
+      PSEUDO_CLASSES_WITH_ARGUMENTS.indexOf(name) > -1 :
+      PSEUDO_CLASSES_WITH_ARGUMENTS.indexOf(name) == -1;
+  });
+}
+
+function someIncorrectlyChained(selector, matches) {
+  var positionInSelector = 0;
+
+  return matches.some(function (match, index) {
+    var matchAt;
+    var nextMatch = matches[index + 1];
+    var nextMatchAt;
+    var name;
+    var nextName;
+    var areChained;
+
+    if (!nextMatch) {
+      return false;
+    }
+
+    matchAt = selector.indexOf(match, positionInSelector);
+    nextMatchAt = selector.indexOf(match, matchAt + 1);
+    positionInSelector = nextMatchAt;
+    areChained = matchAt + match.length == nextMatchAt;
+
+    if (areChained) {
+      name = match.indexOf(Marker.OPEN_ROUND_BRACKET) > -1 ?
+        match.substring(0, match.indexOf(Marker.OPEN_ROUND_BRACKET)) :
+        match;
+      nextName = nextMatch.indexOf(Marker.OPEN_ROUND_BRACKET) > -1 ?
+        nextMatch.substring(0, nextMatch.indexOf(Marker.OPEN_ROUND_BRACKET)) :
+        nextMatch;
+
+      return name != NOT_PSEUDO || nextName != NOT_PSEUDO;
+    } else {
+      return false;
+    }
+  });
+}
+
+function someMixed(matches) {
+  var firstIsPseudoElement = DOUBLE_COLON_PATTERN.test(matches[0]);
+
+  return matches.some(function (match) {
+    return DOUBLE_COLON_PATTERN.test(match) != firstIsPseudoElement;
+  });
+}
+
+module.exports = isMergeable;
diff --git a/lib/optimizer/is-special.js b/lib/optimizer/is-special.js
deleted file mode 100644 (file)
index 9631514..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-function isSpecial(options, selector) {
-  return options.compatibility.selectors.special.test(selector);
-}
-
-module.exports = isSpecial;
index 167494b..e2e0702 100644 (file)
@@ -3,7 +3,7 @@ var optimizeProperties = require('../properties/optimizer');
 var serializeBody = require('../writer/one-time').body;
 var serializeRules = require('../writer/one-time').rules;
 var tidyRules = require('./tidy-rules');
-var isSpecial = require('./is-special');
+var isMergeable = require('./is-mergeable');
 
 var Token = require('../tokenizer/token');
 
@@ -11,6 +11,8 @@ function mergeAdjacent(tokens, context) {
   var lastToken = [null, [], []];
   var options = context.options;
   var adjacentSpace = options.compatibility.selectors.adjacentSpace;
+  var mergeablePseudoClasses = options.compatibility.selectors.mergeablePseudoClasses;
+  var mergeablePseudoElements = options.compatibility.selectors.mergeablePseudoElements;
 
   for (var i = 0, l = tokens.length; i < l; i++) {
     var token = tokens[i];
@@ -26,7 +28,8 @@ function mergeAdjacent(tokens, context) {
       optimizeProperties(token[1], lastToken[2], joinAt, true, context);
       token[2] = [];
     } else if (lastToken[0] == Token.RULE && serializeBody(token[2]) == serializeBody(lastToken[2]) &&
-        !isSpecial(options, serializeRules(token[1])) && !isSpecial(options, serializeRules(lastToken[1]))) {
+        isMergeable(serializeRules(token[1]), mergeablePseudoClasses, mergeablePseudoElements) &&
+        isMergeable(serializeRules(lastToken[1]), mergeablePseudoClasses, mergeablePseudoElements)) {
       lastToken[1] = tidyRules(lastToken[1].concat(token[1]), false, adjacentSpace, false, context.warnings);
       token[2] = [];
     } else {
index e420784..546b8c5 100644 (file)
@@ -1,7 +1,7 @@
 var serializeBody = require('../writer/one-time').body;
 var serializeRules = require('../writer/one-time').rules;
 var tidyRules = require('./tidy-rules');
-var isSpecial = require('./is-special');
+var isMergeable = require('./is-mergeable');
 
 var Token = require('../tokenizer/token');
 
@@ -33,6 +33,8 @@ function removeAnyUnsafeElements(left, candidates) {
 function mergeNonAdjacentByBody(tokens, context) {
   var options = context.options;
   var adjacentSpace = options.compatibility.selectors.adjacentSpace;
+  var mergeablePseudoClasses = options.compatibility.selectors.mergeablePseudoClasses;
+  var mergeablePseudoElements = options.compatibility.selectors.mergeablePseudoElements;
   var candidates = {};
 
   for (var i = tokens.length - 1; i >= 0; i--) {
@@ -48,7 +50,9 @@ function mergeNonAdjacentByBody(tokens, context) {
 
     var candidateBody = serializeBody(token[2]);
     var oldToken = candidates[candidateBody];
-    if (oldToken && !isSpecial(options, serializeRules(token[1])) && !isSpecial(options, serializeRules(oldToken[1]))) {
+    if (oldToken &&
+        isMergeable(serializeRules(token[1]), mergeablePseudoClasses, mergeablePseudoElements) &&
+        isMergeable(serializeRules(oldToken[1]), mergeablePseudoClasses, mergeablePseudoElements)) {
       token[1] = token[2].length > 0 ?
         tidyRules(oldToken[1].concat(token[1]), false, adjacentSpace, false, context.warnings) :
         oldToken[1].concat(token[1]);
index 282cf8d..f4be6d0 100644 (file)
@@ -1,13 +1,15 @@
 var optimizeProperties = require('../properties/optimizer');
 var serializeBody = require('../writer/one-time').body;
 var serializeRules = require('../writer/one-time').rules;
-var isSpecial = require('./is-special');
+var isMergeable = require('./is-mergeable');
 var cloneArray = require('../utils/clone-array');
 
 var Token = require('../tokenizer/token');
 
 function reduceNonAdjacent(tokens, context) {
   var options = context.options;
+  var mergeablePseudoClasses = options.compatibility.selectors.mergeablePseudoClasses;
+  var mergeablePseudoElements = options.compatibility.selectors.mergeablePseudoElements;
   var candidates = {};
   var repeated = [];
 
@@ -21,7 +23,8 @@ function reduceNonAdjacent(tokens, context) {
     }
 
     var selectorAsString = serializeRules(token[1]);
-    var isComplexAndNotSpecial = token[1].length > 1 && !isSpecial(options, selectorAsString);
+    var isComplexAndNotSpecial = token[1].length > 1 &&
+      isMergeable(selectorAsString, mergeablePseudoClasses, mergeablePseudoElements);
     var wrappedSelectors = wrappedSelectorsFrom(token[1]);
     var selectors = isComplexAndNotSpecial ?
       [selectorAsString].concat(wrappedSelectors) :
@@ -80,6 +83,8 @@ function reduceSimpleNonAdjacentCases(tokens, repeated, candidates, options, con
 }
 
 function reduceComplexNonAdjacentCases(tokens, candidates, options, context) {
+  var mergeablePseudoClasses = options.compatibility.selectors.mergeablePseudoClasses;
+  var mergeablePseudoElements = options.compatibility.selectors.mergeablePseudoElements;
   var localContext = {};
 
   function filterOut(idx) {
@@ -101,9 +106,9 @@ function reduceComplexNonAdjacentCases(tokens, candidates, options, context) {
     var intoToken = tokens[intoPosition];
     var reducedBodies = [];
 
-    var selectors = isSpecial(options, complexSelector) ?
-      [complexSelector] :
-      into[0].list;
+    var selectors = isMergeable(complexSelector, mergeablePseudoClasses, mergeablePseudoElements) ?
+      into[0].list :
+      [complexSelector];
 
     localContext.intoPosition = intoPosition;
     localContext.reducedBodies = reducedBodies;
index e8753d9..b01d41f 100644 (file)
@@ -3,7 +3,7 @@ var canReorderSingle = require('./reorderable').canReorderSingle;
 var serializeBody = require('../writer/one-time').body;
 var serializeRules = require('../writer/one-time').rules;
 var tidyRuleDuplicates = require('./tidy-rule-duplicates');
-var isSpecial = require('./is-special');
+var isMergeable = require('./is-mergeable');
 var cloneArray = require('../utils/clone-array');
 
 var Token = require('../tokenizer/token');
@@ -21,6 +21,8 @@ function cloneAndMergeSelectors(propertyA, propertyB) {
 
 function restructure(tokens, context) {
   var options = context.options;
+  var mergeablePseudoClasses = options.compatibility.selectors.mergeablePseudoClasses;
+  var mergeablePseudoElements = options.compatibility.selectors.mergeablePseudoElements;
   var movableTokens = {};
   var movedProperties = [];
   var multiPropertyMoveCache = {};
@@ -80,8 +82,9 @@ function restructure(tokens, context) {
     var mergeableTokens = [];
 
     for (var i = sourceTokens.length - 1; i >= 0; i--) {
-      if (isSpecial(options, serializeRules(sourceTokens[i][1])))
+      if (!isMergeable(serializeRules(sourceTokens[i][1]), mergeablePseudoClasses, mergeablePseudoElements)) {
         continue;
+      }
 
       mergeableTokens.unshift(sourceTokens[i]);
       if (sourceTokens[i][2].length > 0 && uniqueTokensWithBody.indexOf(sourceTokens[i]) == -1)
index 1c62664..0733b91 100644 (file)
@@ -1,5 +1,3 @@
-var util = require('util');
-
 var DEFAULTS = {
   '*': {
     colors: {
@@ -23,7 +21,42 @@ var DEFAULTS = {
     selectors: {
       adjacentSpace: false, // div+ nav Android stock browser hack
       ie7Hack: false, // *+html hack
-      special: /(\-moz\-|\-ms\-|\-o\-|\-webkit\-|:dir\([a-z-]*\)|:first(?![a-z-])|:fullscreen|:left|:read-only|:read-write|:right|:placeholder|:host|:content|\/deep\/|:shadow|:selection|^,)/ // special selectors which prevent merging
+      mergeablePseudoClasses: [
+        ':active',
+        ':after',
+        ':before',
+        ':empty',
+        ':checked',
+        ':disabled',
+        ':empty',
+        ':enabled',
+        ':first-child',
+        ':first-letter',
+        ':first-line',
+        ':first-of-type',
+        ':focus',
+        ':hover',
+        ':lang',
+        ':last-child',
+        ':last-of-type',
+        ':link',
+        ':not',
+        ':nth-child',
+        ':nth-last-child',
+        ':nth-last-of-type',
+        ':nth-of-type',
+        ':only-child',
+        ':only-of-type',
+        ':root',
+        ':target',
+        ':visited'
+      ], // selectors with these pseudo-classes can be merged as these are universally supported
+      mergeablePseudoElements: [
+        '::after',
+        '::before',
+        '::first-letter',
+        '::first-line'
+      ] // selectors with these pseudo-elements can be merged as these are universally supported
     },
     units: {
       ch: true,
@@ -62,7 +95,16 @@ DEFAULTS.ie8 = merge(DEFAULTS.ie9, {
     merging: false
   },
   selectors: {
-    special: /(\-moz\-|\-ms\-|\-o\-|\-webkit\-|:root|:nth|:first\-of|:last|:only|:empty|:target|:checked|::selection|:enabled|:disabled|:not|:placeholder|:host|::content|\/deep\/|::shadow|^,)/
+    mergeablePseudoClasses: [
+      ':after',
+      ':before',
+      ':first-child',
+      ':first-letter',
+      ':focus',
+      ':hover',
+      ':visited'
+    ],
+    mergeablePseudoElements: []
   },
   units: {
     ch: false,
@@ -81,7 +123,12 @@ DEFAULTS.ie7 = merge(DEFAULTS.ie8, {
   },
   selectors: {
     ie7Hack: true,
-    special: /(\-moz\-|\-ms\-|\-o\-|\-webkit\-|:focus|:before|:after|:root|:nth|:first\-of|:last|:only|:empty|:target|:checked|::selection|:enabled|:disabled|:not|:placeholder|:host|::content|\/deep\/|::shadow|^,)/
+    mergeablePseudoClasses: [
+      ':first-child',
+      ':first-letter',
+      ':hover',
+      ':visited'
+    ]
   },
 });
 
@@ -93,10 +140,11 @@ function merge(source, target) {
   for (var key in source) {
     var value = source[key];
 
-    if (typeof value === 'object' && !util.isRegExp(value))
+    if (typeof value === 'object' && !Array.isArray(value)) {
       target[key] = merge(value, target[key] || {});
-    else
+    } else {
       target[key] = key in target ? target[key] : value;
+    }
   }
 
   return target;
diff --git a/test/optimizer/is-mergeable-test.js b/test/optimizer/is-mergeable-test.js
new file mode 100644 (file)
index 0000000..0ec17eb
--- /dev/null
@@ -0,0 +1,180 @@
+var assert = require('assert');
+
+var vows = require('vows');
+
+var isMergeable = require('../../lib/optimizer/is-mergeable');
+var mergeablePseudoClasses = [':after'];
+var mergeablePseudoElements = ['::before'];
+
+vows.describe(isMergeable)
+  .addBatch({
+    'tag name selector': {
+      'topic': 'div',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'class selector': {
+      'topic': '.class',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'id selector': {
+      'topic': '#id',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'complex selector': {
+      'topic': 'div ~ #id > .class',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'vendor-prefixed pseudo-class': {
+      'topic': ':-moz-placeholder',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'vendor-prefixed pseudo-element': {
+      'topic': '::-moz-placeholder',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'unsupported pseudo-class': {
+      'topic': ':first-child',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'unsupported pseudo-element': {
+      'topic': '::marker',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class': {
+      'topic': ':after',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class with selector': {
+      'topic': 'div:after',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class with arguments': {
+      'topic': 'div:lang(en)',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, [':lang'], mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class in the middle': {
+      'topic': 'div :first-child > span',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, [':first-child'], mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-classes in the middle': {
+      'topic': 'div :first-child > span:last-child > em',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, [':first-child', ':last-child'], mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-classes in the middle without spaces': {
+      'topic': 'div :first-child>span:last-child>em',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, [':first-child', ':last-child'], mergeablePseudoElements));
+      }
+    },
+    'double :not pseudo-class': {
+      'topic': 'div:not(:first-child):not(.one)',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, [':first-child', ':not'], mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class with unsupported arguments': {
+      'topic': 'div:after(test)',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class repeated': {
+      'topic': 'div:after:after',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-element': {
+      'topic': '::before',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-element with selector': {
+      'topic': 'div::before',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-element with arguments': {
+      'topic': '::before(test)',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-element repeated': {
+      'topic': '::before::before',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-class and -element mixed': {
+      'topic': ':after::before',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'supported pseudo-element and -class mixed': {
+      'topic': '::before:after',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    '/deep/ selector': {
+      'topic': '.wrapper /deep/ a',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'empty selector': {
+      'topic': '',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'multi selector': {
+      'topic': 'h1,div',
+      'is mergeable': function (selector) {
+        assert.isTrue(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'multi selector with pseudo-class': {
+      'topic': 'h1:first-child,div',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    },
+    'multi selector with empty': {
+      'topic': ',h1',
+      'is not mergeable': function (selector) {
+        assert.isFalse(isMergeable(selector, mergeablePseudoClasses, mergeablePseudoElements));
+      }
+    }
+  })
+  .export(module);
index 20a12c3..91a8661 100644 (file)
@@ -56,6 +56,10 @@ vows.describe('remove duplicates')
         'a{color:red}::-webkit-scrollbar,a{color:#fff}',
         'a{color:red}::-webkit-scrollbar,a{color:#fff}'
       ],
+      'when an invalid pseudo selector used': [
+        'a{color:red}a:after:after{color:red}',
+        'a{color:red}a:after:after{color:red}'
+      ],
       'two same selectors over a block': [
         '.one{color:red}@media print{.two{display:block}}.one{display:none}',
         '@media print{.two{display:block}}.one{color:red;display:none}'