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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public class StTemplateRenderer implements TemplateRenderer {

private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;

private static final boolean DEFAULT_KEEP_MISSING_VARIABLES = false;

private final char startDelimiterToken;

private final char endDelimiterToken;
Expand All @@ -75,6 +77,8 @@ public class StTemplateRenderer implements TemplateRenderer {

private final boolean validateStFunctions;

private final boolean keepMissingVariables;

/**
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
* validation mode, and function validation flag.
Expand All @@ -88,12 +92,13 @@ public class StTemplateRenderer implements TemplateRenderer {
* template
*/
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
boolean validateStFunctions) {
boolean validateStFunctions, boolean keepMissingVariables) {
Assert.notNull(validationMode, "validationMode cannot be null");
this.startDelimiterToken = startDelimiterToken;
this.endDelimiterToken = endDelimiterToken;
this.validationMode = validationMode;
this.validateStFunctions = validateStFunctions;
this.keepMissingVariables = keepMissingVariables;
}

@Override
Expand All @@ -103,6 +108,17 @@ public String apply(String template, Map<String, Object> variables) {
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");

ST st = createST(template);
// If keepMissingVariables is enabled, first fill missing variables with placeholders.
if (this.keepMissingVariables) {
Set<String> allVars = getInputVariables(st);
Set<String> missingVars = new HashSet<>(allVars);
missingVars.removeAll(variables.keySet());

for (String missingVar : missingVars) {
st.add(missingVar, String.format("%c%s%c",
this.startDelimiterToken, missingVar, this.endDelimiterToken));
}
}
for (Map.Entry<String, Object> entry : variables.entrySet()) {
st.add(entry.getKey(), entry.getValue());
}
Expand Down Expand Up @@ -211,6 +227,8 @@ public static final class Builder {

private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS;

private boolean keepMissingVariables = DEFAULT_KEEP_MISSING_VARIABLES;

private Builder() {
}

Expand Down Expand Up @@ -266,14 +284,53 @@ public Builder validateStFunctions() {
return this;
}

/**
* Configures the renderer to keep missing variables in their original placeholder
* form instead of letting StringTemplate render them as empty strings.
*
* <p>When enabled, variables that are referenced in the template but not provided in
* the input map will be rendered as their placeholder text (for example:
* {@code "{username}"} remains {@code "{username}"} in the final output).</p>
*
* <p><b>Important:</b> {@code keepMissingVariables(true)} can only be used when
* {@code validationMode} is set to {@link ValidationMode#NONE}. If validation is
* enabled ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}), enabling
* this flag will result in an {@link IllegalArgumentException} when building the
* renderer.</p>
*
* <p>Example usage:</p>
* <pre>{@code
* TemplateRenderer renderer = StTemplateRenderer.builder()
* .validationMode(ValidationMode.NONE)
* .keepMissingVariables(true)
* .build();
*
* String result = renderer.apply("Hello, {name}, today is {date}",
* Map.of("name", "Alice"));
* // result: "Hello, Alice, today is {date}"
* }</pre>
*
* @param keepMissingVariables whether to keep missing variables as placeholders
* (e.g. "{var}") in the rendered output
* @return this builder instance for chaining
*/
public Builder keepMissingVariables(boolean keepMissingVariables) {
this.keepMissingVariables = keepMissingVariables;
return this;
}

/**
* Builds and returns a new {@link StTemplateRenderer} instance with the
* configured settings.
* @return A configured {@link StTemplateRenderer}.
*/
public StTemplateRenderer build() {
if (this.keepMissingVariables && this.validationMode != ValidationMode.NONE) {
throw new IllegalArgumentException(
"keepMissingVariables can only be enabled when validationMode is NONE.");
}
return new StTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode,
this.validateStFunctions);
this.validateStFunctions, this.keepMissingVariables);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,119 @@ void shouldValidatePropertyAccessCorrectly() {
"Not all variables were replaced in the template. Missing variable names are: [user]");
}

/**
* Tests that keepMissingVariables is false by default, so missing variables are rendered as empty strings.
*/
@Test
void shouldDefaultToNotKeepMissingVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(ValidationMode.NONE)
.build();

String result = renderer.apply("{name}", Map.of());
assertThat(result).isEmpty();
}

/**
* Tests basic functionality: missing variables are preserved as placeholders
* while provided variables are rendered normally.
*/
@Test
void shouldPreservePlaceholderForMissingVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(ValidationMode.NONE)
.keepMissingVariables(true)
.build();

Map<String, Object> variables = new HashMap<>();
variables.put("name", "Alice");

String template = "Hello {name}, today is {date}";
String result = renderer.apply(template, variables);

assertThat(result).isEqualTo("Hello Alice, today is {date}");
}

/**
* Tests that multiple missing variables are all preserved in their original placeholder form.
*/
@Test
void shouldKeepPlaceholdersForMultipleMissingVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(ValidationMode.NONE)
.keepMissingVariables(true)
.build();

Map<String, Object> variables = Map.of("a", 1);

String template = "{a} {b} {c}";
String result = renderer.apply(template, variables);

assertThat(result).isEqualTo("1 {b} {c}");
}

/**
* Tests that keepMissingVariables cannot be enabled with THROW validation mode,
* as they are semantically incompatible.
*/
@Test
void shouldNotAllowThrowValidationModeWhenKeepMissingVariables() {
assertThatThrownBy(() -> StTemplateRenderer.builder()
.validationMode(ValidationMode.THROW)
.keepMissingVariables(true)
.build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE");
}

/**
* Tests that keepMissingVariables cannot be enabled with WARN validation mode,
* as they are semantically incompatible.
*/
@Test
void shouldNotAllowWarnValidationModeWhenKeepMissingVariables() {
assertThatThrownBy(() -> StTemplateRenderer.builder()
.validationMode(ValidationMode.WARN)
.keepMissingVariables(true)
.build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE");
}

/**
* Tests that keepMissingVariables respects custom delimiter tokens when preserving placeholders.
*/
@Test
void shouldRespectCustomDelimitersWhenKeepMissingVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.validationMode(ValidationMode.NONE)
.keepMissingVariables(true)
.build();

Map<String, Object> variables = Map.of("name", "Spring AI");
String result = renderer.apply("Hello <name>, today is <date>", variables);

assertThat(result).isEqualTo("Hello Spring AI, today is <date>");
}

/**
* Tests that StringTemplate built-in functions work correctly and missing variables are still preserved.
*/
@Test
void shouldNotInterfereWithBuiltInFunctionsWhenKeepMissingVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder()
.validationMode(ValidationMode.NONE)
.keepMissingVariables(true)
.build();

Map<String, Object> variables = Map.of("items", new String[] {"a", "b"});
String template = "{first(items)} {last(items)} {missing}";
String result = renderer.apply(template, variables);

assertThat(result).isEqualTo("a b {missing}");
}


}