Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/main/java/com/hubspot/jinjava/Jinjava.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
*
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/hubspot/jinjava/JinjavaConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,17 @@ public boolean isEnableFilterChainOptimization() {
return false;
}

/**
* When {@code false} (default), 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.
*/
@Value.Default
public boolean isKeepTrailingNewline() {
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmoraleda this may be a confusing ask, but could we have this method delegate to LegacyOverrides. Such that with LegacyOverrides.NONE the default value is true, and with LegacyOverrides.ALL, the default value is false

Suggested change
return false;
return getLegacyOverrides().getDefaultKeepTrailingNewlineBehavior();

Because for optimal parity with jinja, the default behavior should be false, but for someone that doesn't want to change the way jinjava is working for them, then LegacyOverrides.NONE will keep this config value as it was before this PR (preserving the newlines)

Lmk if that makes sense

}

@Value.Default
public ObjectMapper getObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper().registerModule(new Jdk8Module());
Expand Down
73 changes: 73 additions & 0 deletions src/test/java/com/hubspot/jinjava/TrailingNewlineTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<>();
Expand Down Expand Up @@ -123,6 +127,7 @@ public void itSkipsDisabledFilterAndContinuesChain() {
BaseJinjavaTest
.newConfigBuilder()
.withEnableFilterChainOptimization(true)
.withKeepTrailingNewline(true)
.withDisabled(disabled)
.build()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ public void setUp() throws Exception {
new Jinjava(
BaseJinjavaTest
.newConfigBuilder()
.withKeepTrailingNewline(true)
.withLegacyOverrides(LegacyOverrides.NONE)
.build()
);
modern =
new Jinjava(
BaseJinjavaTest
.newConfigBuilder()
.withKeepTrailingNewline(true)
.withLegacyOverrides(
LegacyOverrides.newBuilder().withUseNaturalOperatorPrecedence(true).build()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ public void setUp() throws Exception {
new Jinjava(
BaseJinjavaTest
.newConfigBuilder()
.withKeepTrailingNewline(true)
.withLegacyOverrides(LegacyOverrides.NONE)
.build()
);
modern =
new Jinjava(
BaseJinjavaTest
.newConfigBuilder()
.withKeepTrailingNewline(true)
.withLegacyOverrides(
LegacyOverrides.newBuilder().withParseWhitespaceControlStrictly(true).build()
)
Expand Down
10 changes: 10 additions & 0 deletions src/test/java/com/hubspot/jinjava/lib/filter/SliceFilterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,27 @@
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;
import java.util.List;
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(
Expand Down
7 changes: 7 additions & 0 deletions src/test/java/com/hubspot/jinjava/lib/tag/ForTagTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -274,6 +275,7 @@ public void itRespectsTrimBlocksWithLatexSymbols() {
.newConfigBuilder()
.withTokenScannerSymbols(LATEX_SYMBOLS)
.withTrimBlocks(true)
.withKeepTrailingNewline(true)
.build()
);
String result = j.render(
Expand All @@ -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).
Expand All @@ -310,6 +313,7 @@ public void itRespectsLstripBlocksWithLatexSymbols() {
.withTokenScannerSymbols(LATEX_SYMBOLS)
.withLstripBlocks(true)
.withTrimBlocks(true)
.withKeepTrailingNewline(true)
.build()
);
String result = j.render(
Expand Down Expand Up @@ -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()
);
}
}
Loading