diff --git a/src/main/java/com/hubspot/jinjava/Jinjava.java b/src/main/java/com/hubspot/jinjava/Jinjava.java index a5d834b48..f3ac8a4fb 100644 --- a/src/main/java/com/hubspot/jinjava/Jinjava.java +++ b/src/main/java/com/hubspot/jinjava/Jinjava.java @@ -250,7 +250,7 @@ public RenderResult renderForResult( .getInterpreterFactory() .newInstance(this, context, renderConfig); try { - String result = interpreter.render(template); + String result = stripTrailingNewlineIfNeeded(interpreter.render(template)); return new RenderResult( result, interpreter.getContext(), @@ -293,6 +293,18 @@ public RenderResult renderForResult( } } + /** + * Strips a single trailing newline from the rendered output when + * {@code keepTrailingNewline} is {@code false} in {@link Config}, + * matching Python Jinja2's default behaviour. + */ + private String stripTrailingNewlineIfNeeded(String output) { + if (!globalConfig.isKeepTrailingNewline() && output.endsWith("\n")) { + return output.substring(0, output.length() - 1); + } + return output; + } + /** * Creates a new interpreter instance using the global context and global config * diff --git a/src/main/java/com/hubspot/jinjava/JinjavaConfig.java b/src/main/java/com/hubspot/jinjava/JinjavaConfig.java index 0ef3c53d6..322f460c6 100644 --- a/src/main/java/com/hubspot/jinjava/JinjavaConfig.java +++ b/src/main/java/com/hubspot/jinjava/JinjavaConfig.java @@ -216,6 +216,18 @@ public boolean isEnableFilterChainOptimization() { return false; } + /** + * When {@code false}, a single trailing newline is stripped from the rendered output, + * matching Python Jinja2's default. + * When {@code true}, the trailing newline of the rendered output is preserved — + * matching Jinjava's historical behaviour. + * Defaults to {@link LegacyOverrides#getDefaultKeepTrailingNewlineBehavior()}. + */ + @Value.Default + public boolean isKeepTrailingNewline() { + return getLegacyOverrides().getDefaultKeepTrailingNewlineBehavior(); + } + @Value.Default public ObjectMapper getObjectMapper() { ObjectMapper objectMapper = new ObjectMapper().registerModule(new Jdk8Module()); diff --git a/src/main/java/com/hubspot/jinjava/LegacyOverrides.java b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java index 3357a7022..ce4fb9626 100644 --- a/src/main/java/com/hubspot/jinjava/LegacyOverrides.java +++ b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java @@ -20,6 +20,7 @@ public class LegacyOverrides { .withKeepNullableLoopValues(true) .withIteratorOnlyReverseFilter(true) .withHandleBackslashInQuotesOnly(true) + .withDefaultKeepTrailingNewlineBehavior(false) .build(); public static final LegacyOverrides ALL = new LegacyOverrides.Builder() .withEvaluateMapKeys(true) @@ -33,6 +34,7 @@ public class LegacyOverrides { .withKeepNullableLoopValues(true) .withIteratorOnlyReverseFilter(true) .withHandleBackslashInQuotesOnly(true) + .withDefaultKeepTrailingNewlineBehavior(false) .build(); private final boolean evaluateMapKeys; private final boolean iterateOverMapKeys; @@ -45,6 +47,7 @@ public class LegacyOverrides { private final boolean keepNullableLoopValues; private final boolean iteratorOnlyReverseFilter; private final boolean handleBackslashInQuotesOnly; + private final boolean defaultKeepTrailingNewlineBehavior; private LegacyOverrides(Builder builder) { evaluateMapKeys = builder.evaluateMapKeys; @@ -58,6 +61,7 @@ private LegacyOverrides(Builder builder) { keepNullableLoopValues = builder.keepNullableLoopValues; iteratorOnlyReverseFilter = builder.iteratorOnlyReverseFilter; handleBackslashInQuotesOnly = builder.handleBackslashInQuotesOnly; + defaultKeepTrailingNewlineBehavior = builder.defaultKeepTrailingNewlineBehavior; } public static Builder newBuilder() { @@ -108,6 +112,15 @@ public boolean isHandleBackslashInQuotesOnly() { return handleBackslashInQuotesOnly; } + /** + * The default value of {@link com.hubspot.jinjava.JinjavaConfig#isKeepTrailingNewline()}. + * {@code true} preserves Jinjava's historical behaviour of keeping the trailing newline; + * {@code false} matches Python Jinja2's default of stripping it. + */ + public boolean getDefaultKeepTrailingNewlineBehavior() { + return defaultKeepTrailingNewlineBehavior; + } + public static class Builder { private boolean evaluateMapKeys = false; @@ -121,6 +134,7 @@ public static class Builder { private boolean keepNullableLoopValues = false; private boolean iteratorOnlyReverseFilter = false; private boolean handleBackslashInQuotesOnly = false; + private boolean defaultKeepTrailingNewlineBehavior = true; private Builder() {} @@ -144,7 +158,10 @@ public static Builder from(LegacyOverrides legacyOverrides) { ) .withKeepNullableLoopValues(legacyOverrides.keepNullableLoopValues) .withIteratorOnlyReverseFilter(legacyOverrides.iteratorOnlyReverseFilter) - .withHandleBackslashInQuotesOnly(legacyOverrides.handleBackslashInQuotesOnly); + .withHandleBackslashInQuotesOnly(legacyOverrides.handleBackslashInQuotesOnly) + .withDefaultKeepTrailingNewlineBehavior( + legacyOverrides.defaultKeepTrailingNewlineBehavior + ); } public Builder withEvaluateMapKeys(boolean evaluateMapKeys) { @@ -207,5 +224,12 @@ public Builder withHandleBackslashInQuotesOnly(boolean handleBackslashInQuotesOn this.handleBackslashInQuotesOnly = handleBackslashInQuotesOnly; return this; } + + public Builder withDefaultKeepTrailingNewlineBehavior( + boolean defaultKeepTrailingNewlineBehavior + ) { + this.defaultKeepTrailingNewlineBehavior = defaultKeepTrailingNewlineBehavior; + return this; + } } } diff --git a/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java b/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java new file mode 100644 index 000000000..9d43062d5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java @@ -0,0 +1,73 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.Jinjava; +import java.util.HashMap; +import org.junit.Test; + +public class TrailingNewlineTest { + + private static final String TEMPLATE_WITH_TRAILING_NEWLINE = "hello\n"; + private static final String TEMPLATE_WITHOUT_TRAILING_NEWLINE = "hello"; + private static final String TEMPLATE_MULTIPLE_TRAILING_NEWLINES = "hello\n\n"; + + // ── keepTrailingNewline=true (legacy default: preserve \n) ───────────────── + + @Test + public void itKeepsTrailingNewlineIsTrue() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(true).build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineDefault() { + // Defaults keepTrailingNewline=false (matching Python behaviour) + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + // ── keepTrailingNewline=false (Python-compatible: strip trailing \n) ──────── + + @Test + public void itStripsTrailingNewlineIsFalse() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(false).build() + ); + + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + // ── Edge cases ────────────────────────────────────────────────────────────── + + @Test + public void itDoesNotAffectOutputWithNoTrailingNewline() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withKeepTrailingNewline(true).build() + ); + + assertThat(jinjava.render(TEMPLATE_WITHOUT_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + @Test + public void itStripsOnlyOneTrailingNewlineNotMultiple() { + // Python only strips a single trailing newline, not all of them. + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render(TEMPLATE_MULTIPLE_TRAILING_NEWLINES, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineFromRenderedExpressions() { + Jinjava jinjava = new Jinjava(); + assertThat(jinjava.render("{{ greeting }}\n", ImmutableMap.of("greeting", "hello"))) + .isEqualTo("hello"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java index c71050414..0544d1a5c 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java @@ -27,7 +27,11 @@ public class AstFilterChainTest { public void setup() { jinjava = new Jinjava( - BaseJinjavaTest.newConfigBuilder().withEnableFilterChainOptimization(true).build() + BaseJinjavaTest + .newConfigBuilder() + .withEnableFilterChainOptimization(true) + .withKeepTrailingNewline(true) + .build() ); context = new HashMap<>(); @@ -123,6 +127,7 @@ public void itSkipsDisabledFilterAndContinuesChain() { BaseJinjavaTest .newConfigBuilder() .withEnableFilterChainOptimization(true) + .withKeepTrailingNewline(true) .withDisabled(disabled) .build() ); diff --git a/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java b/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java index c9849a9a0..20c4729e4 100644 --- a/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java +++ b/src/test/java/com/hubspot/jinjava/interpret/LegacyOperatorPrecedenceTest.java @@ -21,6 +21,7 @@ public void setUp() throws Exception { new Jinjava( BaseJinjavaTest .newConfigBuilder() + .withKeepTrailingNewline(true) .withLegacyOverrides(LegacyOverrides.NONE) .build() ); @@ -28,6 +29,7 @@ public void setUp() throws Exception { new Jinjava( BaseJinjavaTest .newConfigBuilder() + .withKeepTrailingNewline(true) .withLegacyOverrides( LegacyOverrides.newBuilder().withUseNaturalOperatorPrecedence(true).build() ) diff --git a/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java b/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java index 48bbed0c9..c9cfdc8ce 100644 --- a/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java +++ b/src/test/java/com/hubspot/jinjava/interpret/LegacyWhitespaceControlParsingTest.java @@ -21,6 +21,7 @@ public void setUp() throws Exception { new Jinjava( BaseJinjavaTest .newConfigBuilder() + .withKeepTrailingNewline(true) .withLegacyOverrides(LegacyOverrides.NONE) .build() ); @@ -28,6 +29,7 @@ public void setUp() throws Exception { new Jinjava( BaseJinjavaTest .newConfigBuilder() + .withKeepTrailingNewline(true) .withLegacyOverrides( LegacyOverrides.newBuilder().withParseWhitespaceControlStrictly(true).build() ) diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java index 571a7b0bd..62e0ea820 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java @@ -7,6 +7,7 @@ import com.google.common.collect.Lists; import com.google.common.io.Resources; import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.interpret.RenderResult; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -14,10 +15,19 @@ import java.util.Random; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; +import org.junit.Before; import org.junit.Test; public class SliceFilterTest extends BaseJinjavaTest { + @Before + public void setup() { + jinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withKeepTrailingNewline(true).build() + ); + } + @Test public void itSlicesLists() throws Exception { Document dom = Jsoup.parseBodyFragment( diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java index 5333b5f7c..1895a25a4 100644 --- a/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java @@ -11,6 +11,8 @@ import com.google.common.io.Resources; import com.hubspot.jinjava.BaseInterpretingTest; import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.LegacyOverrides; import com.hubspot.jinjava.interpret.InterpretException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @@ -40,6 +42,11 @@ public class ForTagTest extends BaseInterpretingTest { @Override public void baseSetup() { super.baseSetup(); + + jinjava = + new Jinjava( + BaseJinjavaTest.newConfigBuilder().withKeepTrailingNewline(true).build() + ); tag = new ForTag(); try { diff --git a/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java b/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java index a03fe836e..b7af6c074 100644 --- a/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java +++ b/src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java @@ -256,6 +256,7 @@ public void itRespectsTrimBlocksWithAngleSymbols() { .newConfigBuilder() .withTokenScannerSymbols(ANGLE_SYMBOLS) .withTrimBlocks(true) + .withKeepTrailingNewline(true) .build() ); // Without trimBlocks the newline after <% if show %> would appear in output. @@ -274,6 +275,7 @@ public void itRespectsTrimBlocksWithLatexSymbols() { .newConfigBuilder() .withTokenScannerSymbols(LATEX_SYMBOLS) .withTrimBlocks(true) + .withKeepTrailingNewline(true) .build() ); String result = j.render( @@ -291,6 +293,7 @@ public void itRespectsLstripBlocksWithAngleSymbols() { .withTokenScannerSymbols(ANGLE_SYMBOLS) .withLstripBlocks(true) .withTrimBlocks(true) + .withKeepTrailingNewline(true) .build() ); // Leading spaces before the tag are stripped by lstripBlocks (TreeParser). @@ -310,6 +313,7 @@ public void itRespectsLstripBlocksWithLatexSymbols() { .withTokenScannerSymbols(LATEX_SYMBOLS) .withLstripBlocks(true) .withTrimBlocks(true) + .withKeepTrailingNewline(true) .build() ); String result = j.render( @@ -503,7 +507,11 @@ public void itHandlesBothLinePrefixesTogether() { private Jinjava jinjavaWith(StringTokenScannerSymbols symbols) { return new Jinjava( - BaseJinjavaTest.newConfigBuilder().withTokenScannerSymbols(symbols).build() + BaseJinjavaTest + .newConfigBuilder() + .withKeepTrailingNewline(true) + .withTokenScannerSymbols(symbols) + .build() ); } }