Skip to content

Commit 5017d43

Browse files
committed
Support for trim-modifier in single-line logic
1 parent 6127243 commit 5017d43

2 files changed

Lines changed: 201 additions & 32 deletions

File tree

src/main/java/com/hubspot/jinjava/tree/parse/TokenScanner.java

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,17 @@ private Token handleLineStatement() {
352352
while (contentEnd < length && is[contentEnd] != '\n') {
353353
contentEnd++;
354354
}
355-
String inner = String.valueOf(is, contentStart, contentEnd - contentStart).trim();
356-
String syntheticImage =
357-
symbols.getExpressionStartWithTag() +
358-
" " +
359-
inner +
360-
" " +
361-
symbols.getExpressionEndWithTag();
355+
// Do NOT trim inner here — TagToken.parse() calls handleTrim() which detects
356+
// a leading '-' for left-trim whitespace control and a trailing '-' for
357+
// right-trim. Trimming here would strip those control characters before
358+
// TagToken ever sees them.
359+
// Also do not insert a space before the content when it starts with the
360+
// trim char '-', as that space would prevent handleTrim from detecting it.
361+
String inner = String.valueOf(is, contentStart, contentEnd - contentStart);
362+
String prefix = (inner.length() > 0 && inner.charAt(0) == symbols.getTrimChar())
363+
? symbols.getExpressionStartWithTag()
364+
: symbols.getExpressionStartWithTag() + " ";
365+
String syntheticImage = prefix + inner + " " + symbols.getExpressionEndWithTag();
362366

363367
int next = contentEnd;
364368
if (next < length && is[next] == '\n') {
@@ -385,39 +389,60 @@ private Token handleLineStatement() {
385389
}
386390

387391
/**
388-
* Handles a line comment prefix: consumes the entire line (including newline)
389-
* and returns any pending text token, or {@link #DELIMITER_MATCHED} if none.
392+
* Handles a line comment prefix.
393+
*
394+
* <p>Matches Python Jinja2 semantics exactly:
395+
* <ul>
396+
* <li><b>Plain {@code %#}</b>: the comment content is stripped but the line's
397+
* trailing {@code \n} is <em>kept</em>. The comment line is effectively
398+
* replaced by a blank line in the output.</li>
399+
* <li><b>{@code %#-} (trim modifier)</b>: the comment content AND its trailing
400+
* {@code \n} are both stripped, leaving no blank line.</li>
401+
* </ul>
402+
*
403+
* <p>Neither form affects the newline that ended the <em>preceding</em> line.
390404
*/
391405
private Token handleLineComment() {
406+
int afterPrefix = currPost + lineCommentPrefix.length;
407+
boolean hasTrimModifier =
408+
afterPrefix < length && is[afterPrefix] == symbols.getTrimChar();
409+
410+
// Flush buffered text up to (but not including) the current line's indentation.
411+
// The preceding newline is always preserved regardless of the trim modifier.
392412
Token pending = flushTextBefore(lineIndentStart(currPost));
393413

394-
int end = currPost + lineCommentPrefix.length;
414+
// Advance past the comment content to the end of the line.
415+
int end = afterPrefix;
395416
while (end < length && is[end] != '\n') {
396417
end++;
397418
}
398-
int next = end;
399-
if (next < length && is[next] == '\n') {
400-
next++;
401-
currLine++;
402-
lastNewlinePos = next;
419+
420+
if (hasTrimModifier) {
421+
// %#- : strip trailing \n too, leaving no blank line.
422+
int next = end;
423+
if (next < length && is[next] == '\n') {
424+
next++;
425+
currLine++;
426+
lastNewlinePos = next;
427+
}
428+
tokenStart = next;
429+
currPost = next;
430+
} else {
431+
// %# : leave the trailing \n in place so it renders as a blank line.
432+
tokenStart = end;
433+
currPost = end;
403434
}
404-
tokenStart = next;
405-
currPost = next;
406435

407-
// The comment itself produces no token. Return pending text if any,
408-
// otherwise DELIMITER_MATCHED so the caller loops without advancing currPost.
409436
return (pending != null) ? pending : DELIMITER_MATCHED;
410437
}
411438

412439
/**
413440
* Returns the position of the first character of the indentation on the line
414441
* containing {@code pos} — i.e. the position just after the preceding newline
415-
* (or 0 if at the start of input). This is used to exclude leading horizontal
416-
* whitespace from the text token flushed before a line prefix match, so that
417-
* indented line statements and line comments don't leave whitespace in the output.
442+
* (or 0 if at the start of input). Used to exclude leading horizontal whitespace
443+
* from the text token flushed before a line prefix match.
418444
*/
419445
private int lineIndentStart(int pos) {
420-
// Walk back past the horizontal whitespace that isStartOfLine already accepted.
421446
int p = pos - 1;
422447
while (p >= 0 && (is[p] == ' ' || is[p] == '\t')) {
423448
p--;

src/test/java/com/hubspot/jinjava/tree/parse/StringTokenScannerSymbolsTest.java

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5-
5+
import java.util.HashMap;
6+
import org.junit.Before;
7+
import org.junit.Test;
68
import com.google.common.collect.ImmutableMap;
79
import com.google.common.collect.Lists;
810
import com.hubspot.jinjava.BaseJinjavaTest;
911
import com.hubspot.jinjava.Jinjava;
1012
import com.hubspot.jinjava.JinjavaConfig;
1113
import com.hubspot.jinjava.lib.filter.JoinFilterTest.User;
12-
import java.util.HashMap;
13-
import org.junit.Before;
14-
import org.junit.Test;
1514

1615
public class StringTokenScannerSymbolsTest {
1716

@@ -238,7 +237,86 @@ public void defaultBuilderBehavesLikeDefaultSymbols() {
238237
.isEqualTo(defaultJinjava.render(template, ctx));
239238
}
240239

241-
// ── Builder validation ─────────────────────────────────────────────────────
240+
// ── trimBlocks and lstripBlocks ────────────────────────────────────────────
241+
//
242+
// trimBlocks is handled in TokenScanner.emitStringToken(): when a TagToken or
243+
// NoteToken is emitted and trimBlocks=true, the immediately following newline
244+
// is consumed. This is equally true in the string-based path.
245+
//
246+
// lstripBlocks is handled in TreeParser, which operates on the token stream
247+
// produced by TokenScanner. It strips leading horizontal whitespace from any
248+
// TextNode that immediately precedes a TagNode. Since TreeParser is path-agnostic,
249+
// lstripBlocks works identically for both char-based and string-based scanning.
250+
251+
@Test
252+
public void itRespectsTrimBlocksWithAngleSymbols() {
253+
Jinjava j = new Jinjava(
254+
BaseJinjavaTest
255+
.newConfigBuilder()
256+
.withTokenScannerSymbols(ANGLE_SYMBOLS)
257+
.withTrimBlocks(true)
258+
.build()
259+
);
260+
// Without trimBlocks the newline after <% if show %> would appear in output.
261+
// With trimBlocks=true it is consumed by the scanner, so output is "hello".
262+
String result = j.render(
263+
"<% if show %>\nhello\n<% endif %>",
264+
ImmutableMap.of("show", true)
265+
);
266+
assertThat(result).isEqualTo("hello\n");
267+
}
268+
269+
@Test
270+
public void itRespectsTrimBlocksWithLatexSymbols() {
271+
Jinjava j = new Jinjava(
272+
BaseJinjavaTest
273+
.newConfigBuilder()
274+
.withTokenScannerSymbols(LATEX_SYMBOLS)
275+
.withTrimBlocks(true)
276+
.build()
277+
);
278+
String result = j.render(
279+
"\\BLOCK{ if show }\nhello\n\\BLOCK{ endif }",
280+
ImmutableMap.of("show", true)
281+
);
282+
assertThat(result).isEqualTo("hello\n");
283+
}
284+
285+
@Test
286+
public void itRespectsLstripBlocksWithAngleSymbols() {
287+
Jinjava j = new Jinjava(
288+
BaseJinjavaTest
289+
.newConfigBuilder()
290+
.withTokenScannerSymbols(ANGLE_SYMBOLS)
291+
.withLstripBlocks(true)
292+
.withTrimBlocks(true)
293+
.build()
294+
);
295+
// Leading spaces before the tag are stripped by lstripBlocks (TreeParser).
296+
// The newline after the tag is consumed by trimBlocks (TokenScanner).
297+
String result = j.render(
298+
" <% if show %>\nhello\n <% endif %>",
299+
ImmutableMap.of("show", true)
300+
);
301+
assertThat(result).isEqualTo("hello\n");
302+
}
303+
304+
@Test
305+
public void itRespectsLstripBlocksWithLatexSymbols() {
306+
Jinjava j = new Jinjava(
307+
BaseJinjavaTest
308+
.newConfigBuilder()
309+
.withTokenScannerSymbols(LATEX_SYMBOLS)
310+
.withLstripBlocks(true)
311+
.withTrimBlocks(true)
312+
.build()
313+
);
314+
String result = j.render(
315+
" \\BLOCK{ if show }\nhello\n \\BLOCK{ endif }",
316+
ImmutableMap.of("show", true)
317+
);
318+
assertThat(result).isEqualTo("hello\n");
319+
}
242320

243321
@Test
244322
public void builderRejectsEmptyDelimiter() {
@@ -269,6 +347,27 @@ public void itRendersLineStatementPrefix() {
269347
assertThat(j.render(template, ImmutableMap.of("show", false))).isEqualTo("");
270348
}
271349

350+
@Test
351+
public void itRendersLineStatementPrefixWithWhitespaceControl() {
352+
Jinjava j = new Jinjava(
353+
BaseJinjavaTest
354+
.newConfigBuilder()
355+
.withTokenScannerSymbols(
356+
StringTokenScannerSymbols.builder().withLineStatementPrefix("%%").build()
357+
)
358+
.withTrimBlocks(true)
359+
.withLstripBlocks(true)
360+
.build()
361+
);
362+
// "%%- for" strips the newline before the line (leftTrim).
363+
// trimBlocks consumes the newline after each tag line.
364+
// Expected: the \n after {| is stripped, c| repeated col_num times, each
365+
// followed by \n (from the body line), with the \n after c| stripped by
366+
// the leftTrim on %%- endfor.
367+
String template = "before|\n%%- for _ in range(3)\nc|\n%%- endfor\nafter";
368+
assertThat(j.render(template, ImmutableMap.of())).isEqualTo("before|c|c|c|after");
369+
}
370+
272371
@Test
273372
public void itRendersLineStatementPrefixWithLeadingWhitespace() {
274373
Jinjava j = jinjavaWith(
@@ -298,23 +397,66 @@ public void itRendersLineStatementMixedWithBlockDelimiters() {
298397
}
299398

300399
// ── Line comment prefix ────────────────────────────────────────────────────
400+
//
401+
// Semantics:
402+
// %# (plain): comment content stripped, trailing \n KEPT → blank line where comment was
403+
// %#- (trim): comment content AND trailing \n stripped → no blank line
404+
// Neither form affects the newline that ended the preceding line.
301405

302406
@Test
303-
public void itStripsLineCommentPrefix() {
407+
public void itStripsLineCommentPrefixLeavingBlankLine() {
304408
Jinjava j = jinjavaWith(
305409
StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build()
306410
);
411+
// %# keeps its trailing \n → "before\n" + "\n" + "after" = "before\n\nafter"
307412
String template = "before\n%# this whole line is a comment\nafter";
308-
assertThat(j.render(template, new HashMap<>())).isEqualTo("before\nafter");
413+
assertThat(j.render(template, new HashMap<>())).isEqualTo("before\n\nafter");
309414
}
310415

311416
@Test
312417
public void itStripsLineCommentWithLeadingWhitespace() {
313418
Jinjava j = jinjavaWith(
314419
StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build()
315420
);
421+
// Indentation before %# is stripped, trailing \n is kept → still a blank line
316422
String template = "before\n %# indented comment\nafter";
317-
assertThat(j.render(template, new HashMap<>())).isEqualTo("before\nafter");
423+
assertThat(j.render(template, new HashMap<>())).isEqualTo("before\n\nafter");
424+
}
425+
426+
@Test
427+
public void itStripsLineCommentWithTrimModifier() {
428+
Jinjava j = jinjavaWith(
429+
StringTokenScannerSymbols.builder().withLineCommentPrefix("%#").build()
430+
);
431+
// %# keeps trailing \n → blank line: "before\n\nafter"
432+
assertThat(j.render("before\n%# comment\nafter", new HashMap<>()))
433+
.isEqualTo("before\n\nafter");
434+
// %#- strips trailing \n → no blank line: "before\nafter"
435+
assertThat(j.render("before\n%#- comment\nafter", new HashMap<>()))
436+
.isEqualTo("before\nafter");
437+
}
438+
439+
@Test
440+
public void itStripsLineCommentWithoutLeavingBlankLine() {
441+
// %#- strips both content and trailing \n → no blank line.
442+
// "\\begin{document}\n" (preceding \n kept) + "\\section*{...}" (directly)
443+
Jinjava j = new Jinjava(
444+
BaseJinjavaTest
445+
.newConfigBuilder()
446+
.withTokenScannerSymbols(
447+
StringTokenScannerSymbols
448+
.builder()
449+
.withVariableStartString("\\VAR{")
450+
.withVariableEndString("}")
451+
.withLineCommentPrefix("%#")
452+
.build()
453+
)
454+
.build()
455+
);
456+
String template =
457+
"\\begin{document}\n%#-\\VAR{reportHeader}\n\\section*{\\VAR{title}}";
458+
String result = j.render(template, ImmutableMap.of("title", "My Report"));
459+
assertThat(result).isEqualTo("\\begin{document}\n\\section*{My Report}");
318460
}
319461

320462
@Test
@@ -333,7 +475,9 @@ public void itHandlesBothLinePrefixesTogether() {
333475
.build()
334476
);
335477
String template = "%# this is stripped\n%% set x = 7\n<< x >>";
336-
assertThat(j.render(template, new HashMap<>())).isEqualTo("7");
478+
// %# keeps its trailing \n → blank line, then %% set produces nothing,
479+
// then << x >> renders as 7. Result: "\n7"
480+
assertThat(j.render(template, new HashMap<>())).isEqualTo("\n7");
337481
}
338482

339483
// ── Helper ────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)