Repository: mozilla/nunjucks
Pull request #1033 (2017-10-12 18:57:36)

Jinja-style tests
## Summary
I've got most of the docs changes ready to go, but I figured this would be something worth discussing before I spend time polishing them.

 - adds ~20 Jinja2-style boolean tests + aliases
  - updates node list, parser and compiler to interpret and properly compile `Is` nodes
  - adds tests for tests, parser and compiler changes/additions (no changes to existing tests)
  - adds `addTest`/`getTest` methods to the Environment
  - **CAVEAT**: changes lexer to interpret "null" as the primitive `null` instead of a lookup to "null" in the context. this was necessary to make the test `null is null` pass — this *may* be a compatibility issue
  - **CAVEAT**: adds soft dependency on ES6 Symbols, Maps and Sets for full functionality of `iterable` and `mapping` tests. The whole thing shouldn't explode / degrades gracefully if they're not available, but I don't currently have the ability to test that in an non-evergreen environment, so I can't say if we'd still have IE8 compat.

Addresses [#532](https://github.com/mozilla/nunjucks/issues/532), [#879](https://github.com/mozilla/nunjucks/issues/879) and [#1015](https://github.com/mozilla/nunjucks/issues/1015) .

## Checklist
I've completed the checklist below to ensure I didn't forget anything. This makes reviewing this PR as easy as possible for the maintainers. And it gets this change released as soon as possible.

* [x] Proposed change helps towards [*purpose of this project*](https://github.com/mozilla/nunjucks/blob/master/CONTRIBUTING.md#purpose).
* [ ] [*Documentation*](https://github.com/mozilla/nunjucks/tree/master/docs/) is added / updated to describe proposed change.
* [x] [*Tests*](https://github.com/mozilla/nunjucks/tree/master/tests) are added / updated to cover proposed change.
* [ ] [*Changelog*](https://github.com/mozilla/nunjucks/blob/master/CHANGELOG.md) has an entry for proposed change (if user-facing fix or feature).



Patch:
diff --git a/src/compiler.js b/src/compiler.js
index 97b71d0..ecdd06b 100644
--- a/src/compiler.js
+++ b/src/compiler.js
@@ -373,6 +373,23 @@ var Compiler = Object.extend({
       this.emit(')');
     },
 
+    compileIs: function(node, frame) {
+      // first, we need to try to get the name of the test function, if it's a
+      // callable (i.e., has args) and not a symbol.
+      var right = node.right.name
+        ? node.right.name.value
+        // otherwise go with the symbol value
+        : node.right.value;
+      this.emit('env.getTest("' + right + '").call(context, ');
+      this.compile(node.left, frame);
+      // compile the arguments for the callable if they exist
+      if (node.right.args) {
+        this.emit(',');
+        this.compile(node.right.args, frame);
+      }
+      this.emit(') === true');
+    },
+
     compileOr: binOpEmitter(' || '),
     compileAnd: binOpEmitter(' && '),
     compileAdd: binOpEmitter(' + '),
diff --git a/src/environment.js b/src/environment.js
index 7a7ff67..413e66c 100644
--- a/src/environment.js
+++ b/src/environment.js
@@ -7,6 +7,7 @@ var Obj = require('./object');
 var compiler = require('./compiler');
 var builtin_filters = require('./filters');
 var builtin_loaders = require('./loaders');
+var builtin_tests = require('./tests');
 var runtime = require('./runtime');
 var globals = require('./globals');
 var waterfall = require('a-sync-waterfall');
@@ -74,6 +75,7 @@ var Environment = Obj.extend({
 
         this.globals = globals();
         this.filters = {};
+        this.tests = {};
         this.asyncFilters = [];
         this.extensions = {};
         this.extensionsList = [];
@@ -81,6 +83,9 @@ var Environment = Obj.extend({
         for(var name in builtin_filters) {
             this.addFilter(name, builtin_filters[name]);
         }
+        for(var test in builtin_tests) {
+            this.addTest(test, builtin_tests[test]);
+        }
     },
 
     initCache: function() {
@@ -147,6 +152,18 @@ var Environment = Obj.extend({
         }
         return this.filters[name];
     },
+    
+    addTest: function(name, func) {
+        this.tests[name] = func;
+        return this;
+    },
+    
+    getTest: function(name) {
+        if(!this.tests[name]) {
+            throw new Error('test not found: ' + name);
+        }
+        return this.tests[name];
+    },
 
     resolveTemplate: function(loader, parentName, filename) {
         var isRelative = (loader.isRelative && parentName)? loader.isRelative(filename) : false;
diff --git a/src/lexer.js b/src/lexer.js
index 8384ee9..deb908c 100644
--- a/src/lexer.js
+++ b/src/lexer.js
@@ -216,6 +216,16 @@ Tokenizer.prototype.nextToken = function() {
             else if(tok === 'none') {
                 return token(TOKEN_NONE, tok, lineno, colno);
             }
+            /*
+             * Added to make the test `null is null` evaluate truthily.
+             * Otherwise, Nunjucks will look up null in the context and
+             * return `undefined`, which is not what we want. This *may* have
+             * consequences is someone is using null in their templates as a
+             * variable.
+             */
+            else if(tok === 'null') {
+                return token(TOKEN_NONE, tok, lineno, colno);
+            }
             else if(tok) {
                 return token(TOKEN_SYMBOL, tok, lineno, colno);
             }
diff --git a/src/nodes.js b/src/nodes.js
index e351422..df36f77 100644
--- a/src/nodes.js
+++ b/src/nodes.js
@@ -125,6 +125,7 @@ var TemplateData = Literal.extend('TemplateData');
 var UnaryOp = Node.extend('UnaryOp', { fields: ['target'] });
 var BinOp = Node.extend('BinOp', { fields: ['left', 'right'] });
 var In = BinOp.extend('In');
+var Is = BinOp.extend('Is');
 var Or = BinOp.extend('Or');
 var And = BinOp.extend('And');
 var Not = UnaryOp.extend('Not');
@@ -280,6 +281,7 @@ module.exports = {
     LookupVal: LookupVal,
     BinOp: BinOp,
     In: In,
+    Is: Is,
     Or: Or,
     And: And,
     Not: Not,
diff --git a/src/parser.js b/src/parser.js
index 4515fa2..b0bebe1 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -741,7 +741,7 @@ var Parser = Object.extend({
     },
 
     parseIn: function() {
-      var node = this.parseCompare();
+      var node = this.parseIs();
       while(1) {
         // check if the next token is 'not'
         var tok = this.nextToken();
@@ -750,7 +750,7 @@ var Parser = Object.extend({
         // if it wasn't 'not', put it back
         if (!invert) { this.pushToken(tok); }
         if (this.skipSymbol('in')) {
-          var node2 = this.parseCompare();
+          var node2 = this.parseIs();
           node = new nodes.In(node.lineno,
                               node.colno,
                               node,
@@ -770,6 +770,27 @@ var Parser = Object.extend({
       return node;
     },
 
+    // I put this right after "in" in the operator precedence stack. That can
+    // obviously be changed to be closer to Jinja.
+    parseIs: function() {
+      var node = this.parseCompare();
+      // look for an is
+      if (this.skipSymbol('is')) {
+        // look for a not
+        var not = this.skipSymbol('not');
+        // get the next node
+        var node2 = this.parseCompare();
+        // create an Is node using the next node and the info from our Is node.
+        node = new nodes.Is(node.lineno, node.colno, node, node2);
+        // if we have a Not, create a Not node from our Is node.
+        if (not) {
+          node = new nodes.Not(node.lineno, node.colno, node);
+        }
+      }
+      // return the node.
+      return node;
+    },
+
     parseCompare: function() {
         var compareOps = ['==', '===', '!=', '!==', '<', '>', '<=', '>='];
         var expr = this.parseConcat();


Test patch:
diff --git a/src/tests.js b/src/tests.js
new file mode 100644
index 0000000..cfc8f25
--- /dev/null
+++ b/src/tests.js
@@ -0,0 +1,242 @@
+'use strict';
+
+var SafeString = require('./runtime').SafeString;
+
+/**
+ * Returns `true` if the object is a function, otherwise `false`.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.callable = function(value) {
+  return typeof value === 'function';
+};
+
+/**
+ * Returns `true` if the object is strictly not `undefined`.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.defined = function(value) {
+  return value !== undefined;
+};
+
+/**
+ * Returns `true` if the operand (one) is divisble by the test's argument
+ * (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.divisibleby = function(one, two) {
+  return (one % two) === 0;
+};
+
+/**
+ * Returns true if the string has been escaped (i.e., is a SafeString).
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.escaped = function(value) {
+  return value instanceof SafeString;
+};
+
+/**
+ * Returns `true` if the arguments are strictly equal.
+ * @param { any } one
+ * @param { any } two
+ */
+exports.equalto = function(one, two) {
+  return one === two;
+};
+
+// Aliases
+exports.eq = exports.equalto;
+exports.sameas = exports.equalto;
+
+/**
+ * Returns `true` if the value is evenly divisible by 2.
+ * @param { number } value
+ * @returns { boolean }
+ */
+exports.even = function(value) {
+  return value % 2 === 0;
+};
+
+/**
+ * Returns `true` if the value is falsy - if I recall correctly, '', 0, false,
+ * undefined, NaN or null. I don't know if we should stick to the default JS
+ * behavior or attempt to replicate what Python believes should be falsy (i.e.,
+ * empty arrays, empty dicts, not 0...).
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.falsy = function(value) {
+  return !value;
+};
+
+/**
+ * Returns `true` if the operand (one) is greater or equal to the test's
+ * argument (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.ge = function(one, two) {
+  return one >= two;
+};
+
+/**
+ * Returns `true` if the operand (one) is greater than the test's argument
+ * (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.greaterthan = function(one, two) {
+  return one > two;
+};
+
+// alias
+exports.gt = exports.greaterthan;
+
+/**
+ * Returns `true` if the operand (one) is less than or equal to the test's
+ * argument (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.le = function(one, two) {
+  return one <= two;
+};
+
+/**
+ * Returns `true` if the operand (one) is less than the test's passed argument
+ * (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.lessthan = function(one, two) {
+  return one < two;
+};
+
+// alias
+exports.lt = exports.lessthan;
+
+/**
+ * Returns `true` if the string is lowercased.
+ * @param { string } value
+ * @returns { boolean }
+ */
+exports.lower = function(value) {
+  return value.toLowerCase() === value;
+};
+
+/**
+ * Returns `true` if the operand (one) is less than or equal to the test's
+ * argument (two).
+ * @param { number } one
+ * @param { number } two
+ * @returns { boolean }
+ */
+exports.ne = function(one, two) {
+  return one !== two;
+};
+
+/**
+ * Returns true if the value is strictly equal to `null`.
+ * @param { any }
+ * @returns { boolean }
+ */
+exports.null = function(value) {
+  return value === null;
+};
+
+/**
+ * Returns true if value is a number.
+ * @param { any }
+ * @returns { boolean }
+ */
+exports.number = function(value) {
+  return typeof value === 'number';
+};
+
+/**
+ * Returns `true` if the value is *not* evenly divisible by 2.
+ * @param { number } value
+ * @returns { boolean }
+ */
+exports.odd = function(value) {
+  return value % 2 === 1;
+};
+
+/**
+ * Returns `true` if the value is a string, `false` if not.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.string = function(value) {
+  return typeof value === 'string';
+};
+
+/**
+ * Returns `true` if the value is not in the list of things considered falsy:
+ * '', null, undefined, 0, NaN and false.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.truthy = function(value) {
+  return !!value;
+};
+
+/**
+ * Returns `true` if the value is undefined.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.undefined = function(value) {
+  return value === undefined;
+};
+
+/**
+ * Returns `true` if the string is uppercased.
+ * @param { string } value
+ * @returns { boolean }
+ */
+exports.upper = function(value) {
+  return value.toUpperCase() === value;
+};
+
+/**
+ * If ES6 features are available, returns `true` if the value implements the
+ * `Symbol.iterator` method. If not, it's a string or Array.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.iterable = function(value) {
+  if (Symbol) {
+    return !!value[Symbol.iterator];
+  } else {
+    return Array.isArray(value) || typeof value === 'string';
+  }
+};
+
+/**
+ * If ES6 features are available, returns `true` if the value is an object hash
+ * or an ES6 Map. Otherwise just return if it's an object hash.
+ * @param { any } value
+ * @returns { boolean }
+ */
+exports.mapping = function(value) {
+  // only maps and object hashes
+  var bool = value !== null
+      && value !== undefined
+      && typeof value === 'object'
+      && !Array.isArray(value);
+  if (Set) {
+    return bool && !(value instanceof Set);
+  } else {
+    return bool;
+  }
+};
\ No newline at end of file
diff --git a/tests/compiler.js b/tests/compiler.js
index d70a344..27ad30f 100644
--- a/tests/compiler.js
+++ b/tests/compiler.js
@@ -447,6 +447,11 @@
                     bar: 15 },
                   'yes');
 
+            equal('{{ "yes" if 1 is odd else "no"  }}', 'yes');
+            equal('{{ "yes" if 2 is even else "no"  }}', 'yes');
+            equal('{{ "yes" if 2 is odd else "no"  }}', 'no');
+            equal('{{ "yes" if 1 is even else "no"  }}', 'no');
+
             equal('{% if 1 in [1, 2] %}yes{% endif %}', 'yes');
             equal('{% if 1 in [2, 3] %}yes{% endif %}', '');
             equal('{% if 1 not in [1, 2] %}yes{% endif %}', '');
diff --git a/tests/parser.js b/tests/parser.js
index c167053..0191d97 100644
--- a/tests/parser.js
+++ b/tests/parser.js
@@ -238,6 +238,21 @@
                      [nodes.In,
                       [nodes.Symbol, 'x'],
                       [nodes.Symbol, 'y']]]]]);
+                      
+            isAST(parser.parse('{{ x is callable }}'),
+              [nodes.Root,
+                [nodes.Output,
+                  [nodes.Is,
+                    [nodes.Symbol, 'x'],
+                    [nodes.Symbol, 'callable']]]]);
+
+            isAST(parser.parse('{{ x is not callable }}'),
+              [nodes.Root,
+                [nodes.Output,
+                  [nodes.Not,
+                    [nodes.Is,
+                      [nodes.Symbol, 'x'],
+                      [nodes.Symbol, 'callable']]]]]);
         });
 
         it('should parse tilde', function(){
diff --git a/tests/tests.js b/tests/tests.js
new file mode 100644
index 0000000..2d77f4b
--- /dev/null
+++ b/tests/tests.js
@@ -0,0 +1,186 @@
+(function() {
+  'use strict';
+
+  var expect, util, lib, r;
+
+  if (typeof require !== 'undefined') {
+      expect = require('expect.js');
+      util = require('./util');
+      lib = require('../src/lib');
+      r = require('../src/runtime');
+  } else {
+      expect = window.expect;
+      util = window.util;
+      lib = nunjucks.lib;
+      r = nunjucks.runtime;
+  }
+
+  var render = util.render;
+  
+  describe('tests', function() {
+    it('callable should detect callability', function() {
+      var callable = render('{{ foo is callable }}', { foo: function() { return '!!!' } });
+      var uncallable = render('{{ foo is not callable }}', { foo: '!!!' });
+      expect(callable).to.be('true');
+      expect(uncallable).to.be('true');
+    });
+    
+    it('defined should detect definedness', function() {
+      expect(render('{{ foo is defined }}')).to.be('false');
+      expect(render('{{ foo is not defined }}')).to.be('true');
+      expect(render('{{ foo is defined }}', { foo: null })).to.be('true');
+      expect(render('{{ foo is not defined }}', { foo: null })).to.be('false');
+    });
+
+    it('undefined should detect undefinedness', function() {
+      expect(render('{{ foo is undefined }}')).to.be('true');
+      expect(render('{{ foo is not undefined }}')).to.be('false');
+      expect(render('{{ foo is undefined }}', { foo: null })).to.be('false');
+      expect(render('{{ foo is not undefined }}', { foo: null })).to.be('true');
+    });
+
+    it('none/null should detect strictly null values', function() {
+      // required a change in lexer.js @ 220
+      expect(render('{{ null is null }}')).to.be('true');
+      expect(render('{{ none is none }}')).to.be('true');
+      expect(render('{{ none is null }}')).to.be('true');
+      expect(render('{{ foo is null }}')).to.be('false');
+      expect(render('{{ foo is not null }}', { foo: null })).to.be('false');
+    });
+
+    it('divisibleby should detect divisibility', function() {
+      var divisible = render('{{ "6" is divisibleby(3) }}');
+      var notDivisible = render('{{ 3 is not divisibleby(2) }}');
+      expect(divisible).to.be('true');
+      expect(notDivisible).to.be('true');
+    });
+    
+    it('escaped should test whether or not something is escaped', function() {
+      var escaped = render('{{ (foo | safe) is escaped }}', { foo: 'foobarbaz' });
+      var notEscaped = render('{{ foo is escaped }}', { foo: 'foobarbaz' });
+      expect(escaped).to.be('true');
+      expect(notEscaped).to.be('false');
+    });
+
+    it('even should detect whether or not a number is even', function() {
+      var fiveEven = render('{{ "5" is even }}');
+      var fourNotEven = render('{{ 4 is not even }}');
+      expect(fiveEven).to.be('false');
+      expect(fourNotEven).to.be('false');
+    });
+
+    it('odd should detect whether or not a number is odd', function() {
+      var fiveOdd = render('{{ "5" is odd }}');
+      var fourNotOdd = render('{{ 4 is not odd }}');
+      expect(fiveOdd).to.be('true');
+      expect(fourNotOdd).to.be('true');
+    });
+
+    it('mapping should detect Maps or hashes', function() {
+      var map1 = new Map();
+      var map2 = {};
+      var mapOneIsMapping = render('{{ map is mapping }}', { map: map1 });
+      var mapTwoIsMapping = render('{{ map is mapping }}', { map: map2 });
+      expect(mapOneIsMapping).to.be('true');
+      expect(mapTwoIsMapping).to.be('true');
+    });
+
+    it('falsy should detect whether or not a value is falsy', function() {
+      var zero = render('{{ 0 is falsy }}');
+      var pancakes = render('{{ "pancakes" is not falsy }}');
+      expect(zero).to.be('true');
+      expect(pancakes).to.be('true');
+    });
+  
+    it('truthy should detect whether or not a value is truthy', function() {
+      var nullTruthy = render('{{ null is truthy }}');
+      var pancakesNotTruthy = render('{{ "pancakes" is not truthy }}');
+      expect(nullTruthy).to.be('false');
+      expect(pancakesNotTruthy).to.be('false');
+    });
+
+    it('greaterthan than should detect whether or not a value is less than another', function() {
+      var fiveGreaterThanFour = render('{{ "5" is greaterthan(4) }}');
+      var fourNotGreaterThanTwo = render('{{ 4 is not greaterthan(2) }}');
+      expect(fiveGreaterThanFour).to.be('true');
+      expect(fourNotGreaterThanTwo).to.be('false');
+    });
+
+    it('ge should detect whether or not a value is greater than or equal to another', function() {
+      var fiveGreaterThanEqualToFive = render('{{ "5" is ge(5) }}');
+      var fourNotGreaterThanEqualToTwo = render('{{ 4 is not ge(2) }}');
+      expect(fiveGreaterThanEqualToFive).to.be('true');
+      expect(fourNotGreaterThanEqualToTwo).to.be('false');
+    });
+
+    it('lessthan than should detect whether or not a value is less than another', function() {
+      var fiveLessThanFour = render('{{ "5" is lessthan(4) }}');
+      var fourNotLessThanTwo = render('{{ 4 is not lessthan(2) }}');
+      expect(fiveLessThanFour).to.be('false');
+      expect(fourNotLessThanTwo).to.be('true');
+    });
+    
+    it('le should detect whether or not a value is less than or equal to another', function() {
+      var fiveLessThanEqualToFive = render('{{ "5" is le(5) }}');
+      var fourNotLessThanEqualToTwo = render('{{ 4 is not le(2) }}');
+      expect(fiveLessThanEqualToFive).to.be('true');
+      expect(fourNotLessThanEqualToTwo).to.be('true');
+    });
+
+    it('ne should detect whether or not a value is not equal to another', function() {
+      var five = render('{{ 5 is ne(5) }}');
+      var four = render('{{ 4 is not ne(2) }}');
+      expect(five).to.be('false');
+      expect(four).to.be('false');
+    });
+
+    it('iterable should detect whether or not a value is iterable', function() {
+      var iterable = (function* iterable() { return true; }());
+      var generatorIsIterable = render('{{ fn is iterable }}', { fn: iterable });
+      var arrayIsNotIterable = render('{{ arr is not iterable }}', { arr: [] });
+      var mapIsIterable = render('{{ map is iterable }}', { map: new Map() });
+      var setIsNotIterable = render('{{ set is not iterable }}', { set: new Set() });
+      expect(generatorIsIterable).to.be('true');
+      expect(arrayIsNotIterable).to.be('false');
+      expect(mapIsIterable).to.be('true');
+      expect(setIsNotIterable).to.be('false');
+    });
+
+    it('number should detect whether a value is numeric', function() {
+      var num = render('{{ 5 is number }}');
+      var str = render('{{ "42" is number }}');
+      expect(num).to.be('true');
+      expect(str).to.be('false');
+    });
+    
+    it('string should detect whether a value is a string', function() {
+      var num = render('{{ 5 is string }}');
+      var str = render('{{ "42" is string }}');
+      expect(num).to.be('false');
+      expect(str).to.be('true');
+    });
+    
+    it('equalto should detect value equality', function() {
+      var same = render('{{ 1 is equalto(2) }}');
+      var notSame = render('{{ 2 is not equalto(2) }}');
+      expect(same).to.be('false');
+      expect(notSame).to.be('false');
+    });
+    
+    it('sameas should alias to equalto', function() {
+      var obj = {};
+      var same = render('{{ obj1 is sameas(obj2) }}', { obj1: obj, obj2: obj });
+      expect(same).to.be('true');
+    });
+    
+    it('lower should detect whether or not a string is lowercased', function() {
+      expect(render('{{ "foobar" is lower }}')).to.be('true');
+      expect(render('{{ "Foobar" is lower }}')).to.be('false');
+    });
+    
+    it('upper should detect whether or not a string is uppercased', function() {
+      expect(render('{{ "FOOBAR" is upper }}')).to.be('true');
+      expect(render('{{ "Foobar" is upper }}')).to.be('false');
+    });
+  });
+})();
\ No newline at end of file