/*! * Stylus - Evaluator * Copyright(c) 2010 LearnBoost * MIT Licensed */ /** * Module dependencies. */ var Visitor = require('./') , units = require('../units') , nodes = require('../nodes') , Stack = require('../stack') , Frame = require('../stack/frame') , Scope = require('../stack/scope') , utils = require('../utils') , bifs = require('../functions') , dirname = require('path').dirname , join = require('path').join , colors = require('../colors') , debug = require('debug')('stylus:evaluator') , fs = require('fs'); /** * Clone the block node within the each loop so we don't keep * extending the same block in multiple contexts * * @param {Node} * @return {Node} */ function cloneNode (node) { if (node.block && node.block.node) { node.block.node = node.block.node.clone(); } if (node.nodes && node.nodes.length) { node.nodes.map(cloneNode); } return node; } /** * Initialize a new `Evaluator` with the given `root` Node * and the following `options`. * * Options: * * - `compress` Compress the css output, defaults to false * - `warn` Warn the user of duplicate function definitions etc * * @param {Node} root * @api private */ var Evaluator = module.exports = function Evaluator(root, options) { options = options || {}; Visitor.call(this, root); this.stack = new Stack; this.imports = options.imports || []; this.functions = options.functions || {}; this.globals = options.globals || {}; this.paths = options.paths || []; this.filename = options.filename; this.includeCSS = options['include css']; this.paths.push(dirname(options.filename || '.')); this.stack.push(this.global = new Frame(root)); this.warnings = options.warn; this.options = options; this.calling = []; // TODO: remove, use stack this.importStack = []; this.return = 0; }; /** * Inherit from `Visitor.prototype`. */ Evaluator.prototype.__proto__ = Visitor.prototype; /** * Proxy visit to expose node line numbers. * * @param {Node} node * @return {Node} * @api private */ var visit = Visitor.prototype.visit; Evaluator.prototype.visit = function(node){ try { return visit.call(this, node); } catch (err) { if (err.filename) throw err; err.lineno = node.lineno; err.filename = node.filename; err.stylusStack = this.stack.toString(); try { err.input = fs.readFileSync(err.filename, 'utf8'); } catch (err) { // ignore } throw err; } }; /** * Perform evaluation setup: * * - populate global scope * - iterate imports * * @api private */ Evaluator.prototype.setup = function(){ var root = this.root; var imports = []; this.populateGlobalScope(); this.imports.forEach(function(file){ var expr = new nodes.Expression; expr.push(new nodes.String(file)); imports.push(new nodes.Import(expr)); }, this); root.nodes = imports.concat(root.nodes); }; /** * Populate the global scope with: * * - css colors * - user-defined globals * * @api private */ Evaluator.prototype.populateGlobalScope = function(){ var scope = this.global.scope; // colors Object.keys(colors).forEach(function(name){ var rgb = colors[name] , rgba = new nodes.RGBA(rgb[0], rgb[1], rgb[2], 1) , node = new nodes.Ident(name, rgba); scope.add(node); }); // user-defined globals var globals = this.globals; Object.keys(globals).forEach(function(name){ scope.add(new nodes.Ident(name, globals[name])); }); }; /** * Evaluate the tree. * * @return {Node} * @api private */ Evaluator.prototype.evaluate = function(){ debug('eval %s', this.filename); this.setup(); return this.visit(this.root); }; /** * Visit Group. */ Evaluator.prototype.visitGroup = function(group){ group.nodes = group.nodes.map(function(selector){ selector.val = this.interpolate(selector); debug('ruleset %s', selector.val); return selector; }, this); group.block = this.visit(group.block); return group; }; /** * Visit Charset. */ Evaluator.prototype.visitCharset = function(charset){ return charset; }; /** * Visit Return. */ Evaluator.prototype.visitReturn = function(ret){ ret.expr = this.visit(ret.expr); throw ret; }; /** * Visit Media. */ Evaluator.prototype.visitMedia = function(media){ media.block = this.visit(media.block); var query = this.lookup(media.val); if (query) media.val = new nodes.Literal(query.first.string); return media; }; /** * Visit MozDocument. */ Evaluator.prototype.visitMozDocument = function(mozdocument){ mozdocument.block = this.visit(mozdocument.block); return mozdocument; }; /** * Visit FontFace. */ Evaluator.prototype.visitFontFace = function(face){ face.block = this.visit(face.block); return face; }; /** * Visit FontFace. */ Evaluator.prototype.visitPage = function(page){ page.block = this.visit(page.block); return page; }; /** * Visit Keyframes. */ Evaluator.prototype.visitKeyframes = function(keyframes){ if (keyframes.fabricated) return keyframes; keyframes.name = this.visit(keyframes.name).first.name; keyframes.frames = keyframes.frames.map(function(frame){ frame.block = this.visit(frame.block); return frame; }, this); if ('official' != keyframes.prefix) return keyframes; this.vendors.forEach(function(prefix){ var node = keyframes.clone(); node.prefix = prefix; node.fabricated = true; this.currentBlock.push(node); }, this); return nodes.null; }; /** * Visit Function. */ Evaluator.prototype.visitFunction = function(fn){ // check local var local = this.stack.currentFrame.scope.lookup(fn.name); if (local) this.warn('local ' + local.nodeName + ' "' + fn.name + '" previously defined in this scope'); // user-defined var user = this.functions[fn.name]; if (user) this.warn('user-defined function "' + fn.name + '" is already defined'); // BIF var bif = bifs[fn.name]; if (bif) this.warn('built-in function "' + fn.name + '" is already defined'); return fn; }; /** * Visit Each. */ Evaluator.prototype.visitEach = function(each){ this.return++; var expr = utils.unwrap(this.visit(utils.unwrap(each.expr))) , len = expr.nodes.length , val = new nodes.Ident(each.val) , key = new nodes.Ident(each.key || '__index__') , scope = this.currentScope , block = this.currentBlock , vals = [] , body; this.return--; each.block.scope = false; for (var i = 0; i < len; ++i) { val.val = expr.nodes[i]; key.val = new nodes.Unit(i); scope.add(val); scope.add(key); body = each.block.clone(); body.nodes.map(cloneNode); body = this.visit(body); vals = vals.concat(body.nodes); } this.mixin(vals, block); return vals[vals.length - 1] || nodes.null; }; /** * Visit Call. */ Evaluator.prototype.visitCall = function(call){ debug('call %s', call); var fn = this.lookup(call.name) , ret; // url() this.ignoreColors = 'url' == call.name; // Variable function if (fn && 'expression' == fn.nodeName) { fn = fn.nodes[0]; } // Not a function? try user-defined or built-ins if (fn && 'function' != fn.nodeName) { fn = this.lookupFunction(call.name); } // Undefined function? render literal CSS if (!fn || fn.nodeName != 'function') { debug('%s is undefined', call); var ret = this.literalCall(call); this.ignoreColors = false; return ret; } this.calling.push(call.name); // Massive stack if (this.calling.length > 200) { throw new RangeError('Maximum stylus call stack size exceeded'); } // First node in expression if ('expression' == fn.nodeName) fn = fn.first; // Evaluate arguments this.return++; var args = this.visit(call.args) , mapCopy = {}; for (var key in args.map) { mapCopy[key] = args.map[key]; args.map[key] = this.visit(mapCopy[key].clone()); } this.return--; // Built-in if (fn.fn) { debug('%s is built-in', call); ret = this.invokeBuiltin(fn.fn, args); // User-defined } else if ('function' == fn.nodeName) { debug('%s is user-defined', call); ret = this.invokeFunction(fn, args); } // restore kwargs for (key in mapCopy) { args.map[key] = mapCopy[key]; } this.calling.pop(); this.ignoreColors = false; return ret; }; /** * Visit Ident. */ Evaluator.prototype.visitIdent = function(ident){ var prop; // Property lookup if (ident.property) { if (prop = this.lookupProperty(ident.name)) { return this.visit(prop.expr.clone()); } return nodes.null; // Lookup } else if (ident.val.isNull) { var val = this.lookup(ident.name); return val ? this.visit(val) : ident; // Assign } else { this.return++; ident.val = this.visit(ident.val); this.return--; this.currentScope.add(ident); return ident.val; } }; /** * Visit BinOp. */ Evaluator.prototype.visitBinOp = function(binop){ // Special-case "is defined" pseudo binop if ('is defined' == binop.op) return this.isDefined(binop.left); this.return++; // Visit operands var op = binop.op , left = this.visit(binop.left) , right = this.visit(binop.right); this.return--; // HACK: ternary var val = binop.val ? this.visit(binop.val) : null; // Operate try { return this.visit(left.operate(op, right, val)); } catch (err) { // disregard coercion issues in equality // checks, and simply return false if ('CoercionError' == err.name) { switch (op) { case '==': return nodes.false; case '!=': return nodes.true; } } throw err; } }; /** * Visit UnaryOp. */ Evaluator.prototype.visitUnaryOp = function(unary){ var op = unary.op , node = this.visit(unary.expr); if ('!' != op) { node = node.first.clone(); utils.assertType(node, 'unit'); } switch (op) { case '-': node.val = -node.val; break; case '+': node.val = +node.val; break; case '~': node.val = ~node.val; break; case '!': return node.toBoolean().negate(); } return node; }; /** * Visit TernaryOp. */ Evaluator.prototype.visitTernary = function(ternary){ var ok = this.visit(ternary.cond).toBoolean(); return ok.isTrue ? this.visit(ternary.trueExpr) : this.visit(ternary.falseExpr); }; /** * Visit Expression. */ Evaluator.prototype.visitExpression = function(expr){ for (var i = 0, len = expr.nodes.length; i < len; ++i) { expr.nodes[i] = this.visit(expr.nodes[i]); } // support (n * 5)px etc if (this.castable(expr)) expr = this.cast(expr); return expr; }; /** * Visit Arguments. */ Evaluator.prototype.visitArguments = Evaluator.prototype.visitExpression; /** * Visit Property. */ Evaluator.prototype.visitProperty = function(prop){ var name = this.interpolate(prop) , fn = this.lookup(name) , call = fn && 'function' == fn.nodeName , literal = ~this.calling.indexOf(name); // Function of the same name if (call && !literal && !prop.literal) { this.calling.push(name); var args = nodes.Arguments.fromExpression(utils.unwrap(prop.expr)); var ret = this.visit(new nodes.Call(name, args)); this.calling.pop(); return ret; // Regular property } else { this.return++; prop.name = name; prop.literal = true; this.property = prop; prop.expr = this.visit(prop.expr); delete this.property; this.return--; return prop; } }; /** * Visit Root. */ Evaluator.prototype.visitRoot = function(block){ for (var i = 0; i < block.nodes.length; ++i) { block.index = this.rootIndex = i; block.nodes[i] = this.visit(block.nodes[i]); } return block; }; /** * Visit Block. */ Evaluator.prototype.visitBlock = function(block){ this.stack.push(new Frame(block)); for (block.index = 0; block.index < block.nodes.length; ++block.index) { try { block.nodes[block.index] = this.visit(block.nodes[block.index]); } catch (err) { if ('return' == err.nodeName) { if (this.return) { this.stack.pop(); throw err; } else { block.nodes[block.index] = err; break; } } else { throw err; } } } this.stack.pop(); return block; }; /** * Visit If. */ Evaluator.prototype.visitIf = function(node){ var ret , block = this.currentBlock , negate = node.negate; this.return++; var ok = this.visit(node.cond).first.toBoolean(); this.return--; // Evaluate body if (negate) { // unless if (ok.isFalse) { ret = this.visit(node.block); } } else { // if if (ok.isTrue) { ret = this.visit(node.block); // else } else if (node.elses.length) { var elses = node.elses , len = elses.length; for (var i = 0; i < len; ++i) { // else if if (elses[i].cond) { if (this.visit(elses[i].cond).first.toBoolean().isTrue) { ret = this.visit(elses[i].block); break; } // else } else { ret = this.visit(elses[i]); } } } } // mixin conditional statements within a selector group if (ret && !node.postfix && block.node && 'group' == block.node.nodeName) { this.mixin(ret.nodes, block); return nodes.null; } return ret || nodes.null; }; /** * Visit Extend. */ Evaluator.prototype.visitExtend = function(extend){ // Cloning the selector for when we are in a loop and don't want it to affect // the selector nodes and cause the values to be different to expected var selector = this.interpolate(extend.selector.clone()); var block = !this.currentBlock.node.extends && this.targetBlock.node.extends ? this.targetBlock : this.currentBlock; block.node.extends.push(selector); return nodes.null; }; /** * Visit Import. */ Evaluator.prototype.visitImport = function(imported){ this.return++; var root = this.root , Parser = require('../parser') , path = this.visit(imported.path).first , includeCSS = this.includeCSS , importStack = this.importStack , found , literal; this.return--; debug('import %s', path); // url() passed if ('url' == path.name) return imported; // Ensure string if (!path.string) throw new Error('@import string expected'); var name = path = path.string; // Absolute URL if (/url\s*\(\s*['"]?http/i.test(path)) { return imported; } // Literal if (~path.indexOf('.css') && !~path.indexOf('.css.')) { literal = true; if (!includeCSS) return imported; } // support optional .styl if (!literal && !/\.styl$/i.test(path)) path += '.styl'; // Lookup found = utils.lookup(path, this.paths, this.filename); found = found || utils.lookup(join(name, 'index.styl'), this.paths, this.filename); // Expose imports imported.path = found; imported.dirname = dirname(found); this.paths.push(imported.dirname); // Nested imports if (importStack.length) this.paths.push(dirname(importStack[importStack.length - 1])); if (this.options._imports) this.options._imports.push(imported); // Throw if import failed if (!found) throw new Error('failed to locate @import file ' + path); // Parse the file importStack.push(found); nodes.filename = found; var str = fs.readFileSync(found, 'utf8'); if (literal) return new nodes.Literal(str.replace(/\r\n?/g, "\n")); // parse var block = new nodes.Block , parser = new Parser(str, utils.merge({ root: block }, this.options)); try { block = parser.parse(); } catch (err) { err.filename = found; err.lineno = parser.lexer.lineno; err.input = str; throw err; } // Store the modified time fs.stat(found, function(err, stat){ if (err) return; imported.mtime = stat.mtime; }); // Evaluate imported "root" block.parent = root; block.scope = false; var ret = this.visit(block); this.paths.pop(); importStack.pop(); return ret; }; /** * Invoke `fn` with `args`. * * @param {Function} fn * @param {Array} args * @return {Node} * @api private */ Evaluator.prototype.invokeFunction = function(fn, args){ var block = new nodes.Block(fn.block.parent); fn.block.parent = block; // Clone the function body // to prevent mutation of subsequent calls var body = fn.block.clone(); // mixin block var mixinBlock = this.stack.currentFrame.block; // new block scope this.stack.push(new Frame(block)); var scope = this.currentScope; // arguments local scope.add(new nodes.Ident('arguments', args)); // mixin scope introspection scope.add(new nodes.Ident('mixin', this.return ? nodes.false : new nodes.String(mixinBlock.nodeName))); // current property if (this.property) { var prop = this.propertyExpression(this.property, fn.name); scope.add(new nodes.Ident('current-property', prop)); } else { scope.add(new nodes.Ident('current-property', nodes.null)); } // inject arguments as locals var i = 0 , len = args.nodes.length; fn.params.nodes.forEach(function(node){ // rest param support if (node.rest) { node.val = new nodes.Expression; for (; i < len; ++i) node.val.push(args.nodes[i]); node.val.preserve = true; // argument default support } else { var arg = args.map[node.name] || args.nodes[i++]; node = node.clone(); if (arg) { if (!arg.isEmpty) node.val = arg; } else { args.push(node.val); } // required argument not satisfied if (node.val.isNull) { throw new Error('argument "' + node + '" required for ' + fn); } } scope.add(node); }); // invoke return this.invoke(body, true); }; /** * Invoke built-in `fn` with `args`. * * @param {Function} fn * @param {Array} args * @return {Node} * @api private */ Evaluator.prototype.invokeBuiltin = function(fn, args){ // Map arguments to first node // providing a nicer js api for // BIFs. Functions may specify that // they wish to accept full expressions // via .raw if (fn.raw) { args = args.nodes; } else { args = utils.params(fn).reduce(function(ret, param){ var arg = args.map[param] || args.nodes.shift(); if (arg) ret.push(arg.first); return ret; }, []); } // Invoke the BIF var body = utils.coerce(fn.apply(this, args)); // Always wrapping allows js functions // to return several values with a single // Expression node var expr = new nodes.Expression; expr.push(body); body = expr; // Invoke return this.invoke(body); }; /** * Invoke the given function `body`. * * @param {Block} body * @return {Node} * @api private */ Evaluator.prototype.invoke = function(body, stack){ var self = this , ret; // Return if (this.return) { ret = this.eval(body.nodes); if (stack) this.stack.pop(); // Mixin } else { var targetFrame = this.stack[this.stack.length - 2]; if (targetFrame) this.targetBlock = targetFrame.block; body = this.visit(body); if (stack) this.stack.pop(); this.mixin(body.nodes, this.currentBlock); ret = nodes.null; } return ret; }; /** * Mixin the given `nodes` to the given `block`. * * @param {Array} nodes * @param {Block} block * @api private */ Evaluator.prototype.mixin = function(nodes, block){ var len = block.nodes.length , head = block.nodes.slice(0, block.index) , tail = block.nodes.slice(block.index + 1, len); this._mixin(nodes, head); block.nodes = head.concat(tail); }; /** * Mixin the given `nodes` to the `dest` array. * * @param {Array} nodes * @param {Array} dest * @api private */ Evaluator.prototype._mixin = function(nodes, dest){ var node , len = nodes.length; for (var i = 0; i < len; ++i) { switch ((node = nodes[i]).nodeName) { case 'return': return; case 'block': this._mixin(node.nodes, dest); break; default: dest.push(node); } } }; /** * Evaluate the given `vals`. * * @param {Array} vals * @return {Node} * @api private */ Evaluator.prototype.eval = function(vals){ if (!vals) return nodes.null; var len = vals.length , node = nodes.null; try { for (var i = 0; i < len; ++i) { node = vals[i]; switch (node.nodeName) { case 'if': if ('block' != node.block.nodeName) { node = this.visit(node); break; } case 'each': case 'block': node = this.visit(node); if (node.nodes) node = this.eval(node.nodes); break; default: node = this.visit(node); } } } catch (err) { if ('return' == err.nodeName) { return err.expr; } else { throw err; } } return node; }; /** * Literal function `call`. * * @param {Call} call * @return {call} * @api private */ Evaluator.prototype.literalCall = function(call){ call.args = this.visit(call.args); return call; }; /** * Lookup property `name`. * * @param {String} name * @return {Property} * @api private */ Evaluator.prototype.lookupProperty = function(name){ var i = this.stack.length , prop = this.property , curr = prop && prop.name , index = this.currentBlock.index , top = i , nodes , block , other; while (i--) { block = this.stack[i].block; if (!block.node) continue; switch (block.node.nodeName) { case 'group': case 'function': nodes = block.nodes; // scan siblings from the property index up if (i + 1 == top) { while (index--) { other = this.interpolate(nodes[index]); if (name == other) return nodes[index].clone(); } // sequential lookup for non-siblings (for now) } else { for (var j = 0, len = nodes.length; j < len; ++j) { if ('property' != nodes[j].nodeName) continue; other = this.interpolate(nodes[j]); if (name == other) return nodes[j].clone(); } } break; } } return nodes.null; }; /** * Return the closest mixin-able `Block`. * * @return {Block} * @api private */ Evaluator.prototype.__defineGetter__('closestBlock', function(){ var i = this.stack.length , block; while (i--) { block = this.stack[i].block; if (block.node) { switch (block.node.nodeName) { case 'group': case 'function': case 'media': return block; } } } }); /** * Lookup `name`, with support for JavaScript * functions, and BIFs. * * @param {String} name * @return {Node} * @api private */ Evaluator.prototype.lookup = function(name){ var val; if (this.ignoreColors && name in colors) return; if (val = this.stack.lookup(name)) { return utils.unwrap(val); } else { return this.lookupFunction(name); } }; /** * Map segments in `node` returning a string. * * @param {Node} node * @return {String} * @api private */ Evaluator.prototype.interpolate = function(node){ var self = this; function toString(node) { switch (node.nodeName) { case 'function': case 'ident': return node.name; case 'literal': case 'string': case 'unit': return node.val; case 'expression': self.return++; var ret = toString(self.visit(node).first); self.return--; return ret; } } if (node.segments) { return node.segments.map(toString).join(''); } else { return toString(node); } }; /** * Lookup JavaScript user-defined or built-in function. * * @param {String} name * @return {Function} * @api private */ Evaluator.prototype.lookupFunction = function(name){ var fn = this.functions[name] || bifs[name]; if (fn) return new nodes.Function(name, fn); }; /** * Check if the given `node` is an ident, and if it is defined. * * @param {Node} node * @return {Boolean} * @api private */ Evaluator.prototype.isDefined = function(node){ if ('ident' == node.nodeName) { return nodes.Boolean(this.lookup(node.name)); } else { throw new Error('invalid "is defined" check on non-variable ' + node); } }; /** * Return `Expression` based on the given `prop`, * replacing cyclic calls to the given function `name` * with "__CALL__". * * @param {Property} prop * @param {String} name * @return {Expression} * @api private */ Evaluator.prototype.propertyExpression = function(prop, name){ var expr = new nodes.Expression , val = prop.expr.clone(); // name expr.push(new nodes.String(prop.name)); // replace cyclic call with __CALL__ function replace(node) { if ('call' == node.nodeName && name == node.name) { return new nodes.Literal('__CALL__'); } if (node.nodes) node.nodes = node.nodes.map(replace); return node; } replace(val); expr.push(val); return expr; }; /** * Cast `expr` to the trailing ident. * * @param {Expression} expr * @return {Unit} * @api private */ Evaluator.prototype.cast = function(expr){ return new nodes.Unit(expr.first.val, expr.nodes[1].name); }; /** * Check if `expr` is castable. * * @param {Expression} expr * @return {Boolean} * @api private */ Evaluator.prototype.castable = function(expr){ return 2 == expr.nodes.length && 'unit' == expr.first.nodeName && ~units.indexOf(expr.nodes[1].name); }; /** * Warn with the given `msg`. * * @param {String} msg * @api private */ Evaluator.prototype.warn = function(msg){ if (!this.warnings) return; console.warn('\033[33mWarning:\033[0m ' + msg); }; /** * Return the current `Block`. * * @return {Block} * @api private */ Evaluator.prototype.__defineGetter__('currentBlock', function(){ return this.stack.currentFrame.block; }); /** * Return an array of vendor names. * * @return {Array} * @api private */ Evaluator.prototype.__defineGetter__('vendors', function(){ return this.lookup('vendors').nodes.map(function(node){ return node.string; }); }); /** * Return the current frame `Scope`. * * @return {Scope} * @api private */ Evaluator.prototype.__defineGetter__('currentScope', function(){ return this.stack.currentFrame.scope; }); /** * Return the current `Frame`. * * @return {Frame} * @api private */ Evaluator.prototype.__defineGetter__('currentFrame', function(){ return this.stack.currentFrame; });