Skip to content

Commit 4b2146b

Browse files
committed
Merge pull request #930 from wycats/visitor-update
Add parent tracking and mutation to AST visitors
2 parents b764fb1 + ec798a7 commit 4b2146b

File tree

3 files changed

+192
-38
lines changed

3 files changed

+192
-38
lines changed

docs/compiler-api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ var scanner = new ImportScanner();
204204
scanner.accept(ast);
205205
```
206206

207+
The current node's ancestors will be maintained in the `parents` array, with the most recent parent listed first.
208+
209+
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.
210+
211+
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.
212+
207213
## JavaScript Compiler
208214

209215
The `Handlebars.JavaScriptCompiler` object has a number of methods that may be customized to alter the output of the compiler:

lib/handlebars/compiler/visitor.js

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,109 @@
1-
/*jshint unused: false */
2-
function Visitor() {}
1+
import Exception from "../exception";
2+
import AST from "./ast";
3+
4+
function Visitor() {
5+
this.parents = [];
6+
}
37

48
Visitor.prototype = {
59
constructor: Visitor,
10+
mutating: false,
611

7-
accept: function(object) {
8-
return object && this[object.type](object);
12+
// Visits a given value. If mutating, will replace the value if necessary.
13+
acceptKey: function(node, name) {
14+
var value = this.accept(node[name]);
15+
if (this.mutating) {
16+
// Hacky sanity check:
17+
if (value && (!value.type || !AST[value.type])) {
18+
throw new Exception('Unexpected node type "' + value.type + '" found when accepting ' + name + ' on ' + node.type);
19+
}
20+
node[name] = value;
21+
}
922
},
1023

11-
Program: function(program) {
12-
var body = program.body,
13-
i, l;
24+
// Performs an accept operation with added sanity check to ensure
25+
// required keys are not removed.
26+
acceptRequired: function(node, name) {
27+
this.acceptKey(node, name);
1428

15-
for(i=0, l=body.length; i<l; i++) {
16-
this.accept(body[i]);
29+
if (!node[name]) {
30+
throw new Exception(node.type + ' requires ' + name);
1731
}
1832
},
1933

34+
// Traverses a given array. If mutating, empty respnses will be removed
35+
// for child elements.
36+
acceptArray: function(array) {
37+
for (var i = 0, l = array.length; i < l; i++) {
38+
this.acceptKey(array, i);
39+
40+
if (!array[i]) {
41+
array.splice(i, 1);
42+
i--;
43+
l--;
44+
}
45+
}
46+
},
47+
48+
accept: function(object) {
49+
if (!object) {
50+
return;
51+
}
52+
53+
if (this.current) {
54+
this.parents.unshift(this.current);
55+
}
56+
this.current = object;
57+
58+
var ret = this[object.type](object);
59+
60+
this.current = this.parents.shift();
61+
62+
if (!this.mutating || ret) {
63+
return ret;
64+
} else if (ret !== false) {
65+
return object;
66+
}
67+
},
68+
69+
Program: function(program) {
70+
this.acceptArray(program.body);
71+
},
72+
2073
MustacheStatement: function(mustache) {
21-
this.accept(mustache.sexpr);
74+
this.acceptRequired(mustache, 'sexpr');
2275
},
2376

2477
BlockStatement: function(block) {
25-
this.accept(block.sexpr);
26-
this.accept(block.program);
27-
this.accept(block.inverse);
78+
this.acceptRequired(block, 'sexpr');
79+
this.acceptKey(block, 'program');
80+
this.acceptKey(block, 'inverse');
2881
},
2982

3083
PartialStatement: function(partial) {
31-
this.accept(partial.partialName);
32-
this.accept(partial.context);
33-
this.accept(partial.hash);
84+
this.acceptRequired(partial, 'sexpr');
3485
},
3586

36-
ContentStatement: function(content) {},
37-
CommentStatement: function(comment) {},
87+
ContentStatement: function(/* content */) {},
88+
CommentStatement: function(/* comment */) {},
3889

3990
SubExpression: function(sexpr) {
40-
var params = sexpr.params, paramStrings = [], hash;
41-
42-
this.accept(sexpr.path);
43-
for(var i=0, l=params.length; i<l; i++) {
44-
this.accept(params[i]);
45-
}
46-
this.accept(sexpr.hash);
91+
this.acceptRequired(sexpr, 'path');
92+
this.acceptArray(sexpr.params);
93+
this.acceptKey(sexpr, 'hash');
4794
},
4895

49-
PathExpression: function(path) {},
96+
PathExpression: function(/* path */) {},
5097

51-
StringLiteral: function(string) {},
52-
NumberLiteral: function(number) {},
53-
BooleanLiteral: function(bool) {},
98+
StringLiteral: function(/* string */) {},
99+
NumberLiteral: function(/* number */) {},
100+
BooleanLiteral: function(/* bool */) {},
54101

55102
Hash: function(hash) {
56-
var pairs = hash.pairs;
57-
58-
for(var i=0, l=pairs.length; i<l; i++) {
59-
this.accept(pairs[i]);
60-
}
103+
this.acceptArray(hash.pairs);
61104
},
62105
HashPair: function(pair) {
63-
this.accept(pair.value);
106+
this.acceptRequired(pair, 'value');
64107
}
65108
};
66109

spec/visitor.js

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
/*global Handlebars */
1+
/*global Handlebars, shouldThrow */
22

33
describe('Visitor', function() {
4-
if (!Handlebars.Visitor) {
4+
if (!Handlebars.Visitor || !Handlebars.print) {
55
return;
66
}
77

@@ -23,9 +23,15 @@ describe('Visitor', function() {
2323
};
2424
visitor.BooleanLiteral = function(bool) {
2525
equal(bool.value, true);
26+
27+
equal(this.parents.length, 4);
28+
equal(this.parents[0].type, 'SubExpression');
29+
equal(this.parents[1].type, 'SubExpression');
30+
equal(this.parents[2].type, 'BlockStatement');
31+
equal(this.parents[3].type, 'Program');
2632
};
2733
visitor.PathExpression = function(id) {
28-
equal(/foo\.bar$/.test(id.original), true);
34+
equal(/(foo\.)?bar$/.test(id.original), true);
2935
};
3036
visitor.ContentStatement = function(content) {
3137
equal(content.value, ' ');
@@ -36,4 +42,103 @@ describe('Visitor', function() {
3642

3743
visitor.accept(Handlebars.parse('{{#foo.bar (foo.bar 1 "2" true) [email protected]}}{{!comment}}{{> bar }} {{/foo.bar}}'));
3844
});
45+
46+
it('should return undefined');
47+
48+
describe('mutating', function() {
49+
describe('fields', function() {
50+
it('should replace value', function() {
51+
var visitor = new Handlebars.Visitor();
52+
53+
visitor.mutating = true;
54+
visitor.StringLiteral = function(string) {
55+
return new Handlebars.AST.NumberLiteral(42, string.locInfo);
56+
};
57+
58+
var ast = Handlebars.parse('{{foo foo="foo"}}');
59+
visitor.accept(ast);
60+
equals(Handlebars.print(ast), '{{ PATH:foo [] HASH{foo=NUMBER{42}} }}\n');
61+
});
62+
it('should treat undefined resonse as identity', function() {
63+
var visitor = new Handlebars.Visitor();
64+
visitor.mutating = true;
65+
66+
var ast = Handlebars.parse('{{foo foo=42}}');
67+
visitor.accept(ast);
68+
equals(Handlebars.print(ast), '{{ PATH:foo [] HASH{foo=NUMBER{42}} }}\n');
69+
});
70+
it('should remove false responses', function() {
71+
var visitor = new Handlebars.Visitor();
72+
73+
visitor.mutating = true;
74+
visitor.Hash = function() {
75+
return false;
76+
};
77+
78+
var ast = Handlebars.parse('{{foo foo=42}}');
79+
visitor.accept(ast);
80+
equals(Handlebars.print(ast), '{{ PATH:foo [] }}\n');
81+
});
82+
it('should throw when removing required values', function() {
83+
shouldThrow(function() {
84+
var visitor = new Handlebars.Visitor();
85+
86+
visitor.mutating = true;
87+
visitor.SubExpression = function() {
88+
return false;
89+
};
90+
91+
var ast = Handlebars.parse('{{foo 42}}');
92+
visitor.accept(ast);
93+
}, Handlebars.Exception, 'MustacheStatement requires sexpr');
94+
});
95+
it('should throw when returning non-node responses', function() {
96+
shouldThrow(function() {
97+
var visitor = new Handlebars.Visitor();
98+
99+
visitor.mutating = true;
100+
visitor.SubExpression = function() {
101+
return {};
102+
};
103+
104+
var ast = Handlebars.parse('{{foo 42}}');
105+
visitor.accept(ast);
106+
}, Handlebars.Exception, 'Unexpected node type "undefined" found when accepting sexpr on MustacheStatement');
107+
});
108+
});
109+
describe('arrays', function() {
110+
it('should replace value', function() {
111+
var visitor = new Handlebars.Visitor();
112+
113+
visitor.mutating = true;
114+
visitor.StringLiteral = function(string) {
115+
return new Handlebars.AST.NumberLiteral(42, string.locInfo);
116+
};
117+
118+
var ast = Handlebars.parse('{{foo "foo"}}');
119+
visitor.accept(ast);
120+
equals(Handlebars.print(ast), '{{ PATH:foo [NUMBER{42}] }}\n');
121+
});
122+
it('should treat undefined resonse as identity', function() {
123+
var visitor = new Handlebars.Visitor();
124+
visitor.mutating = true;
125+
126+
var ast = Handlebars.parse('{{foo 42}}');
127+
visitor.accept(ast);
128+
equals(Handlebars.print(ast), '{{ PATH:foo [NUMBER{42}] }}\n');
129+
});
130+
it('should remove false responses', function() {
131+
var visitor = new Handlebars.Visitor();
132+
133+
visitor.mutating = true;
134+
visitor.NumberLiteral = function() {
135+
return false;
136+
};
137+
138+
var ast = Handlebars.parse('{{foo 42}}');
139+
visitor.accept(ast);
140+
equals(Handlebars.print(ast), '{{ PATH:foo [] }}\n');
141+
});
142+
});
143+
});
39144
});

0 commit comments

Comments
 (0)