Repository: PebbleTemplates/pebble
Pull request #240 (2016-11-14 14:46:09)

Add String Interpolation (as per issue #235)
I also made ConcatenateExpressions be counted as safe when they are just made up of two LiteralStringExpressions, and also added a few toString implementations while I was debugging.

Also, I forgot to turn off Eclipse's format-on-save actions, so some of the whitespace & minor formatting changed a bit. Sorry about that!


Patch:
diff --git a/src/main/java/com/mitchellbosecke/pebble/extension/escaper/EscaperNodeVisitor.java b/src/main/java/com/mitchellbosecke/pebble/extension/escaper/EscaperNodeVisitor.java
index 59ca839a..e2545942 100644
--- a/src/main/java/com/mitchellbosecke/pebble/extension/escaper/EscaperNodeVisitor.java
+++ b/src/main/java/com/mitchellbosecke/pebble/extension/escaper/EscaperNodeVisitor.java
@@ -8,18 +8,25 @@
  ******************************************************************************/
 package com.mitchellbosecke.pebble.extension.escaper;
 
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
 import com.mitchellbosecke.pebble.extension.AbstractNodeVisitor;
 import com.mitchellbosecke.pebble.node.ArgumentsNode;
 import com.mitchellbosecke.pebble.node.AutoEscapeNode;
 import com.mitchellbosecke.pebble.node.NamedArgumentNode;
 import com.mitchellbosecke.pebble.node.PrintNode;
-import com.mitchellbosecke.pebble.node.expression.*;
+import com.mitchellbosecke.pebble.node.expression.BlockFunctionExpression;
+import com.mitchellbosecke.pebble.node.expression.ConcatenateExpression;
+import com.mitchellbosecke.pebble.node.expression.Expression;
+import com.mitchellbosecke.pebble.node.expression.FilterExpression;
+import com.mitchellbosecke.pebble.node.expression.FilterInvocationExpression;
+import com.mitchellbosecke.pebble.node.expression.LiteralStringExpression;
+import com.mitchellbosecke.pebble.node.expression.ParentFunctionExpression;
+import com.mitchellbosecke.pebble.node.expression.TernaryExpression;
 import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
 
-import java.util.ArrayList;
-import java.util.LinkedList;
-import java.util.List;
-
 public class EscaperNodeVisitor extends AbstractNodeVisitor {
 
     private final LinkedList<String> strategies = new LinkedList<>();
@@ -65,6 +72,7 @@ public class EscaperNodeVisitor extends AbstractNodeVisitor {
 
     /**
      * Simply wraps the input expression with a {@link EscapeFilter}.
+     *
      * @param expression
      * @return
      */
@@ -77,7 +85,8 @@ public class EscaperNodeVisitor extends AbstractNodeVisitor {
         List<NamedArgumentNode> namedArgs = new ArrayList<>();
         if (!strategies.isEmpty() && strategies.peek() != null) {
             String strategy = strategies.peek();
-            namedArgs.add(new NamedArgumentNode("strategy", new LiteralStringExpression(strategy, expression.getLineNumber())));
+            namedArgs.add(new NamedArgumentNode("strategy",
+                    new LiteralStringExpression(strategy, expression.getLineNumber())));
         }
         ArgumentsNode args = new ArgumentsNode(null, namedArgs, expression.getLineNumber());
 
@@ -108,14 +117,28 @@ public class EscaperNodeVisitor extends AbstractNodeVisitor {
         // string literals are safe
         if (expression instanceof LiteralStringExpression) {
             safe = true;
-        }
-        else if (expression instanceof ParentFunctionExpression || expression instanceof BlockFunctionExpression) {
+        } else if (expression instanceof ParentFunctionExpression || expression instanceof BlockFunctionExpression) {
+            safe = true;
+        } else if (isSafeConcatenateExpr(expression)) {
             safe = true;
         }
 
         return safe;
     }
 
+    /**
+     * Returns true if {@code expr} is a {@link ConcatenateExpression} made up
+     * of two {@link LiteralStringExpression}s.
+     */
+    private boolean isSafeConcatenateExpr(Expression<?> expr) {
+        if (!(expr instanceof ConcatenateExpression)) {
+            return false;
+        }
+        ConcatenateExpression cexpr = (ConcatenateExpression) expr;
+        return cexpr.getLeftExpression() instanceof LiteralStringExpression
+                && cexpr.getRightExpression() instanceof LiteralStringExpression;
+    }
+
     public void pushAutoEscapeState(boolean auto) {
         active.push(auto);
     }
diff --git a/src/main/java/com/mitchellbosecke/pebble/lexer/LexerImpl.java b/src/main/java/com/mitchellbosecke/pebble/lexer/LexerImpl.java
index ddd345ea..8eff7aa9 100644
--- a/src/main/java/com/mitchellbosecke/pebble/lexer/LexerImpl.java
+++ b/src/main/java/com/mitchellbosecke/pebble/lexer/LexerImpl.java
@@ -8,6 +8,17 @@
  ******************************************************************************/
 package com.mitchellbosecke.pebble.lexer;
 
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 import com.mitchellbosecke.pebble.error.ParserException;
 import com.mitchellbosecke.pebble.lexer.Token.Type;
 import com.mitchellbosecke.pebble.operator.BinaryOperator;
@@ -16,12 +27,6 @@ import com.mitchellbosecke.pebble.utils.Pair;
 import com.mitchellbosecke.pebble.utils.StringLengthComparator;
 import com.mitchellbosecke.pebble.utils.StringUtils;
 
-import java.io.IOException;
-import java.io.Reader;
-import java.util.*;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
 /**
  * This class reads the template input and builds single items out of it.
  * <p>
@@ -56,7 +61,11 @@ public final class LexerImpl implements Lexer {
     private ArrayList<Token> tokens;
 
     /**
-     * Make sure every opening bracket has a closing bracket.
+     * Represents the brackets we are currently inside ordered by how recently
+     * we encountered them. (i.e. peek() will return the most innermost bracket,
+     * getLast() will return the outermost). Brackets in this case includes
+     * double quotes. The String value of the pair is the bracket
+     * representation, and the Integer is the line number.
      */
     private LinkedList<Pair<String, Integer>> brackets;
 
@@ -69,7 +78,7 @@ public final class LexerImpl implements Lexer {
     private LinkedList<State> states;
 
     private enum State {
-        DATA, EXECUTE, PRINT, COMMENT
+        DATA, EXECUTE, PRINT, COMMENT, STRING, STRING_INTERPOLATION
     }
 
     /**
@@ -87,10 +96,25 @@ public final class LexerImpl implements Lexer {
 
     private static final Pattern REGEX_NUMBER = Pattern.compile("^[0-9]+(\\.[0-9]+)?");
 
-    // the negative lookbehind assertion is used to ignore escaped quotation
-    // marks
-    private static final Pattern REGEX_STRING = Pattern
-            .compile("((\").*?(?<!\\\\)(\"))|((').*?(?<!\\\\)('))", Pattern.DOTALL);
+    /**
+     * Matches a double quote
+     */
+    private static final Pattern REGEX_DOUBLEQUOTE = Pattern.compile("^\"");
+
+    /**
+     * Matches everything up to the first interpolation in a double quoted
+     * string
+     */
+    private static final Pattern REGEX_STRING_NON_INTERPOLATED_PART = Pattern
+            .compile("^[^#\"\\\\]*(?:(?:\\\\.|#(?!\\{))[^#\"\\\\]*)*", Pattern.DOTALL);
+
+    /**
+     * Matches single quoted strings and double quoted strings without
+     * interpolation. Extra complexity is due to ignoring escaped quotation
+     * marks.
+     */
+    private static final Pattern REGEX_STRING_PLAIN = Pattern
+            .compile("^\"([^#\"\\\\]*(?:\\\\.[^#\"\\\\]*)*)\"|'([^'\\\\]*(?:\\\\.[^'\\\\]*)*)'", Pattern.DOTALL);
 
     private static final String PUNCTUATION = "()[]{}?:.,|=";
 
@@ -102,11 +126,15 @@ public final class LexerImpl implements Lexer {
     /**
      * Constructor
      *
-     * @param syntax          The primary syntax
-     * @param unaryOperators  The available unary operators
-     * @param binaryOperators The available binary operators
+     * @param syntax
+     *            The primary syntax
+     * @param unaryOperators
+     *            The available unary operators
+     * @param binaryOperators
+     *            The available binary operators
      */
-    public LexerImpl(Syntax syntax, Collection<UnaryOperator> unaryOperators, Collection<BinaryOperator> binaryOperators) {
+    public LexerImpl(Syntax syntax, Collection<UnaryOperator> unaryOperators,
+            Collection<BinaryOperator> binaryOperators) {
         this.syntax = syntax;
         this.unaryOperators = unaryOperators;
         this.binaryOperators = binaryOperators;
@@ -115,9 +143,12 @@ public final class LexerImpl implements Lexer {
     /**
      * This is the main method used to tokenize the raw contents of a template.
      *
-     * @param reader The reader provided from the Loader
-     * @param name   The name of the template (used for meaningful error messages)
-     * @throws ParserException Thrown from the Reader object
+     * @param reader
+     *            The reader provided from the Loader
+     * @param name
+     *            The name of the template (used for meaningful error messages)
+     * @throws ParserException
+     *             Thrown from the Reader object
      */
     @Override
     public TokenStream tokenize(Reader reader, String name) throws ParserException {
@@ -150,22 +181,27 @@ public final class LexerImpl implements Lexer {
          */
         while (this.source.length() > 0) {
             switch (this.state) {
-                case DATA:
-                    lexData();
-                    break;
-                case EXECUTE:
-                    lexExecute();
-                    break;
-                case PRINT:
-                    lexPrint();
-                    break;
-                case COMMENT:
-                    lexComment();
-                    break;
-                default:
-                    break;
+            case DATA:
+                lexData();
+                break;
+            case EXECUTE:
+                lexExecute();
+                break;
+            case PRINT:
+                lexPrint();
+                break;
+            case COMMENT:
+                lexComment();
+                break;
+            case STRING:
+                lexString();
+                break;
+            case STRING_INTERPOLATION:
+                lexStringInterpolation();
+                break;
+            default:
+                break;
             }
-
         }
 
         // end of file token
@@ -181,6 +217,54 @@ public final class LexerImpl implements Lexer {
         return new TokenStream(tokens, source.getFilename());
     }
 
+    private void lexStringInterpolation() throws ParserException {
+        String lastBracket = brackets.peek().getLeft();
+        Matcher matcher = syntax.getRegexInterpolationClose().matcher(source);
+        if (syntax.getInterpolationOpenDelimiter().equals(lastBracket) && matcher.lookingAt()) {
+            brackets.pop();
+            pushToken(Token.Type.STRING_INTERPOLATION_END);
+            source.advance(matcher.end());
+            popState();
+        } else {
+            lexExpression();
+        }
+    }
+
+    private void lexString() throws ParserException {
+        // interpolation
+        Matcher matcher = this.syntax.getRegexInterpolationOpen().matcher(source);
+        if (matcher.lookingAt()) {
+            brackets.push(new Pair<>(syntax.getInterpolationOpenDelimiter(), source.getLineNumber()));
+            pushToken(Token.Type.STRING_INTERPOLATION_START);
+            source.advance(matcher.end());
+            pushState(State.STRING_INTERPOLATION);
+            return;
+        }
+
+        // regular string start (always full string if single quotes)
+        matcher = REGEX_STRING_NON_INTERPOLATED_PART.matcher(source);
+        if (matcher.lookingAt() && matcher.end() > 0) {
+            String token = source.substring(matcher.end());
+            source.advance(matcher.end());
+            pushToken(Token.Type.STRING, token);
+            return;
+        }
+
+        // end of string (which may have contained interpolation)
+        matcher = REGEX_DOUBLEQUOTE.matcher(source);
+        if (matcher.lookingAt()) {
+            String expected = brackets.pop().getLeft();
+
+            if (source.charAt(0) != '"') {
+                throw new ParserException(null, String.format("Unclosed \"%s\"", expected), source.getLineNumber(),
+                        source.getFilename());
+            }
+
+            popState();
+            source.advance(matcher.end());
+        }
+    }
+
     /**
      * The DATA state assumes that we are current NOT in between any pair of
      * meaningful delimiters. We are currently looking for the next "open" or
@@ -380,7 +464,7 @@ public final class LexerImpl implements Lexer {
 
             // opening bracket
             if ("([{".indexOf(character) >= 0) {
-                brackets.push(new Pair<String, Integer>(character, source.getLineNumber()));
+                brackets.push(new Pair<>(character, source.getLineNumber()));
             }
 
             // closing bracket
@@ -407,33 +491,48 @@ public final class LexerImpl implements Lexer {
             return;
         }
 
-        // strings
-        matcher = REGEX_STRING.matcher(source);
+        // Plain (non-interpolated) string
+        matcher = REGEX_STRING_PLAIN.matcher(source);
         if (matcher.lookingAt()) {
             token = source.substring(matcher.end());
-
             source.advance(matcher.end());
-
-            char quotationType = token.charAt(0);
-
-            // remove first and last quotation marks
-            token = token.substring(1, token.length() - 1);
-
-            // remove backslashes used to escape inner quotation marks
-            if (quotationType == '\'') {
-                token = token.replaceAll("\\\\(')", "$1");
-            } else if (quotationType == '"') {
-                token = token.replaceAll("\\\\(\")", "$1");
-            }
-
+            token = unquoteAndUnescape(token);
             pushToken(Token.Type.STRING, token);
             return;
         }
 
+        // Interpolated strings
+        matcher = REGEX_DOUBLEQUOTE.matcher(source);
+        if (matcher.lookingAt()) {
+            brackets.push(new Pair<>("\"", source.getLineNumber()));
+            pushState(State.STRING);
+            source.advance(matcher.end());
+            return;
+        }
+
         // we should have found something and returned by this point
         throw new ParserException(null, String.format("Unexpected character [%s]", source.charAt(0)),
                 source.getLineNumber(), source.getFilename());
+    }
+
+    /**
+     * This method assumes the provided {@code str} starts with a single or
+     * double quote. It removes the wrapping quotes, and un-escapes any quotes
+     * within the string.
+     */
+    private String unquoteAndUnescape(String str) {
+        char quotationType = str.charAt(0);
 
+        // remove first and last quotation marks
+        str = str.substring(1, str.length() - 1);
+
+        // remove backslashes used to escape inner quotation marks
+        if (quotationType == '\'') {
+            str = str.replaceAll("\\\\(')", "$1");
+        } else if (quotationType == '"') {
+            str = str.replaceAll("\\\\(\")", "$1");
+        }
+        return str;
     }
 
     private void checkForLeadingWhitespaceTrim(Token leadingToken) {
@@ -503,7 +602,8 @@ public final class LexerImpl implements Lexer {
      * pass control to the overloaded method that will push this token into a
      * list of tokens that we are maintaining.
      *
-     * @param type The type of Token we are creating
+     * @param type
+     *            The type of Token we are creating
      */
     private Token pushToken(Token.Type type) {
         return pushToken(type, null);
@@ -513,8 +613,10 @@ public final class LexerImpl implements Lexer {
      * Create a Token of a certain type and value and push it into the list of
      * tokens that we are maintaining. `
      *
-     * @param type  The type of token we are creating
-     * @param value The value of the new token
+     * @param type
+     *            The type of token we are creating
+     * @param value
+     *            The value of the new token
      */
     private Token pushToken(Token.Type type, String value) {
         // ignore empty text tokens
@@ -531,7 +633,8 @@ public final class LexerImpl implements Lexer {
      * Pushes the current state onto the stack and then updates the current
      * state to the new state.
      *
-     * @param state The new state to use as the current state
+     * @param state
+     *            The new state to use as the current state
      */
     private void pushState(State state) {
         this.states.push(this.state);
diff --git a/src/main/java/com/mitchellbosecke/pebble/lexer/Syntax.java b/src/main/java/com/mitchellbosecke/pebble/lexer/Syntax.java
index 76d92293..bbc2fab3 100644
--- a/src/main/java/com/mitchellbosecke/pebble/lexer/Syntax.java
+++ b/src/main/java/com/mitchellbosecke/pebble/lexer/Syntax.java
@@ -23,6 +23,10 @@ public final class Syntax {
 
     private final String delimiterPrintClose;
 
+    private final String delimiterInterpolationOpen;
+
+    private final String delimiterInterpolationClose;
+
     private final String whitespaceTrim;
 
     /**
@@ -40,6 +44,9 @@ public final class Syntax {
 
     private final Pattern regexTrailingWhitespaceTrim;
 
+    private final Pattern regexInterpolationOpen;
+    private final Pattern regexInterpolationClose;
+
     /**
      * Regular expressions used to find "verbatim" and "endverbatim" tags.
      */
@@ -51,7 +58,9 @@ public final class Syntax {
 
     public Syntax(final String delimiterCommentOpen, final String delimiterCommentClose,
             final String delimiterExecuteOpen, final String delimiterExecuteClose, final String delimiterPrintOpen,
-            final String delimiterPrintClose, final String whitespaceTrim, final boolean enableNewLineTrimming) {
+            final String delimiterPrintClose, final String delimiterInterpolationOpen,
+            final String delimiterInterpolationClose, final String whitespaceTrim,
+            final boolean enableNewLineTrimming) {
         this.delimiterCommentClose = delimiterCommentClose;
         this.delimiterCommentOpen = delimiterCommentOpen;
         this.delimiterExecuteOpen = delimiterExecuteOpen;
@@ -59,14 +68,16 @@ public final class Syntax {
         this.delimiterPrintOpen = delimiterPrintOpen;
         this.delimiterPrintClose = delimiterPrintClose;
         this.whitespaceTrim = whitespaceTrim;
-        
+        this.delimiterInterpolationClose = delimiterInterpolationClose;
+        this.delimiterInterpolationOpen = delimiterInterpolationOpen;
+
         // Do we trim the newline following a tag?
         String newlineRegexSuffix = enableNewLineTrimming ? POSSIBLE_NEW_LINE : "";
 
         // regexes used to find the individual delimiters
         this.regexPrintClose = Pattern.compile("^\\s*" + Pattern.quote(whitespaceTrim) + "?"
                 + Pattern.quote(delimiterPrintClose) + newlineRegexSuffix);
-        
+
         this.regexExecuteClose = Pattern.compile("^\\s*" + Pattern.quote(whitespaceTrim) + "?"
                 + Pattern.quote(delimiterExecuteClose) + newlineRegexSuffix);
         this.regexCommentClose = Pattern.compile(Pattern.quote(delimiterCommentClose) + newlineRegexSuffix);
@@ -88,6 +99,9 @@ public final class Syntax {
                 + Pattern.quote(delimiterPrintClose) + "|" + Pattern.quote(delimiterExecuteClose) + "|"
                 + Pattern.quote(delimiterCommentClose) + ")");
 
+        this.regexInterpolationOpen = Pattern.compile("^" + Pattern.quote(delimiterInterpolationOpen));
+        this.regexInterpolationClose = Pattern.compile("^\\s*" + Pattern.quote(delimiterInterpolationClose));
+
     }
 
     /**
@@ -132,6 +146,14 @@ public final class Syntax {
         return delimiterPrintClose;
     }
 
+    public String getInterpolationOpenDelimiter() {
+        return delimiterInterpolationOpen;
+    }
+
+    public String getInterpolationCloseDelimiter() {
+        return delimiterInterpolationClose;
+    }
+
     public String getWhitespaceTrim() {
         return whitespaceTrim;
     }
@@ -168,6 +190,14 @@ public final class Syntax {
         return regexVerbatimStart;
     }
 
+    Pattern getRegexInterpolationOpen() {
+        return regexInterpolationOpen;
+    }
+
+    Pattern getRegexInterpolationClose() {
+        return regexInterpolationClose;
+    }
+
     /**
      * Helper class to create new instances of {@link Syntax}.
      */
@@ -185,8 +215,12 @@ public final class Syntax {
 
         private String delimiterPrintClose = "}}";
 
+        private String delimiterInterpolationOpen = "#{";
+
+        private String delimiterInterpolationClose = "}";
+
         private String whitespaceTrim = "-";
-        
+
         private boolean enableNewLineTrimming = true;
 
         /**
@@ -299,11 +333,27 @@ public final class Syntax {
             this.whitespaceTrim = whitespaceTrim;
             return this;
         }
-        
+
+        public String getInterpolationOpenDelimiter() {
+            return delimiterInterpolationOpen;
+        }
+
+        public void setInterpolationOpenDelimiter(String delimiterInterpolationOpen) {
+            this.delimiterInterpolationOpen = delimiterInterpolationOpen;
+        }
+
+        public String getInterpolationCloseDelimiter() {
+            return delimiterInterpolationClose;
+        }
+
+        public void setInterpolationCloseDelimiter(String delimiterInterpolationClose) {
+            this.delimiterInterpolationClose = delimiterInterpolationClose;
+        }
+
         public boolean isEnableNewLineTrimming() {
             return enableNewLineTrimming;
         }
-        
+
         public Builder setEnableNewLineTrimming(boolean enableNewLineTrimming) {
             this.enableNewLineTrimming = enableNewLineTrimming;
             return this;
@@ -311,7 +361,8 @@ public final class Syntax {
 
         public Syntax build() {
             return new Syntax(delimiterCommentOpen, delimiterCommentClose, delimiterExecuteOpen, delimiterExecuteClose,
-                    delimiterPrintOpen, delimiterPrintClose, whitespaceTrim, enableNewLineTrimming);
+                    delimiterPrintOpen, delimiterPrintClose, delimiterInterpolationOpen, delimiterInterpolationClose,
+                    whitespaceTrim, enableNewLineTrimming);
         }
     }
 
diff --git a/src/main/java/com/mitchellbosecke/pebble/lexer/Token.java b/src/main/java/com/mitchellbosecke/pebble/lexer/Token.java
index 4153ab60..e1e7ad30 100644
--- a/src/main/java/com/mitchellbosecke/pebble/lexer/Token.java
+++ b/src/main/java/com/mitchellbosecke/pebble/lexer/Token.java
@@ -1,8 +1,8 @@
 /*******************************************************************************
  * This file is part of Pebble.
- * 
+ *
  * Copyright (c) 2014 by Mitchell Bösecke
- * 
+ *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  ******************************************************************************/
@@ -19,7 +19,7 @@ public class Token {
     private int lineNumber;
 
     public enum Type {
-        EOF, TEXT, EXECUTE_START, EXECUTE_END, PRINT_START, PRINT_END, NAME, NUMBER, STRING, OPERATOR, PUNCTUATION
+        EOF, TEXT, EXECUTE_START, EXECUTE_END, PRINT_START, PRINT_END, NAME, NUMBER, STRING, OPERATOR, PUNCTUATION, STRING_INTERPOLATION_START, STRING_INTERPOLATION_END
     }
 
     public Token(Type type, String value, int lineNumber) {
diff --git a/src/main/java/com/mitchellbosecke/pebble/lexer/TokenStream.java b/src/main/java/com/mitchellbosecke/pebble/lexer/TokenStream.java
index dfda4257..207c7128 100644
--- a/src/main/java/com/mitchellbosecke/pebble/lexer/TokenStream.java
+++ b/src/main/java/com/mitchellbosecke/pebble/lexer/TokenStream.java
@@ -1,8 +1,8 @@
 /*******************************************************************************
  * This file is part of Pebble.
- * 
+ *
  * Copyright (c) 2014 by Mitchell Bösecke
- * 
+ *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  ******************************************************************************/
@@ -24,7 +24,7 @@ public class TokenStream {
 
     /**
      * Constructor for a Token Stream
-     * 
+     *
      * @param tokens
      *            A collection of tokens
      * @param name
@@ -38,7 +38,7 @@ public class TokenStream {
 
     /**
      * Consumes and returns the next token in the stream.
-     * 
+     *
      * @return The next token
      */
     public Token next() {
@@ -49,7 +49,7 @@ public class TokenStream {
      * Checks the current token to see if it matches the provided type. If it
      * doesn't match this will throw a SyntaxException. This will consume a
      * token.
-     * 
+     *
      * @param type
      *            The type of token that we expect
      * @return Token The current token
@@ -64,7 +64,7 @@ public class TokenStream {
      * Checks the current token to see if it matches the provided type. If it
      * doesn't match this will throw a SyntaxException. This will consume a
      * token.
-     * 
+     *
      * @param type
      *            The type of token that we expect
      * @param value
@@ -97,7 +97,7 @@ public class TokenStream {
 
     /**
      * Returns the next token in the stream without consuming it.
-     * 
+     *
      * @return The next token
      */
     public Token peek() {
@@ -106,7 +106,7 @@ public class TokenStream {
 
     /**
      * Returns a future token in the stream without consuming any.
-     * 
+     *
      * @param number
      *            How many tokens to lookahead
      * @return The token we are peeking at
@@ -121,12 +121,12 @@ public class TokenStream {
 
     @Override
     public String toString() {
-        return tokens.toString();
+        return String.format("Current: %s. All: %s", current(), tokens);
     }
 
     /**
      * Looks at the current token. Does not consume the token.
-     * 
+     *
      * @return Token The current token
      */
     public Token current() {
@@ -139,7 +139,7 @@ public class TokenStream {
 
     /**
      * used for testing purposes
-     * 
+     *
      * @return List of tokens
      */
     public ArrayList<Token> getTokens() {
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/ArgumentsNode.java b/src/main/java/com/mitchellbosecke/pebble/node/ArgumentsNode.java
index dce79424..541719b4 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/ArgumentsNode.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/ArgumentsNode.java
@@ -26,7 +26,8 @@ public class ArgumentsNode implements Node {
 
     private final int lineNumber;
 
-    public ArgumentsNode(List<PositionalArgumentNode> positionalArgs, List<NamedArgumentNode> namedArgs, int lineNumber) {
+    public ArgumentsNode(List<PositionalArgumentNode> positionalArgs, List<NamedArgumentNode> namedArgs,
+            int lineNumber) {
         this.positionalArgs = positionalArgs;
         this.namedArgs = namedArgs;
         this.lineNumber = lineNumber;
@@ -107,4 +108,9 @@ public class ArgumentsNode implements Node {
         return result;
     }
 
+    @Override
+    public String toString() {
+        return positionalArgs.toString();
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/PositionalArgumentNode.java b/src/main/java/com/mitchellbosecke/pebble/node/PositionalArgumentNode.java
index cb75300e..e91ccb8d 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/PositionalArgumentNode.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/PositionalArgumentNode.java
@@ -1,8 +1,8 @@
 /*******************************************************************************
  * This file is part of Pebble.
- * 
+ *
  * Copyright (c) 2014 by Mitchell Bösecke
- * 
+ *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  ******************************************************************************/
@@ -28,4 +28,9 @@ public class PositionalArgumentNode implements Node {
         return value;
     }
 
+    @Override
+    public String toString() {
+        return value.toString();
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/BinaryExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/BinaryExpression.java
index 8e0f9c35..2e363245 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/BinaryExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/BinaryExpression.java
@@ -18,6 +18,16 @@ public abstract class BinaryExpression<T> implements Expression<T> {
 
     }
 
+    /**
+     * Sets the left and right expressions. This expression is assumed to be
+     * defined on the same line as the left expression.
+     */
+    public BinaryExpression(Expression<?> left, Expression<?> right) {
+        setLeft(left);
+        setRight(right);
+        setLineNumber(left.getLineNumber());
+    }
+
     private Expression<?> leftExpression;
 
     private Expression<?> rightExpression;
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/ConcatenateExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/ConcatenateExpression.java
index 929fd3fc..9a0bc15f 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/ConcatenateExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/ConcatenateExpression.java
@@ -12,6 +12,13 @@ import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
  */
 public class ConcatenateExpression extends BinaryExpression<Object> {
 
+    public ConcatenateExpression() {
+    }
+
+    public ConcatenateExpression(Expression<?> left, Expression<?> right) {
+        super(left, right);
+    }
+
     @Override
     public String evaluate(PebbleTemplateImpl self, EvaluationContextImpl context) throws PebbleException {
 
@@ -28,4 +35,9 @@ public class ConcatenateExpression extends BinaryExpression<Object> {
         return result.toString();
     }
 
+    @Override
+    public String toString() {
+        return String.format("%s + %s", getLeftExpression(), getRightExpression());
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/ContextVariableExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/ContextVariableExpression.java
index 6eed0603..4022b837 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/ContextVariableExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/ContextVariableExpression.java
@@ -52,4 +52,9 @@ public class ContextVariableExpression implements Expression<Object> {
         return lineNumber;
     }
 
+    @Override
+    public String toString() {
+        return String.format("[%s]", name);
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/FunctionOrMacroInvocationExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/FunctionOrMacroInvocationExpression.java
index bc79dba5..32248ffa 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/FunctionOrMacroInvocationExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/FunctionOrMacroInvocationExpression.java
@@ -71,4 +71,9 @@ public class FunctionOrMacroInvocationExpression implements Expression<Object> {
         return this.lineNumber;
     }
 
+    @Override
+    public String toString() {
+        return String.format("%s%s", this.functionName, this.args);
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBooleanExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBooleanExpression.java
index 4820efaf..b200d24f 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBooleanExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralBooleanExpression.java
@@ -39,4 +39,9 @@ public class LiteralBooleanExpression implements Expression<Boolean> {
         return this.lineNumber;
     }
 
+    @Override
+    public String toString() {
+        return value.toString();
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralDoubleExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralDoubleExpression.java
index 2f44a2c9..bfefce4d 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralDoubleExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralDoubleExpression.java
@@ -39,4 +39,9 @@ public class LiteralDoubleExpression implements Expression<Double> {
         return this.lineNumber;
     }
 
+    @Override
+    public String toString() {
+        return value.toString();
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralLongExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralLongExpression.java
index b57f4f7b..905e4d1e 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralLongExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralLongExpression.java
@@ -38,4 +38,9 @@ public class LiteralLongExpression implements Expression<Long> {
         return this.lineNumber;
     }
 
+    @Override
+    public String toString() {
+        return value.toString();
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralStringExpression.java b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralStringExpression.java
index 49b6a41b..3bbf895b 100644
--- a/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralStringExpression.java
+++ b/src/main/java/com/mitchellbosecke/pebble/node/expression/LiteralStringExpression.java
@@ -43,4 +43,9 @@ public class LiteralStringExpression implements Expression<String> {
         return value;
     }
 
+    @Override
+    public String toString() {
+        return String.format("\"%s\"", value);
+    }
+
 }
diff --git a/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java b/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
index e1a4277b..0662dd1b 100644
--- a/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
+++ b/src/main/java/com/mitchellbosecke/pebble/parser/ExpressionParser.java
@@ -27,6 +27,7 @@ import com.mitchellbosecke.pebble.node.TestInvocationExpression;
 import com.mitchellbosecke.pebble.node.expression.ArrayExpression;
 import com.mitchellbosecke.pebble.node.expression.BinaryExpression;
 import com.mitchellbosecke.pebble.node.expression.BlockFunctionExpression;
+import com.mitchellbosecke.pebble.node.expression.ConcatenateExpression;
 import com.mitchellbosecke.pebble.node.expression.ContextVariableExpression;
 import com.mitchellbosecke.pebble.node.expression.Expression;
 import com.mitchellbosecke.pebble.node.expression.FilterExpression;
@@ -74,7 +75,7 @@ public class ExpressionParser {
      *            All the unary operators
      */
     public ExpressionParser(Parser parser, Map<String, BinaryOperator> binaryOperators,
-                            Map<String, UnaryOperator> unaryOperators) {
+            Map<String, UnaryOperator> unaryOperators) {
         this.parser = parser;
         this.binaryOperators = binaryOperators;
         this.unaryOperators = unaryOperators;
@@ -264,69 +265,117 @@ public class ExpressionParser {
 
         switch (token.getType()) {
 
-            case NAME:
-                switch (token.getValue()) {
-
-                    // a constant?
-                    case "true":
-                    case "TRUE":
-                        node = new LiteralBooleanExpression(true, token.getLineNumber());
-                        break;
-                    case "false":
-                    case "FALSE":
-                        node = new LiteralBooleanExpression(false, token.getLineNumber());
-                        break;
-                    case "none":
-                    case "NONE":
-                    case "null":
-                    case "NULL":
-                        node = new LiteralNullExpression(token.getLineNumber());
-                        break;
-
-                    default:
-
-                        // name of a function?
-                        if (stream.peek().test(Token.Type.PUNCTUATION, "(")) {
-                            node = new FunctionOrMacroNameNode(token.getValue(), stream.peek().getLineNumber());
-                        }
-
-                        // variable name
-                        else {
-                            node = new ContextVariableExpression(token.getValue(), token.getLineNumber());
-                        }
-                        break;
-                }
+        case NAME:
+            switch (token.getValue()) {
+
+            // a constant?
+            case "true":
+            case "TRUE":
+                node = new LiteralBooleanExpression(true, token.getLineNumber());
+                stream.next();
+                break;
+            case "false":
+            case "FALSE":
+                node = new LiteralBooleanExpression(false, token.getLineNumber());
+                stream.next();
+                break;
+            case "none":
+            case "NONE":
+            case "null":
+            case "NULL":
+                node = new LiteralNullExpression(token.getLineNumber());
+                stream.next();
                 break;
 
-            case NUMBER:
-                final String numberValue = token.getValue();
-                if (numberValue.contains(".")) {
-                    node = new LiteralDoubleExpression(Double.valueOf(numberValue), token.getLineNumber());
-                } else {
-                    node = new LiteralLongExpression(Long.valueOf(numberValue), token.getLineNumber());
+            default:
+
+                // name of a function?
+                if (stream.peek().test(Token.Type.PUNCTUATION, "(")) {
+                    node = new FunctionOrMacroNameNode(token.getValue(), stream.peek().getLineNumber());
                 }
 
+                // variable name
+                else {
+                    node = new ContextVariableExpression(token.getValue(), token.getLineNumber());
+                }
+                stream.next();
                 break;
+            }
+            break;
 
-            case STRING:
-                node = new LiteralStringExpression(token.getValue(), token.getLineNumber());
-                break;
+        case NUMBER:
+            final String numberValue = token.getValue();
+            if (numberValue.contains(".")) {
+                node = new LiteralDoubleExpression(Double.valueOf(numberValue), token.getLineNumber());
+            } else {
+                node = new LiteralLongExpression(Long.valueOf(numberValue), token.getLineNumber());
+            }
+            stream.next();
+            break;
 
-            // not found, syntax error
-            default:
-                throw new ParserException(null, String.format("Unexpected token \"%s\" of value \"%s\"", token.getType()
-                        .toString(), token.getValue()), token.getLineNumber(), stream.getFilename());
+        case STRING:
+        case STRING_INTERPOLATION_START:
+            node = parseStringExpression();
+            break;
+
+        // not found, syntax error
+        default:
+            throw new ParserException(null, String.format("Unexpected token \"%s\" of value \"%s\"", token.getType()
+                    .toString(), token.getValue()), token.getLineNumber(), stream.getFilename());
         }
 
         // there may or may not be more to this expression - let's keep looking
-        stream.next();
         return parsePostfixExpression(node);
     }
 
+    private Expression<?> parseStringExpression() throws ParserException {
+        List<Expression<?>> nodes = new ArrayList<>();
+
+        // Sequential strings are not OK, but strings can follow interpolation
+        boolean nextCanBeString = true;
+
+        while (true) {
+            if (nextCanBeString && stream.current().test(Token.Type.STRING)) {
+                Token token = stream.expect(Token.Type.STRING);
+                nodes.add(new LiteralStringExpression(token.getValue(), token.getLineNumber()));
+            } else if (stream.current().test(Token.Type.STRING_INTERPOLATION_START)) {
+                stream.expect(Token.Type.STRING_INTERPOLATION_START);
+                nodes.add(parseExpression());
+                stream.expect(Token.Type.STRING_INTERPOLATION_END);
+                nextCanBeString = true;
+            } else {
+                break;
+            }
+        }
+
+        Expression<?> first = nodes.remove(0);
+        if (nodes.isEmpty()) {
+            return first;
+        }
+
+        ConcatenateExpression expr, firstExpr;
+        expr = firstExpr = new ConcatenateExpression(first, null);
+
+        for (int i = 0; i < nodes.size(); i++) {
+            Expression<?> node = nodes.get(i);
+            if (i == nodes.size() - 1) {
+                expr.setRight(node);
+            } else {
+                ConcatenateExpression newExpr = new ConcatenateExpression(node, null);
+                expr.setRight(newExpr);
+                expr = newExpr;
+            }
+        }
+
+        return firstExpr;
+    }
+
     @SuppressWarnings("unchecked")
     private Expression<?> parseTernaryExpression(Expression<?> expression) throws ParserException {
-        // if the next token isn't a ?, we're not dealing with a ternary expression
-        if (!stream.current().test(Token.Type.PUNCTUATION, "?")) return expression;
+        // if the next token isn't a ?, we're not dealing with a ternary
+        // expression
+        if (!stream.current().test(Token.Type.PUNCTUATION, "?"))
+            return expression;
 
         stream.next();
         Expression<?> expression2 = parseExpression();
@@ -383,10 +432,10 @@ public class ExpressionParser {
          * unique ways for the sake of performance.
          */
         switch (functionName) {
-            case "parent":
-                return new ParentFunctionExpression(parser.peekBlockStack(), stream.current().getLineNumber());
-            case "block":
-                return new BlockFunctionExpression(args, node.getLineNumber());
+        case "parent":
+            return new ParentFunctionExpression(parser.peekBlockStack(), stream.current().getLineNumber());
+        case "block":
+            return new BlockFunctionExpression(args, node.getLineNumber());
         }
 
         return new FunctionOrMacroInvocationExpression(functionName, args, node.getLineNumber());
@@ -454,7 +503,8 @@ public class ExpressionParser {
                 }
             }
 
-            node = new GetAttributeExpression(node, new LiteralStringExpression(token.getValue(), token.getLineNumber()), args,
+            node = new GetAttributeExpression(node,
+                    new LiteralStringExpression(token.getValue(), token.getLineNumber()), args,
                     stream.getFilename(), token.getLineNumber());
 
         } else if (stream.current().test(Token.Type.PUNCTUATION, "[")) {
@@ -515,7 +565,8 @@ public class ExpressionParser {
                 if (!namedArgs.isEmpty()) {
                     throw new ParserException(null,
                             "Positional arguments must be declared before any named arguments.", stream.current()
-                            .getLineNumber(), stream.getFilename());
+                                    .getLineNumber(),
+                            stream.getFilename());
                 }
                 positionalArgs.add(new PositionalArgumentNode(argumentValue));
             } else {
@@ -566,7 +617,7 @@ public class ExpressionParser {
         }
 
         // there's at least one expression in the array
-        List<Expression<?>> elements = new ArrayList<Expression<?>>();
+        List<Expression<?>> elements = new ArrayList<>();
         while (true) {
             Expression<?> expr = parseExpression();
             elements.add(expr);
diff --git a/src/main/java/com/mitchellbosecke/pebble/utils/Pair.java b/src/main/java/com/mitchellbosecke/pebble/utils/Pair.java
index 0f1013f3..f08fb24e 100644
--- a/src/main/java/com/mitchellbosecke/pebble/utils/Pair.java
+++ b/src/main/java/com/mitchellbosecke/pebble/utils/Pair.java
@@ -1,8 +1,8 @@
 /*******************************************************************************
  * This file is part of Pebble.
- * 
+ *
  * Copyright (c) 2014 by Mitchell Bösecke
- * 
+ *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  ******************************************************************************/
@@ -10,7 +10,7 @@ package com.mitchellbosecke.pebble.utils;
 
 /**
  * A small utility class used to pair relevant objects together.
- * 
+ *
  * @author Mitchell
  */
 public class Pair<L, R> {
@@ -32,4 +32,9 @@ public class Pair<L, R> {
         return right;
     }
 
+    @Override
+    public String toString() {
+        return String.format("(%s, %s)", left, right);
+    }
+
 }


Test patch:
diff --git a/src/test/java/com/mitchellbosecke/pebble/StringInterpolationTest.java b/src/test/java/com/mitchellbosecke/pebble/StringInterpolationTest.java
new file mode 100644
index 00000000..4d8f7a51
--- /dev/null
+++ b/src/test/java/com/mitchellbosecke/pebble/StringInterpolationTest.java
@@ -0,0 +1,186 @@
+package com.mitchellbosecke.pebble;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.error.RuntimePebbleException;
+import com.mitchellbosecke.pebble.loader.StringLoader;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+public class StringInterpolationTest {
+
+    @Test
+    public void testSimpleVariableInterpolation() throws Exception {
+        String source = "{{ \"Hello, #{name}\" }}";
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("name", "joe");
+        assertEquals("Hello, joe", evaluate(source, ctx));
+    }
+
+    @Test
+    public void testExpressionInterpolation() throws Exception {
+        String src = "{{ \"1 plus 2 equals #{1 + 2}\" }}";
+        assertEquals("1 plus 2 equals 3", evaluate(src));
+    }
+
+    @Test(expected = RuntimePebbleException.class)
+    public void testUnclosedInterpolation() throws Exception {
+        String src = "{{ \" #{ 1 +\" }}";
+
+        evaluate(src);
+    }
+
+    @Test
+    public void testDoubleClosedInterpolation() throws Exception {
+        String src = "{{ \"#{3}}\" }}";
+        assertEquals("3}", evaluate(src));
+    }
+
+    @Test
+    public void testFunctionInInterpolation() throws Exception {
+        String src = "{{ \"Maximum: #{ max(5, 10) } \" }}";
+        assertEquals("Maximum: 10 ", evaluate(src));
+    }
+
+    @Test
+    public void testVerbatimInterpolation() throws Exception {
+        String src = "{% verbatim %}{{ \"Sum: #{ 1 + 2 }\" }}{% endverbatim %}";
+        assertEquals("{{ \"Sum: #{ 1 + 2 }\" }}", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWithEscapedQuotes() throws Exception {
+        String str = "{{ \"The cow says: #{\"\\\"moo\\\"\"}\" }}";
+        assertEquals("The cow says: \"moo\"", evaluate(str));
+    }
+
+    @Test
+    public void testNestedInterpolation0() throws Exception {
+        String src = "{{ \"Nested: #{ outer + \" #{ inner }\" }\" }}";
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("outer", "OUTER");
+        ctx.put("inner", "INNER");
+        assertEquals("Nested: OUTER INNER", evaluate(src, ctx));
+    }
+
+    @Test
+    public void testNestedInterpolation1() throws Exception {
+        String src = "{{ \"#{\"#{\"#{'hi'}\"}\"}\" }}";
+        assertEquals("hi", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWhitespace0() throws Exception {
+        String src = "{{ \"Outer: #{3+4}\" }}";
+        assertEquals("Outer: 7", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWhitespace1() throws Exception {
+        String src = "{{ \"Outer: #{ 3 + 4 }\" }}";
+        assertEquals("Outer: 7", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWhitespace2() throws Exception {
+        String src = "{{ \"Outer:#{ 3 + 4 }\" }}";
+        assertEquals("Outer:7", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWhitespace3() throws Exception {
+        String src = "{{ \"Outer:#{ 3 + 4 } \" }}";
+        assertEquals("Outer:7 ", evaluate(src));
+    }
+
+    @Test
+    public void testInterpolationWhitespace4() throws Exception {
+        String src = "{{ \"Outer:  #{ 3 + 4 }  \" }}";
+        assertEquals("Outer:  7  ", evaluate(src));
+    }
+
+    @Test
+    public void testStringWithNumberSigns() throws Exception {
+        String src = "{{ \"#bang #crash }!!\" }}";
+        assertEquals("#bang #crash }!!", evaluate(src));
+    }
+
+    @Test
+    public void testStringWithNumberSignsAndInterpolation() throws Exception {
+        String src = "{{ \"The cow said ##{'moo'}#\" }}";
+        assertEquals("The cow said #moo#", evaluate(src));
+    }
+
+    @Test
+    public void testWhitespaceBetweenNumberSignAndCurlyBrace() throws Exception {
+        String src = "{{ \"Green eggs and # {ham}\" }}";
+        assertEquals("Green eggs and # {ham}", evaluate(src));
+    }
+
+    @Test
+    public void testStringInsideInterpolation() throws Exception {
+        String src = "{{ \"Outer: #{ \"inner\" }\" }}";
+        assertEquals("Outer: inner", evaluate(src));
+    }
+
+    @Test
+    public void testSingleQuoteNoInterpolation() throws Exception {
+        String src = "{{ '#{3}'}}";
+        assertEquals("#{3}", evaluate(src));
+    }
+
+    @Test
+    public void testSingleQuoteInsideInterpolation() throws Exception {
+        String src = "{{ \"The cow says: #{'moo' + '#{moo}'}\" }}";
+        assertEquals("The cow says: moo#{moo}", evaluate(src));
+    }
+
+    @Test
+    public void testSequentialInterpolations0() throws Exception {
+        String src = "{{ \"#{1+1}#{2+2}\" }}";
+        assertEquals("24", evaluate(src));
+    }
+
+    @Test
+    public void testSequentialInterpolations1() throws Exception {
+        String src = "{{ \"The #{'cow'} says #{'moo'} and jumps #{'over'} the #{'moon'}\"}}";
+        assertEquals("The cow says moo and jumps over the moon", evaluate(src));
+    }
+
+    @Test
+    public void testVariableContainingInterplationSyntax() throws Exception {
+        String src = "{{ \"Hey #{name}\" }}";
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("name", "#{1+1}");
+        assertEquals("Hey #{1+1}", evaluate(src, ctx));
+    }
+
+    @Test
+    public void testNewlineInInterpolation() throws Exception {
+        String src = "{{ \"Sum = #{ 'egg\negg'}\" }}";
+        assertEquals("Sum = egg\negg", evaluate(src));
+    }
+
+    private String evaluate(String template) throws PebbleException, IOException {
+        return evaluate(template, null);
+    }
+
+    private String evaluate(String template, Map<String, Object> context) throws PebbleException, IOException {
+            Writer writer = new StringWriter();
+
+        new PebbleEngine.Builder()
+                .loader(new StringLoader())
+                .strictVariables(false)
+                .build()
+                .getTemplate(template)
+                .evaluate(writer, context);
+        return writer.toString();
+    }
+}