Skip to content

Commit 8c1dc4a

Browse files
committed
Feature (template): Supports multi character template separators
Signed-off-by: engineer <[email protected]>
1 parent aa590e8 commit 8c1dc4a

File tree

2 files changed

+141
-90
lines changed

2 files changed

+141
-90
lines changed

spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java

Lines changed: 108 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
package org.springframework.ai.template.st;
1818

19+
import java.util.Collections;
1920
import java.util.HashSet;
2021
import java.util.Map;
2122
import java.util.Set;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
2225

2326
import org.antlr.runtime.Token;
2427
import org.antlr.runtime.TokenStream;
@@ -57,106 +60,139 @@ public class StTemplateRenderer implements TemplateRenderer {
5760

5861
private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
5962

60-
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
63+
private static final String DEFAULT_START_DELIMITER = "{";
6164

62-
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
65+
private static final String DEFAULT_END_DELIMITER = "}";
6366

67+
private static final char INTERNAL_START_DELIMITER = '{';
68+
69+
private static final char INTERNAL_END_DELIMITER = '}';
70+
71+
/** Default validation mode: throw an exception if variables are missing */
6472
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
6573

74+
/** Default behavior: do not validate ST built-in functions */
6675
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;
6776

68-
private final char startDelimiterToken;
77+
private final String startDelimiterToken;
6978

70-
private final char endDelimiterToken;
79+
private final String endDelimiterToken;
7180

7281
private final ValidationMode validationMode;
7382

7483
private final boolean validateStFunctions;
7584

7685
/**
77-
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
78-
* validation mode, and function validation flag.
79-
* @param startDelimiterToken the character used to denote the start of a template
80-
* variable (e.g., '{')
81-
* @param endDelimiterToken the character used to denote the end of a template
82-
* variable (e.g., '}')
83-
* @param validationMode the mode to use for template variable validation; must not be
84-
* null
85-
* @param validateStFunctions whether to validate StringTemplate functions in the
86-
* template
86+
* Constructs a StTemplateRenderer with custom delimiters, validation mode, and
87+
* function validation flag.
88+
* @param startDelimiterToken Multi-character start delimiter (non-null/non-empty)
89+
* @param endDelimiterToken Multi-character end delimiter (non-null/non-empty)
90+
* @param validationMode Mode for handling missing variables (non-null)
91+
* @param validateStFunctions Whether to treat ST built-in functions as variables
8792
*/
88-
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
93+
public StTemplateRenderer(String startDelimiterToken, String endDelimiterToken, ValidationMode validationMode,
8994
boolean validateStFunctions) {
90-
Assert.notNull(validationMode, "validationMode cannot be null");
95+
Assert.notNull(validationMode, "validationMode must not be null");
96+
Assert.hasText(startDelimiterToken, "startDelimiterToken must not be null or empty");
97+
Assert.hasText(endDelimiterToken, "endDelimiterToken must not be null or empty");
98+
9199
this.startDelimiterToken = startDelimiterToken;
92100
this.endDelimiterToken = endDelimiterToken;
93101
this.validationMode = validationMode;
94102
this.validateStFunctions = validateStFunctions;
95103
}
96104

105+
/**
106+
* Renders the template by first converting custom delimiters to ST's native format,
107+
* then replacing variables.
108+
* @param template Template string with variables (non-null/non-empty)
109+
* @param variables Map of variable names to values (non-null, keys must not be null)
110+
* @return Rendered string with variables replaced
111+
*/
97112
@Override
98113
public String apply(String template, Map<String, Object> variables) {
99-
Assert.hasText(template, "template cannot be null or empty");
100-
Assert.notNull(variables, "variables cannot be null");
101-
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
114+
Assert.hasText(template, "template must not be null or empty");
115+
Assert.notNull(variables, "variables must not be null");
116+
Assert.noNullElements(variables.keySet(), "variables keys must not contain null");
117+
118+
try {
119+
// Convert custom delimiters (e.g., <name>) to ST's native format ({name})
120+
String processedTemplate = preprocessTemplate(template);
121+
ST st = new ST(processedTemplate, INTERNAL_START_DELIMITER, INTERNAL_END_DELIMITER);
102122

103-
ST st = createST(template);
104-
for (Map.Entry<String, Object> entry : variables.entrySet()) {
105-
st.add(entry.getKey(), entry.getValue());
123+
// Add variables to the template
124+
variables.forEach(st::add);
125+
126+
// Validate variable completeness if enabled
127+
if (validationMode != ValidationMode.NONE) {
128+
validate(st, variables);
129+
}
130+
131+
// Render directly (no post-processing needed)
132+
return st.render();
106133
}
107-
if (this.validationMode != ValidationMode.NONE) {
108-
validate(st, variables);
134+
catch (Exception e) {
135+
logger.error("Template rendering failed for template: {}", template, e);
136+
throw new RuntimeException("Failed to render template", e);
109137
}
110-
return st.render();
111138
}
112139

113-
private ST createST(String template) {
114-
try {
115-
return new ST(template, this.startDelimiterToken, this.endDelimiterToken);
116-
}
117-
catch (Exception ex) {
118-
throw new IllegalArgumentException("The template string is not valid.", ex);
119-
}
140+
/**
141+
* Converts custom delimiter-wrapped variables (e.g., <name>) to ST's native format
142+
* ({name}).
143+
*/
144+
private String preprocessTemplate(String template) {
145+
// Escape special regex characters in delimiters
146+
String escapedStart = Pattern.quote(startDelimiterToken);
147+
String escapedEnd = Pattern.quote(endDelimiterToken);
148+
149+
// Regex pattern to match custom variables (e.g., <name> or {{name}})
150+
// Group 1 captures the variable name (letters, numbers, underscores)
151+
String variablePattern = escapedStart + "([a-zA-Z_][a-zA-Z0-9_]*)" + escapedEnd;
152+
153+
// Replace with ST's native format (e.g., {name})
154+
return template.replaceAll(variablePattern, "{$1}");
120155
}
121156

122157
/**
123-
* Validates that all required template variables are provided in the model. Returns
124-
* the set of missing variables for further handling or logging.
125-
* @param st the StringTemplate instance
126-
* @param templateVariables the provided variables
127-
* @return set of missing variable names, or empty set if none are missing
158+
* Validates that all template variables have been provided in the variables map.
128159
*/
129-
private Set<String> validate(ST st, Map<String, Object> templateVariables) {
160+
private void validate(ST st, Map<String, Object> templateVariables) {
130161
Set<String> templateTokens = getInputVariables(st);
131-
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
162+
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : Collections.emptySet();
132163
Set<String> missingVariables = new HashSet<>(templateTokens);
133164
missingVariables.removeAll(modelKeys);
134165

135166
if (!missingVariables.isEmpty()) {
136-
if (this.validationMode == ValidationMode.WARN) {
137-
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
167+
String message = VALIDATION_MESSAGE.formatted(missingVariables);
168+
if (validationMode == ValidationMode.WARN) {
169+
logger.warn(message);
138170
}
139-
else if (this.validationMode == ValidationMode.THROW) {
140-
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
171+
else if (validationMode == ValidationMode.THROW) {
172+
throw new IllegalStateException(message);
141173
}
142174
}
143-
return missingVariables;
144175
}
145176

177+
/**
178+
* Extracts variable names from the template using ST's token stream and regex
179+
* validation.
180+
*/
146181
private Set<String> getInputVariables(ST st) {
147-
TokenStream tokens = st.impl.tokens;
148182
Set<String> inputVariables = new HashSet<>();
183+
TokenStream tokens = st.impl.tokens;
149184
boolean isInsideList = false;
150185

186+
// Primary token-based extraction
151187
for (int i = 0; i < tokens.size(); i++) {
152188
Token token = tokens.get(i);
153189

154-
// Handle list variables with option (e.g., {items; separator=", "})
190+
// Handle list variables (e.g., {items; separator=", "})
155191
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
156192
&& tokens.get(i + 1).getType() == STLexer.ID) {
157193
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
158194
String text = tokens.get(i + 1).getText();
159-
if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) {
195+
if (!Compiler.funcs.containsKey(text) || validateStFunctions) {
160196
inputVariables.add(text);
161197
isInsideList = true;
162198
}
@@ -165,34 +201,43 @@ private Set<String> getInputVariables(ST st) {
165201
else if (token.getType() == STLexer.RDELIM) {
166202
isInsideList = false;
167203
}
168-
// Only add IDs that are not function calls (i.e., not immediately followed by
204+
// Handle regular variables (exclude functions/properties)
169205
else if (!isInsideList && token.getType() == STLexer.ID) {
170206
boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN);
171207
boolean isDotProperty = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT);
172-
// Only add as variable if:
173-
// - Not a function call
174-
// - Not a built-in function used as property (unless validateStFunctions)
175-
if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || this.validateStFunctions
208+
if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || validateStFunctions
176209
|| !(isDotProperty && Compiler.funcs.containsKey(token.getText())))) {
177210
inputVariables.add(token.getText());
178211
}
179212
}
180213
}
214+
215+
// Secondary regex check to catch edge cases
216+
Pattern varPattern = Pattern.compile(Pattern.quote(String.valueOf(INTERNAL_START_DELIMITER))
217+
+ "([a-zA-Z_][a-zA-Z0-9_]*)" + Pattern.quote(String.valueOf(INTERNAL_END_DELIMITER)));
218+
Matcher matcher = varPattern.matcher(st.impl.template);
219+
while (matcher.find()) {
220+
inputVariables.add(matcher.group(1));
221+
}
222+
181223
return inputVariables;
182224
}
183225

226+
/**
227+
* Creates a builder for configuring StTemplateRenderer instances.
228+
*/
184229
public static Builder builder() {
185230
return new Builder();
186231
}
187232

188233
/**
189-
* Builder for configuring and creating {@link StTemplateRenderer} instances.
234+
* Builder for fluent configuration of StTemplateRenderer.
190235
*/
191236
public static final class Builder {
192237

193-
private char startDelimiterToken = DEFAULT_START_DELIMITER_TOKEN;
238+
private String startDelimiterToken = DEFAULT_START_DELIMITER;
194239

195-
private char endDelimiterToken = DEFAULT_END_DELIMITER_TOKEN;
240+
private String endDelimiterToken = DEFAULT_END_DELIMITER;
196241

197242
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
198243

@@ -202,65 +247,42 @@ private Builder() {
202247
}
203248

204249
/**
205-
* Sets the character used as the start delimiter for template expressions.
206-
* Default is '{'.
207-
* @param startDelimiterToken The start delimiter character.
208-
* @return This builder instance for chaining.
250+
* Sets the multi-character start delimiter (e.g., "{{" or "<").
209251
*/
210-
public Builder startDelimiterToken(char startDelimiterToken) {
252+
public Builder startDelimiterToken(String startDelimiterToken) {
211253
this.startDelimiterToken = startDelimiterToken;
212254
return this;
213255
}
214256

215257
/**
216-
* Sets the character used as the end delimiter for template expressions. Default
217-
* is '}'.
218-
* @param endDelimiterToken The end delimiter character.
219-
* @return This builder instance for chaining.
258+
* Sets the multi-character end delimiter (e.g., "}}" or ">").
220259
*/
221-
public Builder endDelimiterToken(char endDelimiterToken) {
260+
public Builder endDelimiterToken(String endDelimiterToken) {
222261
this.endDelimiterToken = endDelimiterToken;
223262
return this;
224263
}
225264

226265
/**
227-
* Sets the validation mode to control behavior when the provided variables do not
228-
* match the variables required by the template. Default is
229-
* {@link ValidationMode#THROW}.
230-
* @param validationMode The desired validation mode.
231-
* @return This builder instance for chaining.
266+
* Sets the validation mode for missing variables.
232267
*/
233268
public Builder validationMode(ValidationMode validationMode) {
234269
this.validationMode = validationMode;
235270
return this;
236271
}
237272

238273
/**
239-
* Configures the renderer to support StringTemplate's built-in functions during
240-
* validation.
241-
* <p>
242-
* When enabled (set to true), identifiers in the template that match known ST
243-
* function names (e.g., "first", "rest", "length") will not be treated as
244-
* required input variables during validation.
245-
* <p>
246-
* When disabled (default, false), these identifiers are treated like regular
247-
* variables and must be provided in the input map if validation is enabled
248-
* ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}).
249-
* @return This builder instance for chaining.
274+
* Enables validation of ST built-in functions (treats them as variables).
250275
*/
251276
public Builder validateStFunctions() {
252277
this.validateStFunctions = true;
253278
return this;
254279
}
255280

256281
/**
257-
* Builds and returns a new {@link StTemplateRenderer} instance with the
258-
* configured settings.
259-
* @return A configured {@link StTemplateRenderer}.
282+
* Builds the configured StTemplateRenderer instance.
260283
*/
261284
public StTemplateRenderer build() {
262-
return new StTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode,
263-
this.validateStFunctions);
285+
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, validateStFunctions);
264286
}
265287

266288
}

spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ void shouldRenderWithoutValidationInNoneMode() {
148148
@Test
149149
void shouldRenderWithCustomDelimiters() {
150150
StTemplateRenderer renderer = StTemplateRenderer.builder()
151-
.startDelimiterToken('<')
152-
.endDelimiterToken('>')
151+
.startDelimiterToken("<")
152+
.endDelimiterToken(">")
153153
.build();
154154
Map<String, Object> variables = new HashMap<>();
155155
variables.put("name", "Spring AI");
@@ -159,11 +159,40 @@ void shouldRenderWithCustomDelimiters() {
159159
assertThat(result).isEqualTo("Hello Spring AI!");
160160
}
161161

162+
@Test
163+
void shouldRenderWithDoubleAngleBracketDelimiters() {
164+
StTemplateRenderer renderer = StTemplateRenderer.builder()
165+
.startDelimiterToken("<<")
166+
.endDelimiterToken(">>")
167+
.build();
168+
169+
Map<String, Object> variables = new HashMap<>();
170+
variables.put("name", "Spring AI");
171+
172+
String result = renderer.apply("Hello <<name>>!", variables);
173+
174+
assertThat(result).isEqualTo("Hello Spring AI!");
175+
}
176+
177+
@Test
178+
void shouldHandleDoubleCurlyBracesAsDelimiters() {
179+
StTemplateRenderer renderer = StTemplateRenderer.builder()
180+
.startDelimiterToken("{{")
181+
.endDelimiterToken("}}")
182+
.build();
183+
Map<String, Object> variables = new HashMap<>();
184+
variables.put("name", "Spring AI");
185+
186+
String result = renderer.apply("Hello {{name}}!", variables);
187+
188+
assertThat(result).isEqualTo("Hello Spring AI!");
189+
}
190+
162191
@Test
163192
void shouldHandleSpecialCharactersAsDelimiters() {
164193
StTemplateRenderer renderer = StTemplateRenderer.builder()
165-
.startDelimiterToken('$')
166-
.endDelimiterToken('$')
194+
.startDelimiterToken("$")
195+
.endDelimiterToken("$")
167196
.build();
168197
Map<String, Object> variables = new HashMap<>();
169198
variables.put("name", "Spring AI");

0 commit comments

Comments
 (0)