Fixes @media and invalid test cases (#47)

* Fixes @media and invalid test cases

The parser now supports media queries with expressionless media features (no
trailing `: <expr>`). For example:

    @media all and (transform-3d) {}

Also adds missing AND operators to test cases which had expressions immediately
following the media type.

Fixes #44.

* Adds more tests and fixes query list parsing

Changing how media queries are parsed introduced a regression where parsing a
malformed media query list would consume tokens past the initial point of
failure.
diff --git a/lib/parser.dart b/lib/parser.dart
index 35f9133..098079f 100644
--- a/lib/parser.dart
+++ b/lib/parser.dart
@@ -375,27 +375,19 @@
   List<MediaQuery> processMediaQueryList() {
     var mediaQueries = <MediaQuery>[];
 
-    bool firstTime = true;
-    var mediaQuery;
     do {
-      mediaQuery = processMediaQuery(firstTime == true);
+      var mediaQuery = processMediaQuery();
       if (mediaQuery != null) {
         mediaQueries.add(mediaQuery);
-        firstTime = false;
-        continue;
+      } else {
+        break;
       }
-
-      // Any more more media types separated by comma.
-      if (!_maybeEat(TokenKind.COMMA)) break;
-
-      // Yep more media types start again.
-      firstTime = true;
-    } while ((!firstTime && mediaQuery != null) || firstTime);
+    } while (_maybeEat(TokenKind.COMMA));
 
     return mediaQueries;
   }
 
