Repository: jshint/jshint (js)

Issue:
Tagged Template Strings are not linting correctly
I'm getting a ```Missing semicolon.``` error when using a tagged template string. If I have time I'll try to submit a test case for this soon.

My code looks roughly like:
```
function tag() {}

var testStr = 'bar';
function foo() {
  return tag `some
    ${testStr} here`;
}
foo();

```

Pull request description:
[[FEAT]] parse and lint tagged template literals
Closes #2000

Patch:
diff --git a/src/jshint.js b/src/jshint.js
index 8b09cc88..a231624c 100644
--- a/src/jshint.js
+++ b/src/jshint.js
@@ -875,10 +875,6 @@ var JSHINT = (function() {
     if (state.tokens.next.id === "(end)")
       error("E006", state.tokens.curr);
 
-    if (state.tokens.next.type === "(template)") {
-      doTemplateLiteral();
-    }
-
     var isDangerous =
       state.option.asi &&
       state.tokens.prev.line !== startLine(state.tokens.curr) &&
@@ -904,7 +900,9 @@ var JSHINT = (function() {
         error("E030", state.tokens.curr, state.tokens.curr.id);
       }
 
-      while (rbp < state.tokens.next.lbp && !isEndOfExpr()) {
+      // TODO: use pratt mechanics rather than special casing template tokens
+      while ((rbp < state.tokens.next.lbp || state.tokens.next.type === "(template)") &&
+              !isEndOfExpr()) {
         isArray = state.tokens.curr.value === "Array";
         isObject = state.tokens.curr.value === "Object";
 
@@ -1491,6 +1489,8 @@ var JSHINT = (function() {
 
   function parseFinalSemicolon() {
     if (state.tokens.next.id !== ";") {
+      // don't complain about unclosed templates / strings
+      if (state.tokens.next.isUnclosed) return advance();
       if (!state.option.asi) {
         // If this is the last statement in a block that ends on
         // the same line *and* option lastsemic is on, ignore the warning.
@@ -1985,24 +1985,37 @@ var JSHINT = (function() {
     }
   };
 
-  state.syntax["(template)"] = {
-    type: "(template)",
+  var baseTemplateSyntax = {
     lbp: 0,
     identifier: false,
-    nud: doTemplateLiteral
+    template: true,
   };
-
-  type("(template middle)", function() {
-    return this;
-  });
-
-  type("(template tail)", function() {
-    return this;
-  });
-
-  type("(no subst template)", function() {
-    return this;
-  });
+  state.syntax["(template)"] = _.extend({
+    type: "(template)",
+    nud: doTemplateLiteral,
+    led: doTemplateLiteral,
+    noSubst: false
+  }, baseTemplateSyntax);
+
+  state.syntax["(template middle)"] = _.extend({
+    type: "(template middle)",
+    middle: true,
+    noSubst: false
+  }, baseTemplateSyntax);
+
+  state.syntax["(template tail)"] = _.extend({
+    type: "(template tail)",
+    tail: true,
+    noSubst: false
+  }, baseTemplateSyntax);
+
+  state.syntax["(no subst template)"] = _.extend({
+    type: "(template)",
+    nud: doTemplateLiteral,
+    led: doTemplateLiteral,
+    noSubst: true,
+    tail: true // mark as tail, since it's always the last component
+  }, baseTemplateSyntax);
 
   type("(regexp)", function() {
     return this;
@@ -2903,20 +2916,38 @@ var JSHINT = (function() {
     return "(scope)" in token;
   }
 
-  function doTemplateLiteral() {
-    while (state.tokens.next.type !== "(template tail)" && state.tokens.next.id !== "(end)") {
-      advance();
-      if (state.tokens.next.type === "(template tail)") {
-        break;
-      } else if (state.tokens.next.type !== "(template middle)" &&
-                 state.tokens.next.type !== "(end)") {
-        expression(10); // should probably have different rbp?
+  function doTemplateLiteral(left) {
+    // ASSERT: this.type === "(template)"
+    // jshint validthis: true
+    var ctx = this.context;
+    var noSubst = this.noSubst;
+    var depth = this.depth;
+
+    if (!noSubst) {
+      while (!end() && state.tokens.next.id !== "(end)") {
+        if (!state.tokens.next.template || state.tokens.next.depth > depth) {
+          expression(0); // should probably have different rbp?
+        } else {
+          // skip template start / middle
+          advance();
+        }
       }
     }
+
     return {
       id: "(template)",
-      type: "(template)"
+      type: "(template)",
+      tag: left
     };
+
+    function end() {
+      if (state.tokens.curr.template && state.tokens.curr.tail &&
+          state.tokens.curr.context === ctx) return true;
+      var complete = (state.tokens.next.template && state.tokens.next.tail &&
+                      state.tokens.next.context === ctx);
+      if (complete) advance();
+      return complete || state.tokens.next.isUnclosed;
+    }
   }
 
   /**
diff --git a/src/lex.js b/src/lex.js
index d3e3a358..ff5554aa 100644
--- a/src/lex.js
+++ b/src/lex.js
@@ -129,7 +129,23 @@ Lexer.prototype = {
   _lines: [],
 
   inContext: function(ctxType) {
-    return this.context.length > 0 && this.context[this.context.length - 1] === ctxType;
+    return this.context.length > 0 && this.context[this.context.length - 1].type === ctxType;
+  },
+
+  pushContext: function(ctxType) {
+    this.context.push({ type: ctxType });
+  },
+
+  popContext: function() {
+    return this.context.pop();
+  },
+
+  isContext: function(context) {
+    return this.context.length > 0 && this.context[this.context.length - 1] === context;
+  },
+
+  currentContext: function() {
+    return this.context.length > 0 && this.context[this.context.length - 1];
   },
 
   getLines: function() {
@@ -237,7 +253,7 @@ Lexer.prototype = {
 
     // A block/object opener
     case "{":
-      this.context.push(Context.Block);
+      this.pushContext(Context.Block);
       return {
         type: Token.Punctuator,
         value: ch1
@@ -246,7 +262,7 @@ Lexer.prototype = {
     // A block/object closer
     case "}":
       if (this.inContext(Context.Block)) {
-        this.context.pop();
+        this.popContext();
       }
       return {
         type: Token.Punctuator,
@@ -1014,6 +1030,7 @@ Lexer.prototype = {
     var ch;
     var startLine = this.line;
     var startChar = this.char;
+    var depth = this.templateStarts.length;
 
     if (!state.option.esnext) {
       // Only lex template strings in ESNext mode.
@@ -1022,8 +1039,9 @@ Lexer.prototype = {
       // Template must start with a backtick.
       tokenType = Token.TemplateHead;
       this.templateStarts.push({ line: this.line, char: this.char });
+      depth = this.templateStarts.length;
       this.skip(1);
-      this.context.push(Context.Template);
+      this.pushContext(Context.Template);
     } else if (this.inContext(Context.Template) && this.peek() === "}") {
       // If we're in a template context, and we have a '}', lex a TemplateMiddle.
       tokenType = Token.TemplateMiddle;
@@ -1037,7 +1055,6 @@ Lexer.prototype = {
         value += "\n";
         if (!this.nextLine()) {
           // Unclosed template literal --- point to the starting "`"
-          this.context.pop();
           var startPos = this.templateStarts.pop();
           this.trigger("error", {
             code: "E052",
@@ -1049,7 +1066,9 @@ Lexer.prototype = {
             value: value,
             startLine: startLine,
             startChar: startChar,
-            isUnclosed: true
+            isUnclosed: true,
+            depth: depth,
+            context: this.popContext()
           };
         }
       }
@@ -1062,7 +1081,9 @@ Lexer.prototype = {
           value: value,
           startLine: startLine,
           startChar: startChar,
-          isUnclosed: false
+          isUnclosed: false,
+          depth: depth,
+          context: this.currentContext()
         };
       } else if (ch === '\\') {
         var escape = this.scanEscapeSequence(checks);
@@ -1078,7 +1099,6 @@ Lexer.prototype = {
     // Final value is either NoSubstTemplate or TemplateTail
     tokenType = tokenType === Token.TemplateHead ? Token.NoSubstTemplate : Token.TemplateTail;
     this.skip(1);
-    this.context.pop();
     this.templateStarts.pop();
 
     return {
@@ -1086,7 +1106,9 @@ Lexer.prototype = {
       value: value,
       startLine: startLine,
       startChar: startChar,
-      isUnclosed: false
+      isUnclosed: false,
+      depth: depth,
+      context: this.popContext()
     };
   },
 
@@ -1620,6 +1642,18 @@ Lexer.prototype = {
       if (token && token.startLine && token.startLine !== this.line) {
         obj.startLine = token.startLine;
       }
+      if (token && token.context) {
+        // Context of current token
+        obj.context = token.context;
+      }
+      if (token && token.depth) {
+        // Nested template depth
+        obj.depth = token.depth;
+      }
+      if (token && token.isUnclosed) {
+        // Mark token as unclosed string / template literal
+        obj.isUnclosed = token.isUnclosed;
+      }
 
       if (isProperty && obj.identifier) {
         obj.isProperty = isProperty;
@@ -1795,3 +1829,4 @@ Lexer.prototype = {
 };
 
 exports.Lexer = Lexer;
+exports.Context = Context;


Test patch:
diff --git a/tests/unit/core.js b/tests/unit/core.js
index 5d12e41f..6577bb86 100644
--- a/tests/unit/core.js
+++ b/tests/unit/core.js
@@ -847,8 +847,15 @@ exports.testES6TemplateLiterals = function (test) {
   TestRun(test)
     .addError(14, "Octal literals are not allowed in strict mode.")
     .addError(21, "Unclosed template literal.")
-    .addError(22, "Expected an identifier and instead saw '(end)'.")
-    .addError(22, "Missing semicolon.")
+    .test(src, { esnext: true });
+  test.done();
+};
+
+exports.testES6TaggedTemplateLiterals = function (test) {
+  var src = fs.readFileSync(__dirname + "/fixtures/es6-template-literal-tagged.js", "utf8");
+  TestRun(test)
+    .addError(16, "Octal literals are not allowed in strict mode.")
+    .addError(23, "Unclosed template literal.")
     .test(src, { esnext: true });
   test.done();
 };
@@ -864,6 +871,19 @@ exports.testES6TemplateLiteralsUnused = function (test) {
   test.done();
 };
 
+exports.testES6TaggedTemplateLiteralsUnused = function (test) {
+  var src = [
+    "function tag() {}",
+    "var a = 'hello';",
+    "alert(tag`${a} world`);"
+  ];
+  TestRun(test)
+    .test(src, { esnext: true, unused: true });
+
+  test.done();
+};
+
+
 exports.testES6TemplateLiteralsUndef = function (test) {
   var src = [
     "/* global alert */",
@@ -876,6 +896,21 @@ exports.testES6TemplateLiteralsUndef = function (test) {
   test.done();
 };
 
+
+exports.testES6TaggedTemplateLiteralsUndef = function (test) {
+  var src = [
+    "/* global alert */",
+    "alert(tag`${a} world`);"
+  ];
+  TestRun(test)
+    .addError(2, "'tag' is not defined.")
+    .addError(2, "'a' is not defined.")
+    .test(src, { esnext: true, undef: true });
+
+  test.done();
+};
+
+
 exports.testES6TemplateLiteralMultiline = function (test) {
   var src = [
     'let multiline = `',
@@ -961,7 +996,35 @@ exports.testES6TemplateLiteralMultilineReturnValue = function (test) {
   test.done();
 };
 
-exports.tesMultilineReturnValueStringLiteral = function (test) {
+
+exports.testES6TaggedTemplateLiteralMultilineReturnValue = function (test) {
+  var src = [
+    'function tag() {}',
+    'function sayHello(to) {',
+    '  return tag`Hello, ',
+    '    ${to}!`;',
+    '}',
+    'print(sayHello("George"));'
+  ];
+
+  TestRun(test).test(src, { esnext: true });
+
+  var src = [
+    'function tag() {}',
+    'function* sayHello(to) {',
+    '  yield tag`Hello, ',
+    '    ${to}!`;',
+    '}',
+    'print(sayHello("George"));'
+  ];
+
+  TestRun(test).test(src, { esnext: true });
+
+  test.done();
+};
+
+
+exports.testMultilineReturnValueStringLiteral = function (test) {
   var src = [
     'function sayHello(to) {',
     '  return "Hello, \\',
diff --git a/tests/unit/fixtures/es6-template-literal-tagged.js b/tests/unit/fixtures/es6-template-literal-tagged.js
new file mode 100644
index 00000000..d0cc007d
--- /dev/null
+++ b/tests/unit/fixtures/es6-template-literal-tagged.js
@@ -0,0 +1,23 @@
+function tag() {}
+
+var one = 1, two = 2;
+
+var string = tag`  ${one} + ${two}
+= ${one + two}`;
+
+var literal = tag`because I
+can`;
+
+var escaped = tag`one = \`${one}\``;
+
+function octal_strictmode() {
+  "use strict";
+
+  var test = tag`\033\t`;
+}
+
+var nested = tag`Look and ${ tag`Nested ${ tag`whoaaa` } template` } listen`;
+
+var innerobj = tag`Template with ${ {obj: "literal"} } inside`;
+
+var unterminated = tag`${one}