diff --git a/preserve-undefined-execution-mode.md b/preserve-undefined-execution-mode.md new file mode 100644 index 000000000..1684a5c27 --- /dev/null +++ b/preserve-undefined-execution-mode.md @@ -0,0 +1,164 @@ +# PreserveUndefinedExecutionMode + +A new execution mode for Jinjava that preserves unknown/undefined variables as their original template syntax instead of rendering them as empty strings. This enables multi-pass rendering scenarios where templates are processed in stages with different variable contexts available at each stage. + +## Use Case + +Multi-pass template rendering is useful when: +- Some variables are known at compile/build time (static values) +- Other variables are only known at runtime (dynamic values) +- You want to pre-render static parts while preserving dynamic placeholders + +## Usage + +```java +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.mode.PreserveUndefinedExecutionMode; + +Jinjava jinjava = new Jinjava(); +JinjavaConfig config = JinjavaConfig.newBuilder() + .withExecutionMode(PreserveUndefinedExecutionMode.instance()) + .build(); + +Map context = new HashMap<>(); +context.put("staticValue", "STATIC"); + +String template = "{{ staticValue }} - {{ dynamicValue }}"; +String result = jinjava.render(template, context, config); +// Result: "STATIC - {{ dynamicValue }}" +``` + +## Behavior Summary + +| Feature | Input | Context | Output | +|---------|-------|---------|--------| +| Undefined expression | `{{ unknown }}` | `{}` | `{{ unknown }}` | +| Defined expression | `{{ name }}` | `{name: "World"}` | `World` | +| Expression with filter | `{{ name \| upper }}` | `{}` | `{{ name \| upper }}` | +| Property access | `{{ obj.property }}` | `{}` | `{{ obj.property }}` | +| Null value | `{{ nullVar }}` | `{nullVar: null}` | `{{ nullVar }}` | +| Mixed | `Hello {{ name }}, {{ unknown }}!` | `{name: "World"}` | `Hello World, {{ unknown }}!` | + +### Control Structures + +| Feature | Input | Context | Output | +|---------|-------|---------|--------| +| If with known condition | `{% if true %}Hello{% endif %}` | `{}` | `Hello` | +| If with unknown condition | `{% if unknown %}Hello{% endif %}` | `{}` | `{% if unknown %}Hello{% endif %}` | +| If-else with unknown | `{% if unknown %}A{% else %}B{% endif %}` | `{}` | `{% if unknown %}A{% else %}B{% endif %}` | +| For with known iterable | `{% for x in items %}{{ x }}{% endfor %}` | `{items: ["a","b"]}` | `ab` | +| For with unknown iterable | `{% for x in items %}{{ x }}{% endfor %}` | `{}` | `{% for x in items %}{{ x }}{% endfor %}` | + +### Set Tags + +Set tags are preserved with their evaluated RHS values, enabling the variable to be set in subsequent rendering passes: + +| Feature | Input | Context | Output | +|---------|-------|---------|--------| +| Set with known RHS | `{% set x = name %}{{ x }}` | `{name: "World"}` | `{% set x = 'World' %}World` | +| Set with unknown RHS | `{% set x = unknown %}{{ x }}` | `{}` | `{% set x = unknown %}{{ x }}` | + +### Comments + +Comment tags are preserved in output for multi-pass scenarios where comments may contain instructions for later processing stages: + +| Feature | Input | Context | Output | +|---------|-------|---------|--------| +| Simple comment | `{# this is a comment #}` | `{}` | `{# this is a comment #}` | +| Inline comment | `Hello {# comment #} World` | `{}` | `Hello {# comment #} World` | +| Comment with variables | `Hello {{ name }}{# comment #}!` | `{name: "World"}` | `Hello World{# comment #}!` | + +### Macros + +Macros are executed and their output is rendered, with only undefined variables within the macro output being preserved: + +```jinja +{# macros.jinja #} +{% macro greet(name) %}Hello {{ name }}, {{ title }}!{% endmacro %} +``` + +| Feature | Input | Context | Output | +|---------|-------|---------|--------| +| Macro with undefined var | `{{ m.greet('World') }}` | `{}` | `Hello World, {{ title }}!` | +| Macro fully defined | `{{ m.greet('World') }}` | `{title: "Mr"}` | `Hello World, Mr!` | + +## Multi-Pass Rendering Example + +```java +// First pass: render static values +Map staticContext = new HashMap<>(); +staticContext.put("appName", "MyApp"); +staticContext.put("version", "1.0"); + +JinjavaConfig preserveConfig = JinjavaConfig.newBuilder() + .withExecutionMode(PreserveUndefinedExecutionMode.instance()) + .build(); + +String template = "{{ appName }} v{{ version }} - Welcome {{ userName }}!"; +String firstPass = jinjava.render(template, staticContext, preserveConfig); +// Result: "MyApp v1.0 - Welcome {{ userName }}!" + +// Second pass: render dynamic values +Map dynamicContext = new HashMap<>(); +dynamicContext.put("userName", "Alice"); + +JinjavaConfig defaultConfig = JinjavaConfig.newBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .build(); + +String secondPass = jinjava.render(firstPass, dynamicContext, defaultConfig); +// Result: "MyApp v1.0 - Welcome Alice!" +``` + +## Implementation Details + +`PreserveUndefinedExecutionMode` extends `EagerExecutionMode` and configures the context with: + +1. **PreserveUndefinedExpressionStrategy** - Returns original expression syntax when variables are undefined, instead of internal representations +2. **DynamicVariableResolver** - Returns `DeferredValue.instance()` for undefined variables, triggering preservation +3. **PartialMacroEvaluation** - Allows macros to execute and return partial results with undefined parts preserved +4. **PreserveResolvedSetTags** - Preserves set tags even when RHS is fully resolved, enabling multi-pass variable binding +5. **PreserveComments** - Outputs comment tags (`{# ... #}`) as-is instead of stripping them + +### New Context Flag: `isPreserveResolvedSetTags` + +A new context configuration flag was added to allow independent control over set tag preservation: + +```java +// In ContextConfigurationIF +default boolean isPreserveResolvedSetTags() { + return false; +} + +// Usage in Context +context.setPreserveResolvedSetTags(true); +``` + +This flag is checked in `EagerSetTagStrategy` to determine whether fully resolved set tags should be preserved in output or consumed during rendering. + +### Context Flag: `isPreserveComments` + +A context configuration flag to preserve comment tags in output: + +```java +// In ContextConfigurationIF +default boolean isPreserveComments() { + return false; +} + +// Usage in Context +context.setPreserveComments(true); +``` + +This flag is checked in `TreeParser` when processing note tokens. When enabled, comments are output as `TextNode` instead of being discarded. + +## Files Changed + +- `PreserveUndefinedExecutionMode.java` - Main execution mode implementation +- `PreserveUndefinedExpressionStrategy.java` - Expression strategy for preserving original syntax +- `ContextConfigurationIF.java` - Added `isPreserveResolvedSetTags` and `isPreserveComments` flags +- `Context.java` - Added getter/setter for new flags +- `EagerSetTagStrategy.java` - Modified to check `isPreserveResolvedSetTags` flag +- `TreeParser.java` - Modified to check `isPreserveComments` flag +- `PreserveUndefinedExecutionModeTest.java` - Test coverage diff --git a/src/main/java/com/hubspot/jinjava/interpret/Context.java b/src/main/java/com/hubspot/jinjava/interpret/Context.java index a9892c060..8c927e593 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/Context.java +++ b/src/main/java/com/hubspot/jinjava/interpret/Context.java @@ -875,6 +875,15 @@ public TemporaryValueClosable withPartialMacroEvaluation( return temporaryValueClosable; } + public boolean isPreserveResolvedSetTags() { + return contextConfiguration.isPreserveResolvedSetTags(); + } + + public void setPreserveResolvedSetTags(boolean preserveResolvedSetTags) { + contextConfiguration = + contextConfiguration.withPreserveResolvedSetTags(preserveResolvedSetTags); + } + public boolean isUnwrapRawOverride() { return contextConfiguration.isUnwrapRawOverride(); } @@ -883,6 +892,22 @@ public void setUnwrapRawOverride(boolean unwrapRawOverride) { contextConfiguration = contextConfiguration.withUnwrapRawOverride(unwrapRawOverride); } + public boolean isPreserveComments() { + return contextConfiguration.isPreserveComments(); + } + + public void setPreserveComments(boolean preserveComments) { + contextConfiguration = contextConfiguration.withPreserveComments(preserveComments); + } + + public boolean isExtendsDeferred() { + return contextConfiguration.isExtendsDeferred(); + } + + public void setExtendsDeferred(boolean extendsDeferred) { + contextConfiguration = contextConfiguration.withExtendsDeferred(extendsDeferred); + } + public TemporaryValueClosable withUnwrapRawOverride() { TemporaryValueClosable temporaryValueClosable = new TemporaryValueClosable<>( isUnwrapRawOverride(), diff --git a/src/main/java/com/hubspot/jinjava/interpret/ContextConfigurationIF.java b/src/main/java/com/hubspot/jinjava/interpret/ContextConfigurationIF.java index daefd5981..c56d5dc97 100644 --- a/src/main/java/com/hubspot/jinjava/interpret/ContextConfigurationIF.java +++ b/src/main/java/com/hubspot/jinjava/interpret/ContextConfigurationIF.java @@ -38,11 +38,26 @@ default boolean isPartialMacroEvaluation() { return false; } + @Default + default boolean isPreserveResolvedSetTags() { + return false; + } + @Default default boolean isUnwrapRawOverride() { return false; } + @Default + default boolean isPreserveComments() { + return false; + } + + @Default + default boolean isExtendsDeferred() { + return false; + } + @Default default ErrorHandlingStrategy getErrorHandlingStrategy() { return ErrorHandlingStrategy.of(); diff --git a/src/main/java/com/hubspot/jinjava/lib/expression/PreserveUndefinedExpressionStrategy.java b/src/main/java/com/hubspot/jinjava/lib/expression/PreserveUndefinedExpressionStrategy.java new file mode 100644 index 000000000..227816f6d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/expression/PreserveUndefinedExpressionStrategy.java @@ -0,0 +1,54 @@ +package com.hubspot.jinjava.lib.expression; + +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.filter.EscapeFilter; +import com.hubspot.jinjava.objects.SafeString; +import com.hubspot.jinjava.tree.output.RenderedOutputNode; +import com.hubspot.jinjava.tree.parse.ExpressionToken; +import com.hubspot.jinjava.util.Logging; +import org.apache.commons.lang3.StringUtils; + +public class PreserveUndefinedExpressionStrategy implements ExpressionStrategy { + + private static final long serialVersionUID = 1L; + + @Override + public RenderedOutputNode interpretOutput( + ExpressionToken master, + JinjavaInterpreter interpreter + ) { + Object var; + try { + var = interpreter.resolveELExpression(master.getExpr(), master.getLineNumber()); + } catch (DeferredValueException e) { + return new RenderedOutputNode(master.getImage()); + } + + if (var == null) { + return new RenderedOutputNode(master.getImage()); + } + + String result = interpreter.getAsString(var); + + if (interpreter.getConfig().isNestedInterpretationEnabled()) { + if ( + !StringUtils.equals(result, master.getImage()) && + (StringUtils.contains(result, master.getSymbols().getExpressionStart()) || + StringUtils.contains(result, master.getSymbols().getExpressionStartWithTag())) + ) { + try { + result = interpreter.renderFlat(result); + } catch (Exception e) { + Logging.ENGINE_LOG.warn("Error rendering variable node result", e); + } + } + } + + if (interpreter.getContext().isAutoEscape() && !(var instanceof SafeString)) { + result = EscapeFilter.escapeHtmlEntities(result); + } + + return new RenderedOutputNode(result); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java index 24cabb2cd..10ae69335 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/BlockTag.java @@ -20,6 +20,7 @@ import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.DeferredValueException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.interpret.TemplateSyntaxException; import com.hubspot.jinjava.tree.TagNode; @@ -60,6 +61,14 @@ public class BlockTag implements Tag { @Override public OutputNode interpretOutput(TagNode tagNode, JinjavaInterpreter interpreter) { + if (interpreter.getContext().isExtendsDeferred()) { + throw new DeferredValueException( + "block tag", + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + HelperStringTokenizer tagData = new HelperStringTokenizer(tagNode.getHelpers()); if (!tagData.hasNext()) { throw new TemplateSyntaxException( diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java index a17bbea02..3619eb230 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ExtendsTag.java @@ -89,6 +89,7 @@ public class ExtendsTag implements Tag { @Override public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { if (interpreter.getContext().isDeferredExecutionMode()) { + interpreter.getContext().setExtendsDeferred(true); throw new DeferredValueException("extends tag"); } HelperStringTokenizer tokenizer = new HelperStringTokenizer(tagNode.getHelpers()); @@ -101,11 +102,18 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { ); } - String path = interpreter.resolveString( - tokenizer.next(), - tagNode.getLineNumber(), - tagNode.getStartPosition() - ); + String path; + try { + path = + interpreter.resolveString( + tokenizer.next(), + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } catch (DeferredValueException e) { + interpreter.getContext().setExtendsDeferred(true); + throw e; + } path = interpreter.resolveResourceLocation(path); interpreter .getContext() diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java index 9af7f2263..748227c73 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerSetTagStrategy.java @@ -58,6 +58,7 @@ public String run(TagNode tagNode, JinjavaInterpreter interpreter) { if ( eagerExecutionResult.getResult().isFullyResolved() && !interpreter.getContext().isDeferredExecutionMode() && + !interpreter.getContext().isPreserveResolvedSetTags() && (Arrays .stream(variables) .noneMatch(RelativePathResolver.CURRENT_PATH_CONTEXT_KEY::equals) || diff --git a/src/main/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionMode.java b/src/main/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionMode.java new file mode 100644 index 000000000..423d28593 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionMode.java @@ -0,0 +1,52 @@ +package com.hubspot.jinjava.mode; + +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.lib.expression.PreserveUndefinedExpressionStrategy; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * An execution mode that preserves unknown/undefined variables as their original template syntax + * instead of rendering them as empty strings. This enables multi-pass rendering scenarios where + * templates are processed in stages with different variable contexts available at each stage. + * + *