-  MediaQuery processMediaQuery([bool startQuery = true]) {
+  MediaQuery processMediaQuery() {
     // Grammar: [ONLY | NOT]? S* media_type S*
     //          [ AND S* MediaExpr ]* | MediaExpr [ AND S* MediaExpr ]*
 
@@ -407,41 +399,39 @@
     var unaryOp = TokenKind.matchMediaOperator(op, 0, opLen);
     if (unaryOp != -1) {
       if (isChecked) {
-        if (startQuery && unaryOp != TokenKind.MEDIA_OP_NOT ||
+        if (unaryOp != TokenKind.MEDIA_OP_NOT ||
             unaryOp != TokenKind.MEDIA_OP_ONLY) {
           _warning("Only the unary operators NOT and ONLY allowed",
               _makeSpan(start));
         }
-        if (!startQuery && unaryOp != TokenKind.MEDIA_OP_AND) {
-          _warning("Only the binary AND operator allowed", _makeSpan(start));
-        }
       }
       _next();
       start = _peekToken.span;
     }
 
     var type;
-    if (startQuery && unaryOp != TokenKind.MEDIA_OP_AND) {
-      // Get the media type.
-      if (_peekIdentifier()) type = identifier();
-    }
+    // Get the media type.
+    if (_peekIdentifier()) type = identifier();
 
     var exprs = <MediaExpression>[];
 
-    if (unaryOp == -1 || unaryOp == TokenKind.MEDIA_OP_AND) {
-      var andOp = false;
-      while (true) {
-        var expr = processMediaExpression(andOp);
-        if (expr == null) break;
-
-        exprs.add(expr);
+    while (true) {
+      // Parse AND if query has a media_type or previous expression.
+      var andOp = exprs.isNotEmpty || type != null;
+      if (andOp) {
         op = _peekToken.text;
         opLen = op.length;
-        andOp = TokenKind.matchMediaOperator(op, 0, opLen) ==
-            TokenKind.MEDIA_OP_AND;
-        if (!andOp) break;
+        if (TokenKind.matchMediaOperator(op, 0, opLen) !=
+            TokenKind.MEDIA_OP_AND) {
+          break;
+        }
         _next();
       }
+
+      var expr = processMediaExpression(andOp);
+      if (expr == null) break;
+
+      exprs.add(expr);
     }
 
     if (unaryOp != -1 || type != null || exprs.length > 0) {
@@ -457,17 +447,16 @@
     if (_maybeEat(TokenKind.LPAREN)) {
       if (_peekIdentifier()) {
         var feature = identifier(); // Media feature.
-        while (_maybeEat(TokenKind.COLON)) {
-          var startExpr = _peekToken.span;
-          var exprs = processExpr();
-          if (_maybeEat(TokenKind.RPAREN)) {
-            return new MediaExpression(
-                andOperator, feature, exprs, _makeSpan(startExpr));
-          } else if (isChecked) {
-            _warning("Missing parenthesis around media expression",
-                _makeSpan(start));
-            return null;
-          }
+        var exprs = _maybeEat(TokenKind.COLON)
+            ? processExpr()
+            : new Expressions(_makeSpan(_peekToken.span));
+        if (_maybeEat(TokenKind.RPAREN)) {
+          return new MediaExpression(
+              andOperator, feature, exprs, _makeSpan(start));
+        } else if (isChecked) {
+          _warning(
+              "Missing parenthesis around media expression", _makeSpan(start));
+          return null;
         }
       } else if (isChecked) {
         _warning("Missing media feature in media expression", _makeSpan(start));
diff --git a/lib/src/css_printer.dart b/lib/src/css_printer.dart
index 79bcaed..ac26acb 100644
--- a/lib/src/css_printer.dart
+++ b/lib/src/css_printer.dart
@@ -54,8 +54,11 @@
 
   void visitMediaExpression(MediaExpression node) {
     emit(node.andOperator ? ' AND ' : ' ');
-    emit('(${node.mediaFeature}:');
-    visitExpressions(node.exprs);
+    emit('(${node.mediaFeature}');
+    if (node.exprs.expressions.isNotEmpty) {
+      emit(':');
+      visitExpressions(node.exprs);
+    }
     emit(')');
   }
 
@@ -68,11 +71,11 @@
     }
   }
 
-  void emitMediaQueries(queries) {
+  void emitMediaQueries(List<MediaQuery> queries) {
     var queriesLen = queries.length;
     for (var i = 0; i < queriesLen; i++) {
       var query = queries[i];
-      if (query.hasMediaType && i > 0) emit(',');
+      if (i > 0) emit(',');
       visitMediaQuery(query);
     }
   }
diff --git a/test/declaration_test.dart b/test/declaration_test.dart
index 9236b12..1cec21e 100644
--- a/test/declaration_test.dart
+++ b/test/declaration_test.dart
@@ -411,13 +411,13 @@
 
   input = '''
 @media only screen and (min-device-width: 4000px) and
-    (min-device-height: 2000px), screen (another: 100px) {
+    (min-device-height: 2000px), screen AND (another: 100px) {
       html {
         font-size: 10em;
       }
     }''';
   generated = '@media ONLY screen AND (min-device-width:4000px) '
-      'AND (min-device-height:2000px), screen (another:100px) {\n'
+      'AND (min-device-height:2000px), screen AND (another:100px) {\n'
       'html {\n  font-size: 10em;\n}\n}';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
@@ -427,14 +427,14 @@
   expect(prettyPrint(stylesheet), generated);
 
   input = '''
-@media screen,print (min-device-width: 4000px) and
-    (min-device-height: 2000px), screen (another: 100px) {
+@media screen,print AND (min-device-width: 4000px) and
+    (min-device-height: 2000px), screen AND (another: 100px) {
       html {
         font-size: 10em;
       }
     }''';
-  generated = '@media screen, print (min-device-width:4000px) AND '
-      '(min-device-height:2000px), screen (another:100px) {\n'
+  generated = '@media screen, print AND (min-device-width:4000px) AND '
+      '(min-device-height:2000px), screen AND (another:100px) {\n'
       'html {\n  font-size: 10em;\n}\n}';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
@@ -444,15 +444,29 @@
   expect(prettyPrint(stylesheet), generated);
 
   input = '''
-@import "test.css" ONLY screen, NOT print (min-device-width: 4000px);''';
-  generated =
-      '@import "test.css" ONLY screen, NOT print (min-device-width:4000px);';
+@import "test.css" ONLY screen, NOT print AND (min-device-width: 4000px);''';
+  generated = '@import "test.css" ONLY screen, '
+      'NOT print AND (min-device-width:4000px);';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
 
   expect(stylesheet != null, true);
   expect(errors.isEmpty, true, reason: errors.toString());
   expect(prettyPrint(stylesheet), generated);
+
+  var css = '@media (min-device-width:400px) {\n}';
+  expectCss(css, css);
+
+  css = '@media all AND (tranform-3d), (-webkit-transform-3d) {\n}';
+  expectCss(css, css);
+
+  // Test that AND operator is required between media type and expressions.
+  css = '@media screen (min-device-width:400px';
+  stylesheet = parseCss(css, errors: errors..clear(), opts: simpleOptions);
+  expect(errors, isNotEmpty);
+  expect(
+      errors.first.message, contains('expected { after media before ruleset'));
+  expect(errors.first.span.text, '(');
 }
 
 void testMozDocument() {