Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add parent tracking and mutation to AST visitors #930

Merged
merged 3 commits into from
Dec 29, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/compiler-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ var scanner = new ImportScanner();
scanner.accept(ast);
```

The current node's ancestors will be maintained in the `parents` array, with the most recent parent listed first.

The visitor may also be configured to operate in mutation mode by setting the `mutation` field to true. When in this mode, handler methods may return any valid AST node and it will replace the one they are currently operating on. Returning `false` will remove the given value (if valid) and returning `undefined` will leave the node in tact. This return structure only apply to mutation mode and non-mutation mode visitors are free to return whatever values they wish.

Implementors that may need to support mutation mode are encouraged to utilize the `acceptKey`, `acceptRequired` and `acceptArray` helpers which provide the conditional overwrite behavior as well as implement sanity checks where pertinent.

## JavaScript Compiler

The `Handlebars.JavaScriptCompiler` object has a number of methods that may be customized to alter the output of the compiler:
Expand Down
113 changes: 78 additions & 35 deletions lib/handlebars/compiler/visitor.js
Original file line number Diff line number Diff line change
@@ -1,66 +1,109 @@
/*jshint unused: false */
function Visitor() {}
import Exception from "../exception";
import AST from "./ast";

function Visitor() {
this.parents = [];
}

Visitor.prototype = {
constructor: Visitor,
mutating: false,

accept: function(object) {
return object && this[object.type](object);
// Visits a given value. If mutating, will replace the value if necessary.
acceptKey: function(node, name) {
var value = this.accept(node[name]);
if (this.mutating) {
// Hacky sanity check:
if (value && (!value.type || !AST[value.type])) {
throw new Exception('Unexpected node type "' + value.type + '" found when accepting ' + name + ' on ' + node.type);
}
node[name] = value;
}
},

Program: function(program) {
var body = program.body,
i, l;
// Performs an accept operation with added sanity check to ensure
// required keys are not removed.
acceptRequired: function(node, name) {
this.acceptKey(node, name);

for(i=0, l=body.length; i<l; i++) {
this.accept(body[i]);
if (!node[name]) {
throw new Exception(node.type + ' requires ' + name);
}
},

// Traverses a given array. If mutating, empty respnses will be removed
// for child elements.
acceptArray: function(array) {
for (var i = 0, l = array.length; i < l; i++) {
this.acceptKey(array, i);

if (!array[i]) {
array.splice(i, 1);
i--;
l--;
}
}
},

accept: function(object) {
if (!object) {
return;
}

if (this.current) {
this.parents.unshift(this.current);
}
this.current = object;

var ret = this[object.type](object);

this.current = this.parents.shift();

if (!this.mutating || ret) {
return ret;
} else if (ret !== false) {
return object;
}
},

Program: function(program) {
this.acceptArray(program.body);
},

MustacheStatement: function(mustache) {
this.accept(mustache.sexpr);
this.acceptRequired(mustache, 'sexpr');
},

BlockStatement: function(block) {
this.accept(block.sexpr);
this.accept(block.program);
this.accept(block.inverse);
this.acceptRequired(block, 'sexpr');
this.acceptKey(block, 'program');
this.acceptKey(block, 'inverse');
},

PartialStatement: function(partial) {
this.accept(partial.partialName);
this.accept(partial.context);
this.accept(partial.hash);
this.acceptRequired(partial, 'sexpr');
},

ContentStatement: function(content) {},
CommentStatement: function(comment) {},
ContentStatement: function(/* content */) {},
CommentStatement: function(/* comment */) {},

SubExpression: function(sexpr) {
var params = sexpr.params, paramStrings = [], hash;

this.accept(sexpr.path);
for(var i=0, l=params.length; i<l; i++) {
this.accept(params[i]);
}
this.accept(sexpr.hash);
this.acceptRequired(sexpr, 'path');
this.acceptArray(sexpr.params);
this.acceptKey(sexpr, 'hash');
},

PathExpression: function(path) {},
PathExpression: function(/* path */) {},

StringLiteral: function(string) {},
NumberLiteral: function(number) {},
BooleanLiteral: function(bool) {},
StringLiteral: function(/* string */) {},
NumberLiteral: function(/* number */) {},
BooleanLiteral: function(/* bool */) {},

Hash: function(hash) {
var pairs = hash.pairs;

for(var i=0, l=pairs.length; i<l; i++) {
this.accept(pairs[i]);
}
this.acceptArray(hash.pairs);
},
HashPair: function(pair) {
this.accept(pair.value);
this.acceptRequired(pair, 'value');
}
};

Expand Down
111 changes: 108 additions & 3 deletions spec/visitor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*global Handlebars */
/*global Handlebars, shouldThrow */

describe('Visitor', function() {
if (!Handlebars.Visitor) {
if (!Handlebars.Visitor || !Handlebars.print) {
return;
}

Expand All @@ -23,9 +23,15 @@ describe('Visitor', function() {
};
visitor.BooleanLiteral = function(bool) {
equal(bool.value, true);

equal(this.parents.length, 4);
equal(this.parents[0].type, 'SubExpression');
equal(this.parents[1].type, 'SubExpression');
equal(this.parents[2].type, 'BlockStatement');
equal(this.parents[3].type, 'Program');
};
visitor.PathExpression = function(id) {
equal(/foo\.bar$/.test(id.original), true);
equal(/(foo\.)?bar$/.test(id.original), true);
};
visitor.ContentStatement = function(content) {
equal(content.value, ' ');
Expand All @@ -36,4 +42,103 @@ describe('Visitor', function() {

visitor.accept(Handlebars.parse('{{#foo.bar (foo.bar 1 "2" true) [email protected]}}{{!comment}}{{> bar }} {{/foo.bar}}'));
});

it('should return undefined');

describe('mutating', function() {
describe('fields', function() {
it('should replace value', function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.StringLiteral = function(string) {
return new Handlebars.AST.NumberLiteral(42, string.locInfo);
};

var ast = Handlebars.parse('{{foo foo="foo"}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [] HASH{foo=NUMBER{42}} }}\n');
});
it('should treat undefined resonse as identity', function() {
var visitor = new Handlebars.Visitor();
visitor.mutating = true;

var ast = Handlebars.parse('{{foo foo=42}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [] HASH{foo=NUMBER{42}} }}\n');
});
it('should remove false responses', function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.Hash = function() {
return false;
};

var ast = Handlebars.parse('{{foo foo=42}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [] }}\n');
});
it('should throw when removing required values', function() {
shouldThrow(function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.SubExpression = function() {
return false;
};

var ast = Handlebars.parse('{{foo 42}}');
visitor.accept(ast);
}, Handlebars.Exception, 'MustacheStatement requires sexpr');
});
it('should throw when returning non-node responses', function() {
shouldThrow(function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.SubExpression = function() {
return {};
};

var ast = Handlebars.parse('{{foo 42}}');
visitor.accept(ast);
}, Handlebars.Exception, 'Unexpected node type "undefined" found when accepting sexpr on MustacheStatement');
});
});
describe('arrays', function() {
it('should replace value', function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.StringLiteral = function(string) {
return new Handlebars.AST.NumberLiteral(42, string.locInfo);
};

var ast = Handlebars.parse('{{foo "foo"}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [NUMBER{42}] }}\n');
});
it('should treat undefined resonse as identity', function() {
var visitor = new Handlebars.Visitor();
visitor.mutating = true;

var ast = Handlebars.parse('{{foo 42}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [NUMBER{42}] }}\n');
});
it('should remove false responses', function() {
var visitor = new Handlebars.Visitor();

visitor.mutating = true;
visitor.NumberLiteral = function() {
return false;
};

var ast = Handlebars.parse('{{foo 42}}');
visitor.accept(ast);
equals(Handlebars.print(ast), '{{ PATH:foo [] }}\n');
});
});
});
});