'use strict'; var Lexer = require('./lexer'); var nodes = require('./nodes'); var utils = require('./utils'); var filters = require('./filters'); var path = require('path'); var constantinople = require('constantinople'); var parseJSExpression = require('character-parser').parseMax; var extname = path.extname; /** * Initialize `Parser` with the given input `str` and `filename`. * * @param {String} str * @param {String} filename * @param {Object} options * @api public */ var Parser = exports = module.exports = function Parser(str, filename, options){ //Strip any UTF-8 BOM off of the start of `str`, if it exists. this.input = str.replace(/^\uFEFF/, ''); this.lexer = new Lexer(this.input, filename); this.filename = filename; this.blocks = {}; this.mixins = {}; this.options = options; this.contexts = [this]; this.inMixin = false; this.dependencies = []; this.inBlock = 0; }; /** * Parser prototype. */ Parser.prototype = { /** * Save original constructor */ constructor: Parser, /** * Push `parser` onto the context stack, * or pop and return a `Parser`. */ context: function(parser){ if (parser) { this.contexts.push(parser); } else { return this.contexts.pop(); } }, /** * Return the next token object. * * @return {Object} * @api private */ advance: function(){ return this.lexer.advance(); }, /** * Single token lookahead. * * @return {Object} * @api private */ peek: function() { return this.lookahead(1); }, /** * Return lexer lineno. * * @return {Number} * @api private */ line: function() { return this.lexer.lineno; }, /** * `n` token lookahead. * * @param {Number} n * @return {Object} * @api private */ lookahead: function(n){ return this.lexer.lookahead(n); }, /** * Parse input returning a string of js for evaluation. * * @return {String} * @api public */ parse: function(){ var block = new nodes.Block, parser; block.line = 0; block.filename = this.filename; while ('eos' != this.peek().type) { if ('newline' == this.peek().type) { this.advance(); } else { var next = this.peek(); var expr = this.parseExpr(); expr.filename = expr.filename || this.filename; expr.line = next.line; block.push(expr); } } if (parser = this.extending) { this.context(parser); var ast = parser.parse(); this.context(); // hoist mixins for (var name in this.mixins) ast.unshift(this.mixins[name]); return ast; } if (!this.extending && !this.included && Object.keys(this.blocks).length){ var blocks = []; utils.walkAST(block, function (node) { if (node.type === 'Block' && node.name) { blocks.push(node.name); } }); Object.keys(this.blocks).forEach(function (name) { if (blocks.indexOf(name) === -1 && !this.blocks[name].isSubBlock) { console.warn('Warning: Unexpected block "' + name + '" ' + ' on line ' + this.blocks[name].line + ' of ' + (this.blocks[name].filename) + '. This block is never used. This warning will be an error in v2.0.0'); } }.bind(this)); } 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 { throw new Error('expected "' + type + '", but got "' + this.peek().type + '"'); } }, /** * Accept the given `type`. * * @param {String} type * @api private */ accept: function(type){ if (this.peek().type === type) { return this.advance(); } }, /** * tag * | doctype * | mixin * | include * | filter * | comment * | text * | 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': return this.parseText(); case 'each': return this.parseEach(); case 'code': return this.parseCode(); case 'call': return this.parseCall(); case 'interpolation': return this.parseInterpolation(); case 'yield': this.advance(); var block = new nodes.Block; block.yield = true; return block; case 'id': case 'class': var tok = this.advance(); this.lexer.defer(this.lexer.tok('tag', 'div')); this.lexer.defer(tok); return this.parseExpr(); default: throw new Error('unexpected token "' + this.peek().type + '"'); } }, /** * Text */ parseText: function(){ var tok = this.expect('text'); var tokens = this.parseInlineTagsInText(tok.val); if (tokens.length === 1) return tokens[0]; var node = new nodes.Block; for (var i = 0; i < tokens.length; i++) { node.push(tokens[i]); }; return node; }, /** * ':' expr * | block */ parseBlockExpansion: function(){ if (':' == this.peek().type) { this.advance(); return new nodes.Block(this.parseExpr()); } else { return this.block(); } }, /** * case */ parseCase: function(){ var val = this.expect('case').val; var node = new nodes.Case(val); node.line = this.line(); var block = new nodes.Block; block.line = this.line(); block.filename = this.filename; this.expect('indent'); while ('outdent' != this.peek().type) { switch (this.peek().type) { case 'newline': this.advance(); break; case 'when': block.push(this.parseWhen()); break; case 'default': block.push(this.parseDefault()); break; default: throw new Error('Unexpected token "' + this.peek().type + '", expected "when", "default" or "newline"'); } } this.expect('outdent'); node.block = block; return node; }, /** * when */ parseWhen: function(){ var val = this.expect('when').val; if (this.peek().type !== 'newline') return new nodes.Case.When(val, this.parseBlockExpansion()); else return new nodes.Case.When(val); }, /** * default */ parseDefault: function(){ this.expect('default'); return new nodes.Case.When('default', this.parseBlockExpansion()); }, /** * code */ parseCode: function(afterIf){ var tok = this.expect('code'); var node = new nodes.Code(tok.val, tok.buffer, tok.escape); var block; node.line = this.line(); // throw an error if an else does not have an if if (tok.isElse && !tok.hasIf) { throw new Error('Unexpected else without if'); } // handle block block = 'indent' == this.peek().type; if (block) { node.block = this.block(); } // handle missing block if (tok.requiresBlock && !block) { node.block = new nodes.Block(); } // mark presense of if for future elses if (tok.isIf && this.peek().isElse) { this.peek().hasIf = true; } else if (tok.isIf && this.peek().type === 'newline' && this.lookahead(2).isElse) { this.lookahead(2).hasIf = true; } return node; }, /** * comment */ parseComment: function(){ var tok = this.expect('comment'); var node; var block; if (block = this.parseTextBlock()) { node = new nodes.BlockComment(tok.val, block, tok.buffer); } else { node = new nodes.Comment(tok.val, tok.buffer); } node.line = this.line(); return node; }, /** * doctype */ parseDoctype: function(){ var tok = this.expect('doctype'); var node = new nodes.Doctype(tok.val); node.line = this.line(); return node; }, /** * filter attrs? text-block */ parseFilter: function(){ var tok = this.expect('filter'); var attrs = this.accept('attrs'); var block; block = this.parseTextBlock() || new nodes.Block(); var options = {}; if (attrs) { attrs.attrs.forEach(function (attribute) { options[attribute.name] = constantinople.toConstant(attribute.val); }); } var node = new nodes.Filter(tok.val, block, options); node.line = this.line(); return node; }, /** * each block */ parseEach: function(){ var tok = this.expect('each'); var node = new nodes.Each(tok.code, tok.val, tok.key); node.line = this.line(); node.block = this.block(); if (this.peek().type == 'code' && this.peek().val == 'else') { this.advance(); node.alternative = this.block(); } return node; }, /** * Resolves a path relative to the template for use in * includes and extends * * @param {String} path * @param {String} purpose Used in error messages. * @return {String} * @api private */ resolvePath: function (path, purpose) { var p = require('path'); var dirname = p.dirname; var basename = p.basename; var join = p.join; if (path[0] !== '/' && !this.filename) throw new Error('the "filename" option is required to use "' + purpose + '" with "relative" paths'); if (path[0] === '/' && !this.options.basedir) throw new Error('the "basedir" option is required to use "' + purpose + '" with "absolute" paths'); path = join(path[0] === '/' ? this.options.basedir : dirname(this.filename), path); if (basename(path).indexOf('.') === -1) path += '.jade'; return path; }, /** * 'extends' name */ parseExtends: function(){ var fs = require('fs'); var path = this.resolvePath(this.expect('extends').val.trim(), 'extends'); if ('.jade' != path.substr(-5)) path += '.jade'; this.dependencies.push(path); var str = fs.readFileSync(path, 'utf8'); var parser = new this.constructor(str, path, this.options); parser.dependencies = this.dependencies; parser.blocks = this.blocks; parser.included = this.included; parser.contexts = this.contexts; this.extending = parser; // TODO: null node return new nodes.Literal(''); }, /** * 'block' name block */ parseBlock: function(){ var block = this.expect('block'); var mode = block.mode; var name = block.val.trim(); var line = block.line; this.inBlock++; block = 'indent' == this.peek().type ? this.block() : new nodes.Block(new nodes.Literal('')); this.inBlock--; block.name = name; block.line = line; var prev = this.blocks[name] || {prepended: [], appended: []} if (prev.mode === 'replace') return this.blocks[name] = prev; var allNodes = prev.prepended.concat(block.nodes).concat(prev.appended); switch (mode) { case 'append': prev.appended = prev.parser === this ? prev.appended.concat(block.nodes) : block.nodes.concat(prev.appended); break; case 'prepend': prev.prepended = prev.parser === this ? block.nodes.concat(prev.prepended) : prev.prepended.concat(block.nodes); break; } block.nodes = allNodes; block.appended = prev.appended; block.prepended = prev.prepended; block.mode = mode; block.parser = this; block.isSubBlock = this.inBlock > 0; return this.blocks[name] = block; }, parseMixinBlock: function () { var block = this.expect('mixin-block'); if (!this.inMixin) { throw new Error('Anonymous blocks are not allowed unless they are part of a mixin.'); } return new nodes.MixinBlock(); }, /** * include block? */ parseInclude: function(){ var fs = require('fs'); var tok = this.expect('include'); var path = this.resolvePath(tok.val.trim(), 'include'); this.dependencies.push(path); // has-filter if (tok.filter) { var str = fs.readFileSync(path, 'utf8').replace(/\r/g, ''); var options = {filename: path}; if (tok.attrs) { tok.attrs.attrs.forEach(function (attribute) { options[attribute.name] = constantinople.toConstant(attribute.val); }); } str = filters(tok.filter, str, options); return new nodes.Literal(str); } // non-jade if ('.jade' != path.substr(-5)) { var str = fs.readFileSync(path, 'utf8').replace(/\r/g, ''); return new nodes.Literal(str); } var str = fs.readFileSync(path, 'utf8'); var parser = new this.constructor(str, path, this.options); parser.dependencies = this.dependencies; parser.blocks = utils.merge({}, this.blocks); parser.included = true; parser.mixins = this.mixins; this.context(parser); var ast = parser.parse(); this.context(); ast.filename = path; if ('indent' == this.peek().type) { ast.includeBlock().push(this.block()); } return ast; }, /** * call ident block */ parseCall: function(){ var tok = this.expect('call'); var name = tok.val; var args = tok.args; var mixin = new nodes.Mixin(name, args, new nodes.Block, true); this.tag(mixin); if (mixin.code) { mixin.block.push(mixin.code); mixin.code = null; } if (mixin.block.isEmpty()) mixin.block = null; return mixin; }, /** * mixin block */ parseMixin: function(){ var tok = this.expect('mixin'); var name = tok.val; var args = tok.args; var mixin; // definition if ('indent' == this.peek().type) { this.inMixin = true; mixin = new nodes.Mixin(name, args, this.block(), false); this.mixins[name] = mixin; this.inMixin = false; return mixin; // call } else { return new nodes.Mixin(name, args, null, true); } }, parseInlineTagsInText: function (str) { var line = this.line(); var match = /(\\)?#\[((?:.|\n)*)$/.exec(str); if (match) { if (match[1]) { // escape var text = new nodes.Text(str.substr(0, match.index) + '#['); text.line = line; var rest = this.parseInlineTagsInText(match[2]); if (rest[0].type === 'Text') { text.val += rest[0].val; rest.shift(); } return [text].concat(rest); } else { var text = new nodes.Text(str.substr(0, match.index)); text.line = line; var buffer = [text]; var rest = match[2]; var range = parseJSExpression(rest); var inner = new Parser(range.src, this.filename, this.options); buffer.push(inner.parse()); return buffer.concat(this.parseInlineTagsInText(rest.substr(range.end + 1))); } } else { var text = new nodes.Text(str); text.line = line; return [text]; } }, /** * indent (text | newline)* outdent */ parseTextBlock: function(){ var block = new nodes.Block; block.line = this.line(); var body = this.peek(); if (body.type !== 'pipeless-text') return; this.advance(); block.nodes = body.val.reduce(function (accumulator, text) { return accumulator.concat(this.parseInlineTagsInText(text)); }.bind(this), []); return block; }, /** * indent expr* outdent */ block: function(){ var block = new nodes.Block; block.line = this.line(); block.filename = this.filename; this.expect('indent'); while ('outdent' != this.peek().type) { if ('newline' == this.peek().type) { this.advance(); } else { var expr = this.parseExpr(); expr.filename = this.filename; block.push(expr); } } this.expect('outdent'); return block; }, /** * interpolation (attrs | class | id)* (text | code | ':')? newline* block? */ parseInterpolation: function(){ var tok = this.advance(); var tag = new nodes.Tag(tok.val); tag.buffer = true; return this.tag(tag); }, /** * tag (attrs | class | id)* (text | code | ':')? newline* block? */ parseTag: function(){ var tok = this.advance(); var tag = new nodes.Tag(tok.val); tag.selfClosing = tok.selfClosing; return this.tag(tag); }, /** * Parse tag. */ tag: function(tag){ tag.line = this.line(); var seenAttrs = false; // (attrs | class | id)* out: while (true) { switch (this.peek().type) { case 'id': case 'class': var tok = this.advance(); tag.setAttribute(tok.type, "'" + tok.val + "'"); continue; case 'attrs': if (seenAttrs) { console.warn(this.filename + ', line ' + this.peek().line + ':\nYou should not have jade tags with multiple attributes.'); } seenAttrs = true; var tok = this.advance(); var attrs = tok.attrs; if (tok.selfClosing) tag.selfClosing = true; for (var i = 0; i < attrs.length; i++) { tag.setAttribute(attrs[i].name, attrs[i].val, attrs[i].escaped); } continue; case '&attributes': var tok = this.advance(); tag.addAttributes(tok.val); break; default: break out; } } // check immediate '.' if ('dot' == this.peek().type) { tag.textOnly = true; this.advance(); } // (text | code | ':')? switch (this.peek().type) { case 'text': tag.block.push(this.parseText()); break; case 'code': tag.code = this.parseCode(); break; case ':': this.advance(); tag.block = new nodes.Block; tag.block.push(this.parseExpr()); break; case 'newline': case 'indent': case 'outdent': case 'eos': case 'pipeless-text': break; default: throw new Error('Unexpected token `' + this.peek().type + '` expected `text`, `code`, `:`, `newline` or `eos`') } // newline* while ('newline' == this.peek().type) this.advance(); // block? if (tag.textOnly) { tag.block = this.parseTextBlock(); } else if ('indent' == this.peek().type) { var block = this.block(); for (var i = 0, len = block.nodes.length; i < len; ++i) { tag.block.push(block.nodes[i]); } } return tag; } };