1'use strict';
2
3var assert = require('assert');
4var TokenStream = require('token-stream');
5var error = require('pug-error');
6var inlineTags = require('./lib/inline-tags');
7
8module.exports = parse;
9module.exports.Parser = Parser;
10function parse(tokens, options) {
11  var parser = new Parser(tokens, options);
12  var ast = parser.parse();
13  return JSON.parse(JSON.stringify(ast));
14};
15
16/**
17 * Initialize `Parser` with the given input `str` and `filename`.
18 *
19 * @param {String} str
20 * @param {String} filename
21 * @param {Object} options
22 * @api public
23 */
24
25function Parser(tokens, options) {
26  options = options || {};
27  if (!Array.isArray(tokens)) {
28    throw new Error('Expected tokens to be an Array but got "' + (typeof tokens) + '"');
29  }
30  if (typeof options !== 'object') {
31    throw new Error('Expected "options" to be an object but got "' + (typeof options) + '"');
32  }
33  this.tokens = new TokenStream(tokens);
34  this.filename = options.filename;
35  this.src = options.src;
36  this.inMixin = 0;
37  this.plugins = options.plugins || [];
38};
39
40/**
41 * Parser prototype.
42 */
43
44Parser.prototype = {
45
46  /**
47   * Save original constructor
48   */
49
50  constructor: Parser,
51
52  error: function (code, message, token) {
53    var err = error(code, message, {
54      line: token.loc.start.line,
55      column: token.loc.start.column,
56      filename: this.filename,
57      src: this.src
58    });
59    throw err;
60  },
61
62  /**
63   * Return the next token object.
64   *
65   * @return {Object}
66   * @api private
67   */
68
69  advance: function(){
70    return this.tokens.advance();
71  },
72
73  /**
74   * Single token lookahead.
75   *
76   * @return {Object}
77   * @api private
78   */
79
80  peek: function() {
81    return this.tokens.peek();
82  },
83
84  /**
85   * `n` token lookahead.
86   *
87   * @param {Number} n
88   * @return {Object}
89   * @api private
90   */
91
92  lookahead: function(n){
93    return this.tokens.lookahead(n);
94  },
95
96  /**
97   * Parse input returning a string of js for evaluation.
98   *
99   * @return {String}
100   * @api public
101   */
102
103  parse: function(){
104    var block = this.emptyBlock(0);
105
106    while ('eos' != this.peek().type) {
107      if ('newline' == this.peek().type) {
108        this.advance();
109      } else if ('text-html' == this.peek().type) {
110        block.nodes = block.nodes.concat(this.parseTextHtml());
111      } else {
112        var expr = this.parseExpr();
113        if (expr) {
114          if (expr.type === 'Block') {
115            block.nodes = block.nodes.concat(expr.nodes);
116          } else {
117            block.nodes.push(expr);
118          }
119        }
120      }
121    }
122
123    return block;
124  },
125
126  /**
127   * Expect the given type, or throw an exception.
128   *
129   * @param {String} type
130   * @api private
131   */
132
133  expect: function(type){
134    if (this.peek().type === type) {
135      return this.advance();
136    } else {
137      this.error('INVALID_TOKEN', 'expected "' + type + '", but got "' + this.peek().type + '"', this.peek());
138    }
139  },
140
141  /**
142   * Accept the given `type`.
143   *
144   * @param {String} type
145   * @api private
146   */
147
148  accept: function(type){
149    if (this.peek().type === type) {
150      return this.advance();
151    }
152  },
153
154  initBlock: function(line, nodes) {
155    /* istanbul ignore if */
156    if ((line | 0) !== line) throw new Error('`line` is not an integer');
157    /* istanbul ignore if */
158    if (!Array.isArray(nodes)) throw new Error('`nodes` is not an array');
159    return {
160      type: 'Block',
161      nodes: nodes,
162      line: line,
163      filename: this.filename
164    };
165  },
166
167  emptyBlock: function(line) {
168    return this.initBlock(line, []);
169  },
170
171  runPlugin: function(context, tok) {
172    var rest = [this];
173    for (var i = 2; i < arguments.length; i++) {
174      rest.push(arguments[i]);
175    }
176    var pluginContext;
177    for (var i = 0; i < this.plugins.length; i++) {
178      var plugin = this.plugins[i];
179      if (plugin[context] && plugin[context][tok.type]) {
180        if (pluginContext) throw new Error('Multiple plugin handlers found for context ' + JSON.stringify(context) + ', token type ' + JSON.stringify(tok.type));
181        pluginContext = plugin[context];
182      }
183    }
184    if (pluginContext) return pluginContext[tok.type].apply(pluginContext, rest);
185  },
186
187  /**
188   *   tag
189   * | doctype
190   * | mixin
191   * | include
192   * | filter
193   * | comment
194   * | text
195   * | text-html
196   * | dot
197   * | each
198   * | code
199   * | yield
200   * | id
201   * | class
202   * | interpolation
203   */
204
205  parseExpr: function(){
206    switch (this.peek().type) {
207      case 'tag':
208        return this.parseTag();
209      case 'mixin':
210        return this.parseMixin();
211      case 'block':
212        return this.parseBlock();
213      case 'mixin-block':
214        return this.parseMixinBlock();
215      case 'case':
216        return this.parseCase();
217      case 'extends':
218        return this.parseExtends();
219      case 'include':
220        return this.parseInclude();
221      case 'doctype':
222        return this.parseDoctype();
223      case 'filter':
224        return this.parseFilter();
225      case 'comment':
226        return this.parseComment();
227      case 'text':
228      case 'interpolated-code':
229      case 'start-pug-interpolation':
230        return this.parseText({block: true});
231      case 'text-html':
232        return this.initBlock(this.peek().loc.start.line, this.parseTextHtml());
233      case 'dot':
234        return this.parseDot();
235      case 'each':
236        return this.parseEach();
237      case 'code':
238        return this.parseCode();
239      case 'blockcode':
240        return this.parseBlockCode();
241      case 'if':
242        return this.parseConditional();
243      case 'while':
244        return this.parseWhile();
245      case 'call':
246        return this.parseCall();
247      case 'interpolation':
248        return this.parseInterpolation();
249      case 'yield':
250        return this.parseYield();
251      case 'id':
252      case 'class':
253        if (!this.peek().loc.start) debugger;
254        this.tokens.defer({
255          type: 'tag',
256          val: 'div',
257          loc: this.peek().loc,
258          filename: this.filename
259        });
260        return this.parseExpr();
261      default:
262        var pluginResult = this.runPlugin('expressionTokens', this.peek());
263        if (pluginResult) return pluginResult;
264        this.error('INVALID_TOKEN', 'unexpected token "' + this.peek().type + '"', this.peek());
265    }
266  },
267
268  parseDot: function() {
269    this.advance();
270    return this.parseTextBlock();
271  },
272
273  /**
274   * Text
275   */
276
277  parseText: function(options){
278    var tags = [];
279    var lineno = this.peek().loc.start.line;
280    var nextTok = this.peek();
281    loop:
282      while (true) {
283        switch (nextTok.type) {
284          case 'text':
285            var tok = this.advance();
286            tags.push({
287              type: 'Text',
288              val: tok.val,
289              line: tok.loc.start.line,
290              column: tok.loc.start.column,
291              filename: this.filename
292            });
293            break;
294          case 'interpolated-code':
295            var tok = this.advance();
296            tags.push({
297              type: 'Code',
298              val: tok.val,
299              buffer: tok.buffer,
300              mustEscape: tok.mustEscape !== false,
301              isInline: true,
302              line: tok.loc.start.line,
303              column: tok.loc.start.column,
304              filename: this.filename
305            });
306            break;
307          case 'newline':
308            if (!options || !options.block) break loop;
309            var tok = this.advance();
310            var nextType = this.peek().type;
311            if (nextType === 'text' || nextType === 'interpolated-code') {
312              tags.push({
313                type: 'Text',
314                val: '\n',
315                line: tok.loc.start.line,
316                column: tok.loc.start.column,
317                filename: this.filename
318              });
319            }
320            break;
321          case 'start-pug-interpolation':
322            this.advance();
323            tags.push(this.parseExpr());
324            this.expect('end-pug-interpolation');
325            break;
326          default:
327            var pluginResult = this.runPlugin('textTokens', nextTok, tags);
328            if (pluginResult) break;
329            break loop;
330        }
331        nextTok = this.peek();
332      }
333    if (tags.length === 1) return tags[0];
334    else return this.initBlock(lineno, tags);
335  },
336
337  parseTextHtml: function () {
338    var nodes = [];
339    var currentNode = null;
340loop:
341    while (true) {
342      switch (this.peek().type) {
343        case 'text-html':
344          var text = this.advance();
345          if (!currentNode) {
346            currentNode = {
347              type: 'Text',
348              val: text.val,
349              filename: this.filename,
350              line: text.loc.start.line,
351              column: text.loc.start.column,
352              isHtml: true
353            };
354            nodes.push(currentNode);
355          } else {
356            currentNode.val += '\n' + text.val;
357          }
358          break;
359        case 'indent':
360          var block = this.block();
361          block.nodes.forEach(function (node) {
362            if (node.isHtml) {
363              if (!currentNode) {
364                currentNode = node;
365                nodes.push(currentNode);
366              } else {
367                currentNode.val += '\n' + node.val;
368              }
369            } else {
370              currentNode = null;
371              nodes.push(node);
372            }
373          });
374          break;
375        case 'code':
376          currentNode = null;
377          nodes.push(this.parseCode(true));
378          break;
379        case 'newline':
380          this.advance();
381          break;
382        default:
383          break loop;
384      }
385    }
386    return nodes;
387  },
388
389  /**
390   *   ':' expr
391   * | block
392   */
393
394  parseBlockExpansion: function(){
395    var tok = this.accept(':');
396    if (tok) {
397      var expr = this.parseExpr();
398      return expr.type === 'Block' ? expr : this.initBlock(tok.loc.start.line, [expr]);
399    } else {
400      return this.block();
401    }
402  },
403
404  /**
405   * case
406   */
407
408  parseCase: function(){
409    var tok = this.expect('case');
410    var node = {
411      type: 'Case',
412      expr: tok.val,
413      line: tok.loc.start.line,
414      column: tok.loc.start.column,
415      filename: this.filename
416    };
417
418    var block = this.emptyBlock(tok.loc.start.line + 1);
419    this.expect('indent');
420    while ('outdent' != this.peek().type) {
421      switch (this.peek().type) {
422        case 'comment':
423        case 'newline':
424          this.advance();
425          break;
426        case 'when':
427          block.nodes.push(this.parseWhen());
428          break;
429        case 'default':
430          block.nodes.push(this.parseDefault());
431          break;
432        default:
433          var pluginResult = this.runPlugin('caseTokens', this.peek(), block);
434          if (pluginResult) break;
435          this.error('INVALID_TOKEN', 'Unexpected token "' + this.peek().type
436                          + '", expected "when", "default" or "newline"', this.peek());
437      }
438    }
439    this.expect('outdent');
440
441    node.block = block;
442
443    return node;
444  },
445
446  /**
447   * when
448   */
449
450  parseWhen: function(){
451    var tok = this.expect('when');
452    if (this.peek().type !== 'newline') {
453      return {
454        type: 'When',
455        expr: tok.val,
456        block: this.parseBlockExpansion(),
457        debug: false,
458        line: tok.loc.start.line,
459        column: tok.loc.start.column,
460        filename: this.filename
461      };
462    } else {
463      return {
464        type: 'When',
465        expr: tok.val,
466        debug: false,
467        line: tok.loc.start.line,
468        column: tok.loc.start.column,
469        filename: this.filename
470      };
471    }
472  },
473
474  /**
475   * default
476   */
477
478  parseDefault: function(){
479    var tok = this.expect('default');
480    return {
481      type: 'When',
482      expr: 'default',
483      block: this.parseBlockExpansion(),
484      debug: false,
485      line: tok.loc.start.line,
486      column: tok.loc.start.column,
487      filename: this.filename
488    };
489  },
490
491  /**
492   * code
493   */
494
495  parseCode: function(noBlock){
496    var tok = this.expect('code');
497    assert(typeof tok.mustEscape === 'boolean', 'Please update to the newest version of pug-lexer.');
498    var node = {
499      type: 'Code',
500      val: tok.val,
501      buffer: tok.buffer,
502      mustEscape: tok.mustEscape !== false,
503      isInline: !!noBlock,
504      line: tok.loc.start.line,
505      column: tok.loc.start.column,
506      filename: this.filename
507    };
508    // todo: why is this here?  It seems like a hacky workaround
509    if (node.val.match(/^ *else/)) node.debug = false;
510
511    if (noBlock) return node;
512
513    var block;
514
515    // handle block
516    block = 'indent' == this.peek().type;
517    if (block) {
518      if (tok.buffer) {
519        this.error('BLOCK_IN_BUFFERED_CODE', 'Buffered code cannot have a block attached to it', this.peek());
520      }
521      node.block = this.block();
522    }
523
524    return node;
525  },
526  parseConditional: function(){
527    var tok = this.expect('if');
528    var node = {
529      type: 'Conditional',
530      test: tok.val,
531      consequent: this.emptyBlock(tok.loc.start.line),
532      alternate: null,
533      line: tok.loc.start.line,
534      column: tok.loc.start.column,
535      filename: this.filename
536    };
537
538    // handle block
539    if ('indent' == this.peek().type) {
540      node.consequent = this.block();
541    }
542
543    var currentNode = node;
544    while (true) {
545      if (this.peek().type === 'newline') {
546        this.expect('newline');
547      } else if (this.peek().type === 'else-if') {
548        tok = this.expect('else-if');
549        currentNode = (
550          currentNode.alternate = {
551            type: 'Conditional',
552            test: tok.val,
553            consequent: this.emptyBlock(tok.loc.start.line),
554            alternate: null,
555            line: tok.loc.start.line,
556            column: tok.loc.start.column,
557            filename: this.filename
558          }
559        );
560        if ('indent' == this.peek().type) {
561          currentNode.consequent = this.block();
562        }
563      } else if (this.peek().type === 'else') {
564        this.expect('else');
565        if (this.peek().type === 'indent') {
566          currentNode.alternate = this.block();
567        }
568        break;
569      } else {
570        break;
571      }
572    }
573
574    return node;
575  },
576  parseWhile: function(){
577    var tok = this.expect('while');
578    var node = {
579      type: 'While',
580      test: tok.val,
581      line: tok.loc.start.line,
582      column: tok.loc.start.column,
583      filename: this.filename
584    };
585
586    // handle block
587    if ('indent' == this.peek().type) {
588      node.block = this.block();
589    } else {
590      node.block = this.emptyBlock(tok.loc.start.line);
591    }
592
593    return node;
594  },
595
596  /**
597   * block code
598   */
599
600  parseBlockCode: function(){
601    var tok = this.expect('blockcode');
602    var line = tok.loc.start.line;
603    var column = tok.loc.start.column;
604    var body = this.peek();
605    var text = '';
606    if (body.type === 'start-pipeless-text') {
607      this.advance();
608      while (this.peek().type !== 'end-pipeless-text') {
609        tok = this.advance();
610        switch (tok.type) {
611          case 'text':
612            text += tok.val;
613            break;
614          case 'newline':
615            text += '\n';
616            break;
617          default:
618            var pluginResult = this.runPlugin('blockCodeTokens', tok, tok);
619            if (pluginResult) {
620              text += pluginResult;
621              break;
622            }
623            this.error('INVALID_TOKEN', 'Unexpected token type: ' + tok.type, tok);
624        }
625      }
626      this.advance();
627    }
628    return {
629      type: 'Code',
630      val: text,
631      buffer: false,
632      mustEscape: false,
633      isInline: false,
634      line: line,
635      column: column,
636      filename: this.filename
637    };
638  },
639  /**
640   * comment
641   */
642
643  parseComment: function(){
644    var tok = this.expect('comment');
645    var block;
646    if (block = this.parseTextBlock()) {
647      return {
648        type: 'BlockComment',
649        val: tok.val,
650        block: block,
651        buffer: tok.buffer,
652        line: tok.loc.start.line,
653        column: tok.loc.start.column,
654        filename: this.filename
655      };
656    } else {
657      return {
658        type: 'Comment',
659        val: tok.val,
660        buffer: tok.buffer,
661        line: tok.loc.start.line,
662        column: tok.loc.start.column,
663        filename: this.filename
664      };
665    }
666  },
667
668  /**
669   * doctype
670   */
671
672  parseDoctype: function(){
673    var tok = this.expect('doctype');
674    return {
675      type: 'Doctype',
676      val: tok.val,
677      line: tok.loc.start.line,
678      column: tok.loc.start.column,
679      filename: this.filename
680    };
681  },
682
683  parseIncludeFilter: function() {
684    var tok = this.expect('filter');
685    var attrs = [];
686
687    if (this.peek().type === 'start-attributes') {
688      attrs = this.attrs();
689    }
690
691    return {
692      type: 'IncludeFilter',
693      name: tok.val,
694      attrs: attrs,
695      line: tok.loc.start.line,
696      column: tok.loc.start.column,
697      filename: this.filename
698    };
699  },
700
701  /**
702   * filter attrs? text-block
703   */
704
705  parseFilter: function(){
706    var tok = this.expect('filter');
707    var block, attrs = [];
708
709    if (this.peek().type === 'start-attributes') {
710      attrs = this.attrs();
711    }
712
713    if (this.peek().type === 'text') {
714      var textToken = this.advance();
715      block = this.initBlock(textToken.loc.start.line, [
716        {
717          type: 'Text',
718          val: textToken.val,
719          line: textToken.loc.start.line,
720          column: textToken.loc.start.column,
721          filename: this.filename
722        }
723      ]);
724    } else if (this.peek().type === 'filter') {
725      block = this.initBlock(tok.loc.start.line, [this.parseFilter()]);
726    } else {
727      block = this.parseTextBlock() || this.emptyBlock(tok.loc.start.line);
728    }
729
730    return {
731      type: 'Filter',
732      name: tok.val,
733      block: block,
734      attrs: attrs,
735      line: tok.loc.start.line,
736      column: tok.loc.start.column,
737      filename: this.filename
738    };
739  },
740
741  /**
742   * each block
743   */
744
745  parseEach: function(){
746    var tok = this.expect('each');
747    var node = {
748      type: 'Each',
749      obj: tok.code,
750      val: tok.val,
751      key: tok.key,
752      block: this.block(),
753      line: tok.loc.start.line,
754      column: tok.loc.start.column,
755      filename: this.filename
756    };
757    if (this.peek().type == 'else') {
758      this.advance();
759      node.alternate = this.block();
760    }
761    return node;
762  },
763
764  /**
765   * 'extends' name
766   */
767
768  parseExtends: function(){
769    var tok = this.expect('extends');
770    var path = this.expect('path');
771    return {
772      type: 'Extends',
773      file: {
774        type: 'FileReference',
775        path: path.val.trim(),
776        line: path.loc.start.line,
777        column: path.loc.start.column,
778        filename: this.filename
779      },
780      line: tok.loc.start.line,
781      column: tok.loc.start.column,
782      filename: this.filename
783    };
784  },
785
786  /**
787   * 'block' name block
788   */
789
790  parseBlock: function(){
791    var tok = this.expect('block');
792
793    var node = 'indent' == this.peek().type ? this.block() : this.emptyBlock(tok.loc.start.line);
794    node.type = 'NamedBlock';
795    node.name = tok.val.trim();
796    node.mode = tok.mode;
797    node.line = tok.loc.start.line;
798    node.column = tok.loc.start.column;
799
800    return node;
801  },
802
803  parseMixinBlock: function () {
804    var tok = this.expect('mixin-block');
805    if (!this.inMixin) {
806      this.error('BLOCK_OUTISDE_MIXIN', 'Anonymous blocks are not allowed unless they are part of a mixin.', tok);
807    }
808    return {
809      type: 'MixinBlock',
810      line: tok.loc.start.line,
811      column: tok.loc.start.column,
812      filename: this.filename
813    };
814  },
815
816  parseYield: function() {
817    var tok = this.expect('yield');
818    return {
819      type: 'YieldBlock',
820      line: tok.loc.start.line,
821      column: tok.loc.start.column,
822      filename: this.filename
823    };
824  },
825
826  /**
827   * include block?
828   */
829
830  parseInclude: function(){
831    var tok = this.expect('include');
832    var node = {
833      type: 'Include',
834      file: {
835        type: 'FileReference',
836        filename: this.filename
837      },
838      line: tok.loc.start.line,
839      column: tok.loc.start.column,
840      filename: this.filename
841    };
842    var filters = [];
843    while (this.peek().type === 'filter') {
844      filters.push(this.parseIncludeFilter());
845    }
846    var path = this.expect('path');
847
848    node.file.path = path.val.trim();
849    node.file.line = path.loc.start.line;
850    node.file.column = path.loc.start.column;
851
852    if ((/\.jade$/.test(node.file.path) || /\.pug$/.test(node.file.path)) && !filters.length) {
853      node.block = 'indent' == this.peek().type ? this.block() : this.emptyBlock(tok.loc.start.line);
854      if (/\.jade$/.test(node.file.path)) {
855        console.warn(
856          this.filename + ', line ' + tok.loc.start.line +
857          ':\nThe .jade extension is deprecated, use .pug for "' + node.file.path +'".'
858        );
859      }
860    } else {
861      node.type = 'RawInclude';
862      node.filters = filters;
863      if (this.peek().type === 'indent') {
864        this.error('RAW_INCLUDE_BLOCK', 'Raw inclusion cannot contain a block', this.peek());
865      }
866    }
867    return node;
868  },
869
870  /**
871   * call ident block
872   */
873
874  parseCall: function(){
875    var tok = this.expect('call');
876    var name = tok.val;
877    var args = tok.args;
878    var mixin = {
879      type: 'Mixin',
880      name: name,
881      args: args,
882      block: this.emptyBlock(tok.loc.start.line),
883      call: true,
884      attrs: [],
885      attributeBlocks: [],
886      line: tok.loc.start.line,
887      column: tok.loc.start.column,
888      filename: this.filename
889    };
890
891    this.tag(mixin);
892    if (mixin.code) {
893      mixin.block.nodes.push(mixin.code);
894      delete mixin.code;
895    }
896    if (mixin.block.nodes.length === 0) mixin.block = null;
897    return mixin;
898  },
899
900  /**
901   * mixin block
902   */
903
904  parseMixin: function(){
905    var tok = this.expect('mixin');
906    var name = tok.val;
907    var args = tok.args;
908
909    if ('indent' == this.peek().type) {
910      this.inMixin++;
911      var mixin = {
912        type: 'Mixin',
913        name: name,
914        args: args,
915        block: this.block(),
916        call: false,
917        line: tok.loc.start.line,
918        column: tok.loc.start.column,
919        filename: this.filename
920      };
921      this.inMixin--;
922      return mixin;
923    } else {
924      this.error('MIXIN_WITHOUT_BODY', 'Mixin ' + name + ' declared without body', tok);
925    }
926  },
927
928  /**
929   * indent (text | newline)* outdent
930   */
931
932  parseTextBlock: function(){
933    var tok = this.accept('start-pipeless-text');
934    if (!tok) return;
935    var block = this.emptyBlock(tok.loc.start.line);
936    while (this.peek().type !== 'end-pipeless-text') {
937      var tok = this.advance();
938      switch (tok.type) {
939        case 'text':
940          block.nodes.push({
941            type: 'Text',
942            val: tok.val,
943            line: tok.loc.start.line,
944            column: tok.loc.start.column,
945            filename: this.filename
946          });
947          break;
948        case 'newline':
949          block.nodes.push({
950            type: 'Text',
951            val: '\n',
952            line: tok.loc.start.line,
953            column: tok.loc.start.column,
954            filename: this.filename
955          });
956          break;
957        case 'start-pug-interpolation':
958          block.nodes.push(this.parseExpr());
959          this.expect('end-pug-interpolation');
960          break;
961        case 'interpolated-code':
962          block.nodes.push({
963            type: 'Code',
964            val: tok.val,
965            buffer: tok.buffer,
966            mustEscape: tok.mustEscape !== false,
967            isInline: true,
968            line: tok.loc.start.line,
969            column: tok.loc.start.column,
970            filename: this.filename
971          });
972          break;
973        default:
974          var pluginResult = this.runPlugin('textBlockTokens', tok, block, tok);
975          if (pluginResult) break;
976          this.error('INVALID_TOKEN', 'Unexpected token type: ' + tok.type, tok);
977      }
978    }
979    this.advance();
980    return block;
981  },
982
983  /**
984   * indent expr* outdent
985   */
986
987  block: function(){
988    var tok = this.expect('indent');
989    var block = this.emptyBlock(tok.loc.start.line);
990    while ('outdent' != this.peek().type) {
991      if ('newline' == this.peek().type) {
992        this.advance();
993      } else if ('text-html' == this.peek().type) {
994        block.nodes = block.nodes.concat(this.parseTextHtml());
995      } else {
996        var expr = this.parseExpr();
997        if (expr.type === 'Block') {
998          block.nodes = block.nodes.concat(expr.nodes);
999        } else {
1000          block.nodes.push(expr);
1001        }
1002      }
1003    }
1004    this.expect('outdent');
1005    return block;
1006  },
1007
1008  /**
1009   * interpolation (attrs | class | id)* (text | code | ':')? newline* block?
1010   */
1011
1012  parseInterpolation: function(){
1013    var tok = this.advance();
1014    var tag = {
1015      type: 'InterpolatedTag',
1016      expr: tok.val,
1017      selfClosing: false,
1018      block: this.emptyBlock(tok.loc.start.line),
1019      attrs: [],
1020      attributeBlocks: [],
1021      isInline: false,
1022      line: tok.loc.start.line,
1023      column: tok.loc.start.column,
1024      filename: this.filename
1025    };
1026
1027    return this.tag(tag, {selfClosingAllowed: true});
1028  },
1029
1030  /**
1031   * tag (attrs | class | id)* (text | code | ':')? newline* block?
1032   */
1033
1034  parseTag: function(){
1035    var tok = this.advance();
1036    var tag = {
1037      type: 'Tag',
1038      name: tok.val,
1039      selfClosing: false,
1040      block: this.emptyBlock(tok.loc.start.line),
1041      attrs: [],
1042      attributeBlocks: [],
1043      isInline: inlineTags.indexOf(tok.val) !== -1,
1044      line: tok.loc.start.line,
1045      column: tok.loc.start.column,
1046      filename: this.filename
1047    };
1048
1049    return this.tag(tag, {selfClosingAllowed: true});
1050  },
1051
1052  /**
1053   * Parse tag.
1054   */
1055
1056  tag: function(tag, options) {
1057    var seenAttrs = false;
1058    var attributeNames = [];
1059    var selfClosingAllowed = options && options.selfClosingAllowed;
1060    // (attrs | class | id)*
1061    out:
1062      while (true) {
1063        switch (this.peek().type) {
1064          case 'id':
1065          case 'class':
1066            var tok = this.advance();
1067            if (tok.type === 'id') {
1068              if (attributeNames.indexOf('id') !== -1) {
1069                this.error('DUPLICATE_ID', 'Duplicate attribute "id" is not allowed.', tok);
1070              }
1071              attributeNames.push('id');
1072            }
1073            tag.attrs.push({
1074              name: tok.type,
1075              val: "'" + tok.val + "'",
1076              line: tok.loc.start.line,
1077              column: tok.loc.start.column,
1078              filename: this.filename,
1079              mustEscape: false
1080            });
1081            continue;
1082          case 'start-attributes':
1083            if (seenAttrs) {
1084              console.warn(this.filename + ', line ' + this.peek().loc.start.line + ':\nYou should not have pug tags with multiple attributes.');
1085            }
1086            seenAttrs = true;
1087            tag.attrs = tag.attrs.concat(this.attrs(attributeNames));
1088            continue;
1089          case '&attributes':
1090            var tok = this.advance();
1091            tag.attributeBlocks.push({
1092              type: 'AttributeBlock',
1093              val: tok.val,
1094              line: tok.loc.start.line,
1095              column: tok.loc.start.column,
1096              filename: this.filename
1097            });
1098            break;
1099          default:
1100            var pluginResult = this.runPlugin('tagAttributeTokens', this.peek(), tag, attributeNames);
1101            if (pluginResult) break;
1102            break out;
1103        }
1104      }
1105
1106    // check immediate '.'
1107    if ('dot' == this.peek().type) {
1108      tag.textOnly = true;
1109      this.advance();
1110    }
1111
1112    // (text | code | ':')?
1113    switch (this.peek().type) {
1114      case 'text':
1115      case 'interpolated-code':
1116        var text = this.parseText();
1117        if (text.type === 'Block') {
1118          tag.block.nodes.push.apply(tag.block.nodes, text.nodes);
1119        } else {
1120          tag.block.nodes.push(text);
1121        }
1122        break;
1123      case 'code':
1124        tag.block.nodes.push(this.parseCode(true));
1125        break;
1126      case ':':
1127        this.advance();
1128        var expr = this.parseExpr();
1129        tag.block = expr.type === 'Block' ? expr : this.initBlock(tag.line, [expr]);
1130        break;
1131      case 'newline':
1132      case 'indent':
1133      case 'outdent':
1134      case 'eos':
1135      case 'start-pipeless-text':
1136      case 'end-pug-interpolation':
1137        break;
1138      case 'slash':
1139        if (selfClosingAllowed) {
1140          this.advance();
1141          tag.selfClosing = true;
1142          break;
1143        }
1144      default:
1145        var pluginResult = this.runPlugin('tagTokens', this.peek(), tag, options);
1146        if (pluginResult) break;
1147        this.error('INVALID_TOKEN', 'Unexpected token `' + this.peek().type + '` expected `text`, `interpolated-code`, `code`, `:`' + (selfClosingAllowed ? ', `slash`' : '') + ', `newline` or `eos`', this.peek())
1148    }
1149
1150    // newline*
1151    while ('newline' == this.peek().type) this.advance();
1152
1153    // block?
1154    if (tag.textOnly) {
1155      tag.block = this.parseTextBlock() || this.emptyBlock(tag.line);
1156    } else if ('indent' == this.peek().type) {
1157      var block = this.block();
1158      for (var i = 0, len = block.nodes.length; i < len; ++i) {
1159        tag.block.nodes.push(block.nodes[i]);
1160      }
1161    }
1162
1163    return tag;
1164  },
1165
1166  attrs: function(attributeNames) {
1167    this.expect('start-attributes');
1168
1169    var attrs = [];
1170    var tok = this.advance();
1171    while (tok.type === 'attribute') {
1172      if (tok.name !== 'class' && attributeNames) {
1173        if (attributeNames.indexOf(tok.name) !== -1) {
1174          this.error('DUPLICATE_ATTRIBUTE', 'Duplicate attribute "' + tok.name + '" is not allowed.', tok);
1175        }
1176        attributeNames.push(tok.name);
1177      }
1178      attrs.push({
1179        name: tok.name,
1180        val: tok.val,
1181        line: tok.loc.start.line,
1182        column: tok.loc.start.column,
1183        filename: this.filename,
1184        mustEscape: tok.mustEscape !== false
1185      });
1186      tok = this.advance();
1187    }
1188    this.tokens.defer(tok);
1189    this.expect('end-attributes');
1190    return attrs;
1191  }
1192};