Behavior: + *

    + *
  • Expressions with undefined variables are preserved: {@code {{ unknown }}} → {@code {{ unknown }}}
  • + *
  • Expressions with defined variables are evaluated: {@code {{ name }}} with {name: "World"} → "World"
  • + *
  • Control structures (if/for) with undefined conditions/iterables are preserved
  • + *
  • Set tags are preserved with evaluated RHS: {@code {% set x = name %}} + * with {name: "World"} → {@code {% set x = 'World' %}}
  • + *
  • Macros are executed; undefined variables within macro output are preserved
  • + *
  • Variables explicitly set to null are also preserved
  • + *
+ * + *

This mode extends {@link EagerExecutionMode} to preserve control structures and tags, + * but uses a custom expression strategy to preserve the original expression syntax + * instead of internal representations. It enables partial macro evaluation so that + * macros can execute and produce output with undefined parts preserved. + */ +public class PreserveUndefinedExecutionMode extends EagerExecutionMode { + + private static final ExecutionMode INSTANCE = new PreserveUndefinedExecutionMode(); + + protected PreserveUndefinedExecutionMode() {} + + @SuppressFBWarnings( + value = "HSM_HIDING_METHOD", + justification = "Purposefully overriding to return static instance of this class." + ) + public static ExecutionMode instance() { + return INSTANCE; + } + + @Override + public void prepareContext(Context context) { + super.prepareContext(context); + context.setExpressionStrategy(new PreserveUndefinedExpressionStrategy()); + context.setDynamicVariableResolver(varName -> DeferredValue.instance()); + context.setPartialMacroEvaluation(true); + context.setPreserveResolvedSetTags(true); + context.setPreserveComments(true); + } +} diff --git a/src/main/java/com/hubspot/jinjava/tree/TreeParser.java b/src/main/java/com/hubspot/jinjava/tree/TreeParser.java index 9904e7c7a..e156952b2 100644 --- a/src/main/java/com/hubspot/jinjava/tree/TreeParser.java +++ b/src/main/java/com/hubspot/jinjava/tree/TreeParser.java @@ -147,6 +147,17 @@ private Node nextNode() { ) ); } + if (interpreter.getContext().isPreserveComments()) { + TextToken commentAsText = new TextToken( + token.getImage(), + token.getLineNumber(), + token.getStartPosition(), + symbols + ); + TextNode n = new TextNode(commentAsText); + n.setParent(parent); + return n; + } } else { interpreter.addError( TemplateError.fromException( diff --git a/src/test/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionModeTest.java b/src/test/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionModeTest.java new file mode 100644 index 000000000..2bf3d0c44 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/mode/PreserveUndefinedExecutionModeTest.java @@ -0,0 +1,322 @@ +package com.hubspot.jinjava.mode; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.LegacyOverrides; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class PreserveUndefinedExecutionModeTest { + + private Jinjava jinjava; + private JinjavaConfig config; + + @Before + public void setup() { + jinjava = new Jinjava(); + config = + JinjavaConfig + .newBuilder() + .withExecutionMode(PreserveUndefinedExecutionMode.instance()) + .withLegacyOverrides( + LegacyOverrides.newBuilder().withUsePyishObjectMapper(true).build() + ) + .build(); + } + + private String render(String template) { + return jinjava.renderForResult(template, new HashMap<>(), config).getOutput(); + } + + private String render(String template, Map context) { + return jinjava.renderForResult(template, context, config).getOutput(); + } + + @Test + public void itPreservesUndefinedExpression() { + String output = render("{{ unknown }}"); + assertThat(output).isEqualTo("{{ unknown }}"); + } + + @Test + public void itEvaluatesDefinedExpression() { + Map context = new HashMap<>(); + context.put("name", "World"); + String output = render("{{ name }}", context); + assertThat(output).isEqualTo("World"); + } + + @Test + public void itPreservesUndefinedExpressionWithFilter() { + String output = render("{{ name | upper }}"); + assertThat(output).isEqualTo("{{ name | upper }}"); + } + + @Test + public void itPreservesUndefinedPropertyAccess() { + String output = render("{{ obj.property }}"); + assertThat(output).isEqualTo("{{ obj.property }}"); + } + + @Test + public void itPreservesNullValueExpression() { + Map context = new HashMap<>(); + context.put("nullVar", null); + String output = render("{{ nullVar }}", context); + assertThat(output).isEqualTo("{{ nullVar }}"); + } + + @Test + public void itPreservesMixedDefinedAndUndefined() { + Map context = new HashMap<>(); + context.put("name", "World"); + String output = render("Hello {{ name }}, {{ unknown }}!", context); + assertThat(output).isEqualTo("Hello World, {{ unknown }}!"); + } + + @Test + public void itPreservesComplexExpression() { + Map context = new HashMap<>(); + context.put("known", 5); + String output = render("{{ known + unknown }}", context); + assertThat(output).isEqualTo("{{ known + unknown }}"); + } + + @Test + public void itAllowsMultiPassRendering() { + Map firstPassContext = new HashMap<>(); + firstPassContext.put("staticValue", "STATIC"); + + String template = "{{ staticValue }} - {{ dynamicValue }}"; + String firstPassResult = jinjava + .renderForResult(template, firstPassContext, config) + .getOutput(); + + assertThat(firstPassResult).isEqualTo("STATIC - {{ dynamicValue }}"); + + Map secondPassContext = new HashMap<>(); + secondPassContext.put("dynamicValue", "DYNAMIC"); + JinjavaConfig defaultConfig = JinjavaConfig + .newBuilder() + .withExecutionMode(DefaultExecutionMode.instance()) + .build(); + String secondPassResult = jinjava + .renderForResult(firstPassResult, secondPassContext, defaultConfig) + .getOutput(); + + assertThat(secondPassResult).isEqualTo("STATIC - DYNAMIC"); + } + + @Test + public void itEvaluatesForTagWithKnownIterable() { + Map context = new HashMap<>(); + context.put("items", Arrays.asList("a", "b", "c")); + String output = render("{% for item in items %}{{ item }}{% endfor %}", context); + assertThat(output).isEqualTo("abc"); + } + + @Test + public void itEvaluatesIfTagWithKnownCondition() { + String output = render("{% if true %}Hello{% endif %}"); + assertThat(output).isEqualTo("Hello"); + } + + @Test + public void itEvaluatesIfTagWithFalseCondition() { + String output = render("{% if false %}Hello{% else %}Goodbye{% endif %}"); + assertThat(output).isEqualTo("Goodbye"); + } + + @Test + public void itHandlesNestedUndefinedInKnownStructure() { + Map context = new HashMap<>(); + context.put("items", Arrays.asList("a", "b")); + String output = render( + "{% for item in items %}{{ item }}-{{ unknown }}{% endfor %}", + context + ); + assertThat(output).isEqualTo("a-{{ unknown }}b-{{ unknown }}"); + } + + @Test + public void itPreservesSetTagWithKnownRHSValue() { + Map context = new HashMap<>(); + context.put("name", "World"); + String output = render("{% set x = name %}{{ x }}", context); + // Set tag is preserved with evaluated RHS for multi-pass rendering + assertThat(output).isEqualTo("{% set x = 'World' %}World"); + } + + @Test + public void itPreservesSetTagWithUnknownRHS() { + String output = render("{% set x = unknown %}{{ x }}"); + assertThat(output).isEqualTo("{% set x = unknown %}{{ x }}"); + } + + @Test + public void itPreservesIfTagWithUnknownCondition() { + String output = render("{% if unknown %}Hello{% endif %}"); + assertThat(output).isEqualTo("{% if unknown %}Hello{% endif %}"); + } + + @Test + public void itPreservesIfElseWithUnknownCondition() { + String output = render("{% if unknown %}Hello{% else %}Goodbye{% endif %}"); + assertThat(output).isEqualTo("{% if unknown %}Hello{% else %}Goodbye{% endif %}"); + } + + @Test + public void itPreservesForTagWithUnknownIterable() { + String output = render("{% for item in items %}{{ item }}{% endfor %}"); + assertThat(output).isEqualTo("{% for item in items %}{{ item }}{% endfor %}"); + } + + @Test + public void itPreservesUndefinedInImportedMacro() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("macros.jinja")) { + return "{% macro greet(name) %}Hello {{ name }}, {{ title }}!{% endmacro %}"; + } + return ""; + }); + + String template = "{% import 'macros.jinja' as m %}{{ m.greet('World') }}"; + String output = render(template); + assertThat(output).isEqualTo("Hello World, {{ title }}!"); + } + + @Test + public void itEvaluatesMacroWithAllDefinedVariables() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("macros.jinja")) { + return "{% macro greet(name) %}Hello {{ name }}, {{ title }}!{% endmacro %}"; + } + return ""; + }); + + Map context = new HashMap<>(); + context.put("title", "Mr"); + String template = "{% import 'macros.jinja' as m %}{{ m.greet('World') }}"; + String output = render(template, context); + // When all variables are defined, macro fully evaluates + assertThat(output).isEqualTo("Hello World, Mr!"); + } + + @Test + public void itPreservesUndefinedInFromImportMacro() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("macros.jinja")) { + return "{% macro greet() %}Hello {{ unknown }}!{% endmacro %}"; + } + return ""; + }); + + String template = "{% from 'macros.jinja' import greet %}{{ greet() }}"; + String output = render(template); + // Macro executes, but undefined variables are preserved + assertThat(output).isEqualTo("Hello {{ unknown }}!"); + } + + @Test + public void itRendersExtendsTagWithStaticPath() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("base.html")) { + return "Base: {% block content %}default{% endblock %}"; + } + return ""; + }); + + String template = + "{% extends 'base.html' %}{% block content %}child content{% endblock %}"; + String output = render(template); + assertThat(output).isEqualTo("Base: child content"); + } + + @Test + public void itRendersExtendsTagWithDefinedVariablePath() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("base.html")) { + return "Base: {% block content %}default{% endblock %}"; + } + return ""; + }); + + Map context = new HashMap<>(); + context.put("templatePath", "base.html"); + String template = + "{% extends templatePath %}{% block content %}child content{% endblock %}"; + String output = render(template, context); + assertThat(output).isEqualTo("Base: child content"); + } + + @Test + public void itPreservesExtendsTagWithUndefinedVariablePath() { + String template = + "{% extends templatePath %}{% block content %}child content{% endblock %}"; + String output = render(template); + assertThat(output) + .isEqualTo( + "{% extends templatePath %}{% block content %}child content{% endblock %}" + ); + } + + @Test + public void itPreservesUndefinedVariablesInExtendedTemplate() { + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("base.html")) { + return "Title: {{ title }} - {% block content %}default{% endblock %}"; + } + return ""; + }); + + String template = + "{% extends 'base.html' %}{% block content %}{{ message }}{% endblock %}"; + String output = render(template); + assertThat(output).isEqualTo("Title: {{ title }} - {{ message }}"); + } + + @Test + public void itPreservesNestedExtendsWhenParentPathIsUndefined() { + // Chained inheritance: grandchild extends child (resolved), child extends parent (undefined) + jinjava.setResourceLocator((fullName, encoding, interpreter) -> { + if (fullName.equals("child.html")) { + return "{% extends parentPath %}{% block content %}child: {{ childVar }}{% endblock %}"; + } + return ""; + }); + + String template = + "{% extends 'child.html' %}{% block content %}grandchild content{% endblock %}"; + String output = render(template); + // The child template's extends and block should be preserved since parentPath is undefined + assertThat(output) + .isEqualTo( + "{% extends parentPath %}{% block content %}child: {{ childVar }}{% endblock %}" + ); + } + + @Test + public void itPreservesComments() { + String output = render("{# this is a comment #}"); + assertThat(output).isEqualTo("{# this is a comment #}"); + } + + @Test + public void itPreservesCommentsWithSurroundingContent() { + String output = render("Hello {# inline comment #} World"); + assertThat(output).isEqualTo("Hello {# inline comment #} World"); + } + + @Test + public void itPreservesCommentsWithVariables() { + Map context = new HashMap<>(); + context.put("name", "World"); + String output = render("Hello {{ name }}{# comment #}!", context); + assertThat(output).isEqualTo("Hello World{# comment #}!"); + } +}