Repository: mozilla/nunjucks (js)

Issue:
Feature request: Add switch statement
Hello,

It would be great if there was a `{% switch %}` statement in nunjucks

Something like the following:

```
{% switch someVar %}
  {% case 'value1' %}
   ...
  {% case 'value2' %}
   ...
  {% case 'value3' %}
   ...
  {% default %}
   ...
{% endswitch %}
```

I'm relatively new to nunjucks so i'm not sure of the complexities involved in adding something like this.  I saw this: https://mozilla.github.io/nunjucks/api.html#custom-tags, but it's a little bit beyond me! 

Cheers

Pull request description:
Adds switch tag, as featured in Craft CMS and Liquid. Addresses #967.
## Summary

Proposed change:

Adds a switch tag, as featured in [Liquid](http://shopify.github.io/liquid/tags/control-flow/#casewhen) and [Craft CMS](https://craftcms.com/docs/templating/switch), and as requested in [#967](https://github.com/mozilla/nunjucks/issues/967).

It works about how you'd expect. Switch on a variable, provide cases with potential values, and then an optional default case.

Still need to write docs + changelog entries.

**CAVEAT:** Unlike in JS, cases break automatically. There's no explicit break.
**CAVEAT**: Unlike in Liquid, switch tags use JS nomenclature (instead of Ruby's `case`/`when`/`else` ). It's much closer to Craft's implementation in that regard. If this is an issue, the code is written so that tag names can be easily swapped out.

```django
{% switch foo %}
  {% case 'bar' %}
    BAR
  {% case 'baz' %}
    BAZ
  {% default %}
    NEITHER BAR NOR BAZ
{% endswitch %}
```

The compiled code is fairly straightforward.

```js
function root(env, context, frame, runtime, cb) {
  var lineno = null;
  var colno = null;
  var output = '';
  try {
    var parentTemplate = null;
    switch (runtime.contextOrFrameLookup(context, frame, 'foo')) {
      case 'bar':
        output += 'BAR';
        break;
      case 'baz':
        output += 'BAZ';
        break;
      default:
        output += 'NEITHER FOO NOR BAR';
    }
    if (parentTemplate) {
      parentTemplate.rootRenderFunc(env, context, frame, runtime, cb);
    } else {
      cb(null, output);
    }
  } catch (e) {
    cb(runtime.handleError(e, lineno, colno));
  }
}

return {
  root: root
};
```
Closes #967 .


## 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).

<!-- Tick of items by replacing `[ ]` by `[x]` -->

Patch:
diff --git a/src/compiler.js b/src/compiler.js
index 97b71d0..2aa8fa9 100644
--- a/src/compiler.js
+++ b/src/compiler.js
@@ -555,6 +555,28 @@ var Compiler = Object.extend({
         }, this);
     },
 
+    compileSwitch: function(node, frame) {
+        this.emit('switch (');
+        this.compile(node.expr, frame);
+        this.emit(') {');
+        for (var i = 0; i < node.cases.length; i += 1) {
+            var c = node.cases[i];
+            this.emit('case ');
+            this.compile(c.cond, frame);
+            this.emit(': ');
+            this.compile(c.body, frame);
+            // preserve fall-throughs
+            if (c.body.children.length) {
+                this.emitLine('break;');
+            }
+        }
+        if (node.default) {
+            this.emit('default:');
+            this.compile(node.default, frame);
+        }
+        this.emit('}');
+    },
+
     compileIf: function(node, frame, async) {
         this.emit('if(');
         this._compileExpression(node.cond, frame);
diff --git a/src/nodes.js b/src/nodes.js
index e351422..3d9c581 100644
--- a/src/nodes.js
+++ b/src/nodes.js
@@ -119,6 +119,8 @@ var TemplateRef = Node.extend('TemplateRef', { fields: ['template'] });
 var Extends = TemplateRef.extend('Extends');
 var Include = Node.extend('Include', { fields: ['template', 'ignoreMissing'] });
 var Set = Node.extend('Set', { fields: ['targets', 'value'] });
+var Switch = Node.extend('Switch', { fields: [ 'expr', 'cases', 'default' ]});
+var Case = Node.extend('Case', { fields: ['cond', 'body'] });
 var Output = NodeList.extend('Output');
 var Capture = Node.extend('Capture', { fields: ['body'] });
 var TemplateData = Literal.extend('TemplateData');
@@ -277,6 +279,8 @@ module.exports = {
     Extends: Extends,
     Include: Include,
     Set: Set,
+    Switch: Switch,
+    Case: Case,
     LookupVal: LookupVal,
     BinOp: BinOp,
     In: In,
diff --git a/src/parser.js b/src/parser.js
index 4515fa2..a72a1d5 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -535,6 +535,74 @@ var Parser = Object.extend({
 
         return node;
     },
+    
+    parseSwitch: function() {
+        /*
+         * Store the tag names in variables in case someone ever wants to
+         * customize this.
+         */
+        var switchStart = 'switch';
+        var switchEnd = 'endswitch';
+        var caseStart = 'case';
+        var caseDefault = 'default';
+
+        // Get the switch tag.
+        var tag = this.peekToken();
+
+        // fail early if we get some unexpected tag.
+        if (
+            !this.skipSymbol(switchStart)
+            && !this.skipSymbol(caseStart)
+            && !this.skipSymbol(caseDefault)
+        ) {
+            this.fail('parseSwitch: expected "switch," "case" or "default"', tag.lineno, tag.colno);
+        }
+
+        // parse the switch expression
+        var expr = this.parseExpression();
+        
+        // advance until a start of a case, a default case or an endswitch.
+        this.advanceAfterBlockEnd(switchStart);
+        this.parseUntilBlocks(caseStart, caseDefault, switchEnd);
+
+        // this is the first case. it could also be an endswitch, we'll check.
+        var tok = this.peekToken();
+
+        // create new variables for our cases and default case.
+        var cases = [];
+        var defaultCase;
+
+        // while we're dealing with new cases nodes...
+        do {
+            // skip the start symbol and get the case expression
+            this.skipSymbol(caseStart);
+            var cond = this.parseExpression();
+            this.advanceAfterBlockEnd(switchStart);
+            // get the body of the case node and add it to the array of cases.
+            var body = this.parseUntilBlocks(caseStart, caseDefault, switchEnd);
+            cases.push(new nodes.Case(tok.line, tok.col, cond, body));
+            // get our next case
+            tok = this.peekToken();
+        } while (tok && tok.value === caseStart);
+      
+        // we either have a default case or a switch end.
+        switch (tok.value) {
+            case caseDefault:
+                this.advanceAfterBlockEnd();
+                defaultCase = this.parseUntilBlocks(switchEnd);
+                this.advanceAfterBlockEnd();
+                break;
+            case switchEnd:
+                this.advanceAfterBlockEnd();
+                break;
+            default:
+                // otherwise bail because EOF
+                this.fail('parseSwitch: expected "case," "default" or "endswitch," got EOF.');
+        }
+
+        // and return the switch node.
+        return new nodes.Switch(tag.lineno, tag.colno, expr, cases, defaultCase);
+    },
 
     parseStatement: function () {
         var tok = this.peekToken();
@@ -568,6 +636,7 @@ var Parser = Object.extend({
         case 'import': return this.parseImport();
         case 'from': return this.parseFrom();
         case 'filter': return this.parseFilterStatement();
+        case 'switch': return this.parseSwitch();
         default:
             if (this.extensions.length) {
                 for (var i = 0; i < this.extensions.length; i++) {


Test patch:
diff --git a/tests/compiler.js b/tests/compiler.js
index d70a344..0a0cbe0 100644
--- a/tests/compiler.js
+++ b/tests/compiler.js
@@ -116,6 +116,21 @@
             finish(done);
         });
 
+        it('should compile switch statements', function() {
+            // standard switches
+            var tpl1 = '{% switch foo %}{% case "bar" %}BAR{% case "baz" %}BAZ{% default %}NEITHER FOO NOR BAR{% endswitch %}';
+            // test no-default switches
+            var tpl2 = '{% switch foo %}{% case "bar" %}BAR{% case "baz" %}BAZ{% endswitch %}';
+            // test fall-through cases
+            var tpl3 = '{% switch foo %}{% case "bar" %}{% case "baz" %}BAR{% endswitch %}';
+            equal(tpl1, 'NEITHER FOO NOR BAR');
+            equal(tpl1, { foo: 'bar' }, 'BAR');
+            equal(tpl1, { foo: 'baz' }, 'BAZ');
+            equal(tpl2, '');
+            equal(tpl3, { foo: 'bar' }, 'BAR');
+            equal(tpl3, { foo: 'baz' }, 'BAR');
+        });
+
         it('should compile if blocks', function(done) {
             var tmpl = ('Give me some {% if hungry %}pizza' +
                         '{% else %}water{% endif %}');
diff --git a/tests/parser.js b/tests/parser.js
index c167053..3bc54e7 100644
--- a/tests/parser.js
+++ b/tests/parser.js
@@ -584,6 +584,27 @@
                     [nodes.Output, [nodes.TemplateData, '\n']]]);
         });
 
+        it('should parse switch statements', function() {
+            var tpl = '{% switch foo %}{% case "bar" %}BAR{% case "baz" %}BAZ{% default %}NEITHER FOO NOR BAR{% endswitch %}';
+            isAST(parser.parse(tpl),
+                [nodes.Root,
+                 [nodes.Switch,
+                  [nodes.Symbol, 'foo'],
+                   [[ nodes.Case,
+                     [nodes.Literal, 'bar'],
+                     [nodes.NodeList,
+                      [nodes.Output,
+                       [nodes.TemplateData, 'BAR']]]],
+                    [nodes.Case,
+                     [nodes.Literal, 'baz'],
+                      [nodes.NodeList,
+                       [nodes.Output,
+                        [nodes.TemplateData, 'BAZ']]]]],
+                 [nodes.NodeList,
+                  [ nodes.Output,
+                   [nodes.TemplateData, 'NEITHER FOO NOR BAR']]]]]);
+        });
+
         it('should parse keyword and non-keyword arguments', function() {
             isAST(parser.parse('{{ foo("bar", falalalala, baz="foobar") }}'),
                   [nodes.Root,
@@ -656,7 +677,7 @@
                   [nodes.Root,
                    [nodes.If,
                     [nodes.Symbol, 'x'],
-                    [nodes.NodeList,
+                [nodes.NodeList,
                      [nodes.Output,
                       [nodes.TemplateData, '\n  hi \n']]]]]);