'use strict'; var assert = require('assert'); var TokenStream = require('token-stream'); var error = require('pug-error'); var inlineTags = require('./lib/inline-tags'); module.exports = parse; module.exports.Parser = Parser; function parse(tokens, options) { var parser = new Parser(tokens, options); var ast = parser.parse(); return JSON.parse(JSON.stringify(ast)); }; /** * Initialize `Parser` with the given input `str` and `filename`. * * @param {String} str * @param {String} filename * @param {Object} options * @api public */ function Parser(tokens, options) { options = options || {}; if (!Array.isArray(tokens)) { throw new Error('Expected tokens to be an Array but got "' + (typeof tokens) + '"'); } if (typeof options !== 'object') { throw new Error('Expected "options" to be an object but got "' + (typeof options) + '"'); } this.tokens = new TokenStream(tokens); this.filename = options.filename; this.src = options.src; this.inMixin = 0; this.plugins = options.plugins || []; }; /** * Parser prototype. */ Parser.prototype = { /** * Save original constructor */ constructor: Parser, error: function (code, message, token) { var err = error(code, message, { line: token.line, column: token.col, filename: this.filename, src: this.src }); throw err; }, /** * Return the next token object. * * @return {Object} * @api private */ advance: function(){ return this.tokens.advance(); }, /** * Single token lookahead. * * @return {Object} * @api private */ peek: function() { return this.tokens.peek(); }, /** * `n` token lookahead. * * @param {Number} n * @return {Object} * @api private */ lookahead: function(n){ return this.tokens.lookahead(n); }, /** * Parse input returning a string of js for evaluation. * * @return {String} * @api public */ parse: function(){ var block = this.emptyBlock(0); while ('eos' != this.peek().type) { if ('newline' == this.peek().type) { this.advance(); } else if ('text-html' == this.peek().type) { block.nodes = block.nodes.concat(this.parseTextHtml()); } else { var expr = this.parseExpr(); if (expr) { if (expr.type === 'Block') { block.nodes = block.nodes.concat(expr.nodes); } else { block.nodes.push(expr); } } } } return block; }, /** * Expect the given type, or throw an exception. * * @param {String} type * @api private */ expect: function(type){ if (this.peek().type === type) { return this.advance(); } else { this.error('INVALID_TOKEN', 'expected "' + type + '", but got "' + this.peek().type + '"', this.peek()); } }, /** * Accept the given `type`. * * @param {String} type * @api private */ accept: function(type){ if (this.peek().type === type) { return this.advance(); } }, initBlock: function(line, nodes) { /* istanbul ignore if */ if ((line | 0) !== line) throw new Error('`line` is not an integer'); /* istanbul ignore if */ if (!Array.isArray(nodes)) throw new Error('`nodes` is not an array'); return { type: 'Block', nodes: nodes, line: line, filename: this.filename }; }, emptyBlock: function(line) { return this.initBlock(line, []); }, runPlugin: function(context, tok) { var rest = [this]; for (var i = 2; i < arguments.length; i++) { rest.push(arguments[i]); } var pluginContext; for (var i = 0; i < this.plugins.length; i++) { var plugin = this.plugins[i]; if (plugin[context] && plugin[context][tok.type]) { if (pluginContext) throw new Error('Multiple plugin handlers found for context ' + JSON.stringify(context) + ', token type ' + JSON.stringify(tok.type)); pluginContext = plugin[context]; } } if (pluginContext) return pluginContext[tok.type].apply(pluginContext, rest); }, /** * tag * | doctype * | mixin * | include * | filter * | comment * | text * | text-html * | dot * | each * | code * | yield * | id * | class * | interpolation */ parseExpr: function(){ switch (this.peek().type) { case 'tag': return this.parseTag(); case 'mixin': return this.parseMixin(); case 'block': return this.parseBlock(); case 'mixin-block': return this.parseMixinBlock(); case 'case': return this.parseCase(); case 'extends': return this.parseExtends(); case 'include': return this.parseInclude(); case 'doctype': return this.parseDoctype(); case 'filter': return this.parseFilter(); case 'comment': return this.parseComment(); case 'text': case 'interpolated-code': case 'start-pug-interpolation': return this.parseText({block: true}); case 'text-html': return this.initBlock(this.peek().line, this.parseTextHtml()); case 'dot': return this.parseDot(); case 'each': return this.parseEach(); case 'code': return this.parseCode(); case 'blockcode': return this.parseBlockCode(); case 'if': return this.parseConditional(); case 'while': return this.parseWhile(); case 'call': return this.parseCall(); case 'interpolation': return this.parseInterpolation(); case 'yield': return this.parseYield(); case 'id': case 'class': this.tokens.defer({ type: 'tag', val: 'div', line: this.peek().line, col: this.peek().col, filename: this.filename }); return this.parseExpr(); default: var pluginResult = this.runPlugin('expressionTokens', this.peek()); if (pluginResult) return pluginResult; this.error('INVALID_TOKEN', 'unexpected token "' + this.peek().type + '"', this.peek()); } }, parseDot: function() { this.advance(); return this.parseTextBlock(); }, /** * Text */ parseText: function(options){ var tags = []; var lineno = this.peek().line; var nextTok = this.peek(); loop: while (true) { switch (nextTok.type) { case 'text': var tok = this.advance(); tags.push({ type: 'Text', val: tok.val, line: tok.line, column: tok.col, filename: this.filename }); break; case 'interpolated-code': var tok = this.advance(); tags.push({ type: 'Code', val: tok.val, buffer: tok.buffer, mustEscape: tok.mustEscape !== false, isInline: true, line: tok.line, column: tok.col, filename: this.filename }); break; case 'newline': if (!options || !options.block) break loop; var tok = this.advance(); var nextType = this.peek().type; if (nextType === 'text' || nextType === 'interpolated-code') { tags.push({ type: 'Text', val: '\n', line: tok.line, column: tok.col, filename: this.filename }); } break; case 'start-pug-interpolation': this.advance(); tags.push(this.parseExpr()); this.expect('end-pug-interpolation'); break; default: var pluginResult = this.runPlugin('textTokens', nextTok, tags); if (pluginResult) break; break loop; } nextTok = this.peek(); } if (tags.length === 1) return tags[0]; else return this.initBlock(lineno, tags); }, parseTextHtml: function () { var nodes = []; var currentNode = null; loop: while (true) { switch (this.peek().type) { case 'text-html': var text = this.advance(); if (!currentNode) { currentNode = { type: 'Text', val: text.val, filename: this.filename, line: text.line, column: text.col, isHtml: true }; nodes.push(currentNode); } else { currentNode.val += '\n' + text.val; } break; case 'indent': var block = this.block(); block.nodes.forEach(function (node) { if (node.isHtml) { if (!currentNode) { currentNode = node; nodes.push(currentNode); } else { currentNode.val += '\n' + node.val; } } else { currentNode = null; nodes.push(node); } }); break; case 'code': currentNode = null; nodes.push(this.parseCode(true)); break; case 'newline': this.advance(); break; default: break loop; } } return nodes; }, /** * ':' expr * | block */ parseBlockExpansion: function(){ var tok = this.accept(':'); if (tok) { var expr = this.parseExpr(); return expr.type === 'Block' ? expr : this.initBlock(tok.line, [expr]); } else { return this.block(); } }, /** * case */ parseCase: function(){ var tok = this.expect('case'); var node = { type: 'Case', expr: tok.val, line: tok.line, column: tok.col, filename: this.filename }; var block = this.emptyBlock(tok.line + 1); this.expect('indent'); while ('outdent' != this.peek().type) { switch (this.peek().type) { case 'comment': case 'newline': this.advance(); break; case 'when': block.nodes.push(this.parseWhen()); break; case 'default': block.nodes.push(this.parseDefault()); break; default: var pluginResult = this.runPlugin('caseTokens', this.peek(), block); if (pluginResult) break; this.error('INVALID_TOKEN', 'Unexpected token "' + this.peek().type + '", expected "when", "default" or "newline"', this.peek()); } } this.expect('outdent'); node.block = block; return node; }, /** * when */ parseWhen: function(){ var tok = this.expect('when'); if (this.peek().type !== 'newline') { return { type: 'When', expr: tok.val, block: this.parseBlockExpansion(), debug: false, line: tok.line, column: tok.col, filename: this.filename }; } else { return { type: 'When', expr: tok.val, debug: false, line: tok.line, column: tok.col, filename: this.filename }; } }, /** * default */ parseDefault: function(){ var tok = this.expect('default'); return { type: 'When', expr: 'default', block: this.parseBlockExpansion(), debug: false, line: tok.line, column: tok.col, filename: this.filename }; }, /** * code */ parseCode: function(noBlock){ var tok = this.expect('code'); assert(typeof tok.mustEscape === 'boolean', 'Please update to the newest version of pug-lexer.'); var node = { type: 'Code', val: tok.val, buffer: tok.buffer, mustEscape: tok.mustEscape !== false, isInline: !!noBlock, line: tok.line, column: tok.col, filename: this.filename }; // todo: why is this here? It seems like a hacky workaround if (node.val.match(/^ *else/)) node.debug = false; if (noBlock) return node; var block; // handle block block = 'indent' == this.peek().type; if (block) { if (tok.buffer) { this.error('BLOCK_IN_BUFFERED_CODE', 'Buffered code cannot have a block attached to it', this.peek()); } node.block = this.block(); } return node; }, parseConditional: function(){ var tok = this.expect('if'); var node = { type: 'Conditional', test: tok.val, consequent: this.emptyBlock(tok.line), alternate: null, line: tok.line, column: tok.col, filename: this.filename }; // handle block if ('indent' == this.peek().type) { node.consequent = this.block(); } var currentNode = node; while (true) { if (this.peek().type === 'newline') { this.expect('newline'); } else if (this.peek().type === 'else-if') { tok = this.expect('else-if'); currentNode = ( currentNode.alternate = { type: 'Conditional', test: tok.val, consequent: this.emptyBlock(tok.line), alternate: null, line: tok.line, column: tok.col, filename: this.filename } ); if ('indent' == this.peek().type) { currentNode.consequent = this.block(); } } else if (this.peek().type === 'else') { this.expect('else'); if (this.peek().type === 'indent') { currentNode.alternate = this.block(); } break; } else { break; } } return node; }, parseWhile: function(){ var tok = this.expect('while'); var node = { type: 'While', test: tok.val, line: tok.line, column: tok.col, filename: this.filename }; // handle block if ('indent' == this.peek().type) { node.block = this.block(); } else { node.block = this.emptyBlock(tok.line); } return node; }, /** * block code */ parseBlockCode: function(){ var tok = this.expect('blockcode'); var line = tok.line; var column = tok.col; var body = this.peek(); var text = ''; if (body.type === 'start-pipeless-text') { this.advance(); while (this.peek().type !== 'end-pipeless-text') { tok = this.advance(); switch (tok.type) { case 'text': text += tok.val; break; case 'newline': text += '\n'; break; default: var pluginResult = this.runPlugin('blockCodeTokens', tok, tok); if (pluginResult) { text += pluginResult; break; } this.error('INVALID_TOKEN', 'Unexpected token type: ' + tok.type, tok); } } this.advance(); } return { type: 'Code', val: text, buffer: false, mustEscape: false, isInline: false, line: line, column: column, filename: this.filename }; }, /** * comment */ parseComment: function(){ var tok = this.expect('comment'); var block; if (block = this.parseTextBlock()) { return { type: 'BlockComment', val: tok.val, block: block, buffer: tok.buffer, line: tok.line, column: tok.col, filename: this.filename }; } else { return { type: 'Comment', val: tok.val, buffer: tok.buffer, line: tok.line, column: tok.col, filename: this.filename }; } }, /** * doctype */ parseDoctype: function(){ var tok = this.expect('doctype'); return { type: 'Doctype', val: tok.val, line: tok.line, column: tok.col, filename: this.filename }; }, parseIncludeFilter: function() { var tok = this.expect('filter'); var attrs = []; if (this.peek().type === 'start-attributes') { attrs = this.attrs(); } return { type: 'IncludeFilter', name: tok.val, attrs: attrs, line: tok.line, column: tok.col, filename: this.filename }; }, /** * filter attrs? text-block */ parseFilter: function(){ var tok = this.expect('filter'); var block, attrs = []; if (this.peek().type === 'start-attributes') { attrs = this.attrs(); } if (this.peek().type === 'text') { var textToken = this.advance(); block = this.initBlock(textToken.line, [ { type: 'Text', val: textToken.val, line: textToken.line, column: textToken.col, filename: this.filename } ]); } else if (this.peek().type === 'filter') { block = this.initBlock(tok.line, [this.parseFilter()]); } else { block = this.parseTextBlock() || this.emptyBlock(tok.line); } return { type: 'Filter', name: tok.val, block: block, attrs: attrs, line: tok.line, column: tok.col, filename: this.filename }; }, /** * each block */ parseEach: function(){ var tok = this.expect('each'); var node = { type: 'Each', obj: tok.code, val: tok.val, key: tok.key, block: this.block(), line: tok.line, column: tok.col, filename: this.filename }; if (this.peek().type == 'else') { this.advance(); node.alternate = this.block(); } return node; }, /** * 'extends' name */ parseExtends: function(){ var tok = this.expect('extends'); var path = this.expect('path'); return { type: 'Extends', file: { type: 'FileReference', path: path.val.trim(), line: path.line, column: path.col, filename: this.filename }, line: tok.line, column: tok.col, filename: this.filename }; }, /** * 'block' name block */ parseBlock: function(){ var tok = this.expect('block'); var node = 'indent' == this.peek().type ? this.block() : this.emptyBlock(tok.line); node.type = 'NamedBlock'; node.name = tok.val.trim(); node.mode = tok.mode; node.line = tok.line; node.column = tok.col; return node; }, parseMixinBlock: function () { var tok = this.expect('mixin-block'); if (!this.inMixin) { this.error('BLOCK_OUTISDE_MIXIN', 'Anonymous blocks are not allowed unless they are part of a mixin.', tok); } return { type: 'MixinBlock', line: tok.line, column: tok.col, filename: this.filename }; }, parseYield: function() { var tok = this.expect('yield'); return { type: 'YieldBlock', line: tok.line, column: tok.col, filename: this.filename }; }, /** * include block? */ parseInclude: function(){ var tok = this.expect('include'); var node = { type: 'Include', file: { type: 'FileReference', filename: this.filename }, line: tok.line, column: tok.col, filename: this.filename }; var filters = []; while (this.peek().type === 'filter') { filters.push(this.parseIncludeFilter()); } var path = this.expect('path'); node.file.path = path.val.trim(); node.file.line = path.line; node.file.column = path.col; if ((/\.jade$/.test(node.file.path) || /\.pug$/.test(node.file.path)) && !filters.length) { node.block = 'indent' == this.peek().type ? this.block() : this.emptyBlock(tok.line); if (/\.jade$/.test(node.file.path)) { console.warn( this.filename + ', line ' + tok.line + ':\nThe .jade extension is deprecated, use .pug for "' + node.file.path +'".' ); } } else { node.type = 'RawInclude'; node.filters = filters; if (this.peek().type === 'indent') { this.error('RAW_INCLUDE_BLOCK', 'Raw inclusion cannot contain a block', this.peek()); } } return node; }, /** * call ident block */ parseCall: function(){ var tok = this.expect('call'); var name = tok.val; var args = tok.args; var mixin = { type: 'Mixin', name: name, args: args, block: this.emptyBlock(tok.line), call: true, attrs: [], attributeBlocks: [], line: tok.line, column: tok.col, filename: this.filename }; this.tag(mixin); if (mixin.code) { mixin.block.nodes.push(mixin.code); delete mixin.code; } if (mixin.block.nodes.length === 0) mixin.block = null; return mixin; }, /** * mixin block */ parseMixin: function(){ var tok = this.expect('mixin'); var name = tok.val; var args = tok.args; if ('indent' == this.peek().type) { this.inMixin++; var mixin = { type: 'Mixin', name: name, args: args, block: this.block(), call: false, line: tok.line, column: tok.col, filename: this.filename }; this.inMixin--; return mixin; } else { this.error('MIXIN_WITHOUT_BODY', 'Mixin ' + name + ' declared without body', tok); } }, /** * indent (text | newline)* outdent */ parseTextBlock: function(){ var tok = this.accept('start-pipeless-text'); if (!tok) return; var block = this.emptyBlock(tok.line); while (this.peek().type !== 'end-pipeless-text') { var tok = this.advance(); switch (tok.type) { case 'text': block.nodes.push({ type: 'Text', val: tok.val, line: tok.line, column: tok.col, filename: this.filename }); break; case 'newline': block.nodes.push({ type: 'Text', val: '\n', line: tok.line, column: tok.col, filename: this.filename }); break; case 'start-pug-interpolation': block.nodes.push(this.parseExpr()); this.expect('end-pug-interpolation'); break; case 'interpolated-code': block.nodes.push({ type: 'Code', val: tok.val, buffer: tok.buffer, mustEscape: tok.mustEscape !== false, isInline: true, line: tok.line, column: tok.col, filename: this.filename }); break; default: var pluginResult = this.runPlugin('textBlockTokens', tok, block, tok); if (pluginResult) break; this.error('INVALID_TOKEN', 'Unexpected token type: ' + tok.type, tok); } } this.advance(); return block; }, /** * indent expr* outdent */ block: function(){ var tok = this.expect('indent'); var block = this.emptyBlock(tok.line); while ('outdent' != this.peek().type) { if ('newline' == this.peek().type) { this.advance(); } else if ('text-html' == this.peek().type) { block.nodes = block.nodes.concat(this.parseTextHtml()); } else { var expr = this.parseExpr(); if (expr.type === 'Block') { block.nodes = block.nodes.concat(expr.nodes); } else { block.nodes.push(expr); } } } this.expect('outdent'); return block; }, /** * interpolation (attrs | class | id)* (text | code | ':')? newline* block? */ parseInterpolation: function(){ var tok = this.advance(); var tag = { type: 'InterpolatedTag', expr: tok.val, selfClosing: false, block: this.emptyBlock(tok.line), attrs: [], attributeBlocks: [], isInline: false, line: tok.line, column: tok.col, filename: this.filename }; return this.tag(tag, {selfClosingAllowed: true}); }, /** * tag (attrs | class | id)* (text | code | ':')? newline* block? */ parseTag: function(){ var tok = this.advance(); var tag = { type: 'Tag', name: tok.val, selfClosing: false, block: this.emptyBlock(tok.line), attrs: [], attributeBlocks: [], isInline: inlineTags.indexOf(tok.val) !== -1, line: tok.line, column: tok.col, filename: this.filename }; return this.tag(tag, {selfClosingAllowed: true}); }, /** * Parse tag. */ tag: function(tag, options) { var seenAttrs = false; var attributeNames = []; var selfClosingAllowed = options && options.selfClosingAllowed; // (attrs | class | id)* out: while (true) { switch (this.peek().type) { case 'id': case 'class': var tok = this.advance(); if (tok.type === 'id') { if (attributeNames.indexOf('id') !== -1) { this.error('DUPLICATE_ID', 'Duplicate attribute "id" is not allowed.', tok); } attributeNames.push('id'); } tag.attrs.push({ name: tok.type, val: "'" + tok.val + "'", line: tok.line, column: tok.col, filename: this.filename, mustEscape: false }); continue; case 'start-attributes': if (seenAttrs) { console.warn(this.filename + ', line ' + this.peek().line + ':\nYou should not have pug tags with multiple attributes.'); } seenAttrs = true; tag.attrs = tag.attrs.concat(this.attrs(attributeNames)); continue; case '&attributes': var tok = this.advance(); tag.attributeBlocks.push({ type: 'AttributeBlock', val: tok.val, line: tok.line, column: tok.col, filename: this.filename }); break; default: var pluginResult = this.runPlugin('tagAttributeTokens', this.peek(), tag, attributeNames); if (pluginResult) break; break out; } } // check immediate '.' if ('dot' == this.peek().type) { tag.textOnly = true; this.advance(); } // (text | code | ':')? switch (this.peek().type) { case 'text': case 'interpolated-code': var text = this.parseText(); if (text.type === 'Block') { tag.block.nodes.push.apply(tag.block.nodes, text.nodes); } else { tag.block.nodes.push(text); } break; case 'code': tag.block.nodes.push(this.parseCode(true)); break; case ':': this.advance(); var expr = this.parseExpr(); tag.block = expr.type === 'Block' ? expr : this.initBlock(tag.line, [expr]); break; case 'newline': case 'indent': case 'outdent': case 'eos': case 'start-pipeless-text': case 'end-pug-interpolation': break; case 'slash': if (selfClosingAllowed) { this.advance(); tag.selfClosing = true; break; } default: var pluginResult = this.runPlugin('tagTokens', this.peek(), tag, options); if (pluginResult) break; this.error('INVALID_TOKEN', 'Unexpected token `' + this.peek().type + '` expected `text`, `interpolated-code`, `code`, `:`' + (selfClosingAllowed ? ', `slash`' : '') + ', `newline` or `eos`', this.peek()) } // newline* while ('newline' == this.peek().type) this.advance(); // block? if (tag.textOnly) { tag.block = this.parseTextBlock() || this.emptyBlock(tag.line); } else if ('indent' == this.peek().type) { var block = this.block(); for (var i = 0, len = block.nodes.length; i < len; ++i) { tag.block.nodes.push(block.nodes[i]); } } return tag; }, attrs: function(attributeNames) { this.expect('start-attributes'); var attrs = []; var tok = this.advance(); while (tok.type === 'attribute') { if (tok.name !== 'class' && attributeNames) { if (attributeNames.indexOf(tok.name) !== -1) { this.error('DUPLICATE_ATTRIBUTE', 'Duplicate attribute "' + tok.name + '" is not allowed.', tok); } attributeNames.push(tok.name); } attrs.push({ name: tok.name, val: tok.val, line: tok.line, column: tok.col, filename: this.filename, mustEscape: tok.mustEscape !== false }); tok = this.advance(); } this.tokens.defer(tok); this.expect('end-attributes'); return attrs; } };