Skip to content

Commit

Permalink
Adds Jinja2-style boolean tests, as requested in mozilla#532 and mozi…
Browse files Browse the repository at this point in the history
…lla#1015.

  - adds 21 Jinja2-style boolean tests
  - 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 `null` instead of a lookup to "null"
    in the context. this was necessary to make the test `null is null` pass
  - CAVEAT: adds dependency on ES6 Symbols, Maps and Sets for `iterable` and
    `mapping` tests. These may need to be removed if we're not okay with
    dumping IE. The tests for these tests require ES6 features and would need
    to be removed as well.
  • Loading branch information
noahlange committed Oct 12, 2017
1 parent 6f4739e commit 2fd547f
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 2 deletions.
17 changes: 17 additions & 0 deletions src/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(' + '),
Expand Down
17 changes: 17 additions & 0 deletions src/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -74,13 +75,17 @@ var Environment = Obj.extend({

this.globals = globals();
this.filters = {};
this.tests = {};
this.asyncFilters = [];
this.extensions = {};
this.extensionsList = [];

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() {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/lexer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions src/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -280,6 +281,7 @@ module.exports = {
LookupVal: LookupVal,
BinOp: BinOp,
In: In,
Is: Is,
Or: Or,
And: And,
Not: Not,
Expand Down
25 changes: 23 additions & 2 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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();
Expand Down
105 changes: 105 additions & 0 deletions src/tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'use strict';

var SafeString = require('./runtime').SafeString;

exports.callable = function(value) {
return typeof value === 'function';
};

exports.defined = function(value) {
return value !== undefined;
};

exports.divisibleby = function(one, two) {
return (one % two) === 0;
};

exports.escaped = function(value) {
return value instanceof SafeString;
};

exports.equalto = function(one, two) {
return one === two;
};

exports.even = function(value) {
return value % 2 === 0;
};

exports.falsy = function(value) {
return !value;
};

exports.greaterthan = function(one, two) {
return one > two;
};

exports.lessthan = function(one, two) {
return one < two;
};

exports.lower = function(value) {
return value.toLowerCase() === value;
};

exports.number = function(value) {
return typeof value === 'number';
};

exports.none = function(value) {
return value === null;
};

exports.null = function(value) {
return value === null;
};

exports.odd = function(value) {
return value % 2 === 1;
};

exports.sameas = function(one, two) {
return Object.is(one, two);
};

exports.string = function(value) {
return typeof value === 'string';
};

exports.truthy = function(value) {
return !!value;
};

exports.undefined = function(value) {
return value === undefined;
};

exports.upper = function(value) {
return value.toUpperCase() === value;
};

/**
* ES6 features required. Unless we're okay with nuking IE support, these may
* need to be removed.
*/

exports.iterable = function(value) {
if (Symbol) {
return !!value[Symbol.iterator];
} else {
throw new Error('ES6 Symbols are unavailable in your browser or runtime environment');
}
};

exports.mapping = function(value) {
// only maps and object hashes
if (Set) {
return value !== null
&& value !== undefined
&& typeof value === 'object'
&& !Array.isArray(value)
&& !(value instanceof Set);
} else {
throw new Error('ES6 Sets are unavailable in your browser or runtime environment.');
}
};
5 changes: 5 additions & 0 deletions tests/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}', '');
Expand Down
15 changes: 15 additions & 0 deletions tests/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(){
Expand Down
Loading

0 comments on commit 2fd547f

Please sign in to comment.