diff --git a/src/main/java/com/hubspot/jinjava/LegacyOverrides.java b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java index bd3732455..396a98bb5 100644 --- a/src/main/java/com/hubspot/jinjava/LegacyOverrides.java +++ b/src/main/java/com/hubspot/jinjava/LegacyOverrides.java @@ -21,6 +21,7 @@ public interface LegacyOverrides extends WithLegacyOverrides { .withAllowAdjacentTextNodes(true) .withUseTrimmingForNotesAndExpressions(true) .withKeepNullableLoopValues(true) + .withKeepTrailingNewline(true) .build(); LegacyOverrides ALL = new Builder() .withEvaluateMapKeys(true) @@ -32,6 +33,7 @@ public interface LegacyOverrides extends WithLegacyOverrides { .withAllowAdjacentTextNodes(true) .withUseTrimmingForNotesAndExpressions(true) .withKeepNullableLoopValues(true) + .withKeepTrailingNewline(false) .build(); @Value.Default @@ -79,6 +81,17 @@ default boolean isKeepNullableLoopValues() { return false; } + /** + * When {@code false} (default, legacy behaviour), the trailing newline of + * the rendered output is preserved — matching Jinjava's historical behaviour. + * When {@code true}, a single trailing newline is stripped from the rendered + * output, matching Python Jinja2's default ({@code keep_trailing_newline=False}). + */ + @Value.Default + default boolean isKeepTrailingNewline() { + return true; + } + class Builder extends ImmutableLegacyOverrides.Builder {} static Builder newBuilder() { diff --git a/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java index 27b8cea87..2f004ff24 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java +++ b/src/main/java/com/hubspot/jinjava/interpret/JinjavaInterpreter.java @@ -510,7 +510,7 @@ private String render(Node root, boolean processExtendRoots, long renderLimit) { } if (ignoredOutput.length() > 0) { - return ( + return stripTrailingNewlineIfNeeded( EagerReconstructionUtils.labelWithNotes( EagerReconstructionUtils.wrapInTag( ignoredOutput.toString(), @@ -524,7 +524,7 @@ private String render(Node root, boolean processExtendRoots, long renderLimit) { output.getValue() ); } - return output.getValue(); + return stripTrailingNewlineIfNeeded(output.getValue()); } finally { if (pushed) { JinjavaInterpreter.popCurrent(); @@ -532,6 +532,18 @@ private String render(Node root, boolean processExtendRoots, long renderLimit) { } } + /** + * Strips a single trailing newline from the rendered output when + * {@code keepTrailingNewline} is {@code false} in {@link LegacyOverrides}, + * matching Python Jinja2's default {@code keep_trailing_newline=False} behaviour. + */ + private String stripTrailingNewlineIfNeeded(String output) { + if (!config.getLegacyOverrides().isKeepTrailingNewline() && output.endsWith("\n")) { + return output.substring(0, output.length() - 1); + } + return output; + } + private void resolveBlockStubs(OutputList output) { resolveBlockStubs(output, new Stack<>()); } 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..53cc2a047 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java @@ -0,0 +1,123 @@ +package com.hubspot.jinjava; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +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 itKeepsTrailingNewlineWhenLegacyOverrideIsTrue() { + Jinjava jinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withKeepTrailingNewline(true).build() + ) + .build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itKeepsTrailingNewlineWithNoneLegacyOverrides() { + // LegacyOverrides.NONE defaults keepTrailingNewline=true (legacy behaviour) + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withLegacyOverrides(LegacyOverrides.NONE).build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello\n"); + } + + // ── keepTrailingNewline=false (Python-compatible: strip trailing \n) ──────── + + @Test + public void itStripsTrailingNewlineWhenLegacyOverrideIsFalse() { + Jinjava jinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withKeepTrailingNewline(false).build() + ) + .build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + @Test + public void itKeepsTrailingNewlineWithThreePointOLegacyOverrides() { + // LegacyOverrides.THREE_POINT_0 does not opt into Python-compatible stripping + Jinjava jinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides(LegacyOverrides.THREE_POINT_0) + .build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineWithAllLegacyOverrides() { + Jinjava jinjava = new Jinjava( + JinjavaConfig.newBuilder().withLegacyOverrides(LegacyOverrides.ALL).build() + ); + assertThat(jinjava.render(TEMPLATE_WITH_TRAILING_NEWLINE, new HashMap<>())) + .isEqualTo("hello"); + } + + // ── Edge cases ────────────────────────────────────────────────────────────── + + @Test + public void itDoesNotAffectOutputWithNoTrailingNewline() { + Jinjava jinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withKeepTrailingNewline(false).build() + ) + .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( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withKeepTrailingNewline(false).build() + ) + .build() + ); + assertThat(jinjava.render(TEMPLATE_MULTIPLE_TRAILING_NEWLINES, new HashMap<>())) + .isEqualTo("hello\n"); + } + + @Test + public void itStripsTrailingNewlineFromRenderedExpressions() { + Jinjava jinjava = new Jinjava( + JinjavaConfig + .newBuilder() + .withLegacyOverrides( + LegacyOverrides.newBuilder().withKeepTrailingNewline(false).build() + ) + .build() + ); + assertThat(jinjava.render("{{ greeting }}\n", ImmutableMap.of("greeting", "hello"))) + .isEqualTo("hello"); + } +}