'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; }; /** * 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(); }, /** * Skip `n` tokens. * * @param {Number} n * @api private */ skip: function(n){ while (n--) this.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; } 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 'when': return this.parseWhen(); case 'default': return this.parseDefault(); 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.parseTextWithInlineTags(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(); node.block = this.block(); return node; }, /** * when */ parseWhen: function(){ var val = this.expect('when').val return new nodes.Case.When(val, this.parseBlockExpansion()); }, /** * default */ parseDefault: function(){ this.expect('default'); return new nodes.Case.When('default', this.parseBlockExpansion()); }, /** * code */ parseCode: function(){ var tok = this.expect('code'); var node = new nodes.Code(tok.val, tok.buffer, tok.escape); var block; var i = 1; node.line = this.line(); while (this.lookahead(i) && 'newline' == this.lookahead(i).type) ++i; block = 'indent' == this.lookahead(i).type; if (block) { this.skip(i-1); node.block = this.block(); } return node; }, /** * comment */ parseComment: function(){ var tok = this.expect('comment'); var node; if ('indent' == this.peek().type) { this.lexer.pipeless = true; node = new nodes.BlockComment(tok.val, this.parseTextBlock(), tok.buffer); this.lexer.pipeless = false; } 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; if ('indent' == this.peek().type) { this.lexer.pipeless = true; block = this.parseTextBlock(); this.lexer.pipeless = false; } else { block = 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'; var str = fs.readFileSync(path, 'utf8'); var parser = new this.constructor(str, path, this.options); parser.blocks = this.blocks; 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(); block = 'indent' == this.peek().type ? this.block() : new nodes.Block(new nodes.Literal('')); 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; 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'); // has-filter if (tok.filter) { var str = fs.readFileSync(path, 'utf8').replace(/\r/g, ''); str = filters(tok.filter, str, { filename: path }); 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.blocks = utils.merge({}, this.blocks); 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); } }, parseTextWithInlineTags: 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.parseTextWithInlineTags(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.parseTextWithInlineTags(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 spaces = this.expect('indent').val; if (null == this._spaces) this._spaces = spaces; var indent = Array(spaces - this._spaces + 1).join(' '); while ('outdent' != this.peek().type) { switch (this.peek().type) { case 'newline': this.advance(); break; case 'indent': this.parseTextBlock(true).nodes.forEach(function(node){ block.push(node); }); break; default: var texts = this.parseTextWithInlineTags(indent + this.advance().val); texts.forEach(function (text) { block.push(text); }); } } if (spaces == this._spaces) this._spaces = null; this.expect('outdent'); 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('You 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(); } if (tag.selfClosing && ['newline', 'outdent', 'eos'].indexOf(this.peek().type) === -1 && (this.peek().type !== 'text' || /^\s*$/.text(this.peek().val))) { throw new Error(name + ' is self closing and should not have content.'); } // (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': 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 ('indent' == this.peek().type) { if (tag.textOnly) { this.lexer.pipeless = true; tag.block = this.parseTextBlock(); this.lexer.pipeless = false; } else { var block = this.block(); if (tag.block) { for (var i = 0, len = block.nodes.length; i < len; ++i) { tag.block.push(block.nodes[i]); } } else { tag.block = block; } } } return tag; } };