diff --git a/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java
new file mode 100644
index 0000000000..6836d10d12
--- /dev/null
+++ b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java
@@ -0,0 +1,503 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.util;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.lang.Nullable;
+import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
+
+/**
+ * Parser for Strings that have placeholder values in them. In its simplest form,
+ * a placeholder takes the form of {@code ${name}}, where {@code name} is the key
+ * that can be resolved using a {@link PlaceholderResolver PlaceholderResolver},
+ * ${
the prefix, and }
the suffix.
+ *
+ *
A placeholder can also have a default value if its key does not represent a
+ * known property. The default value is separated from the key using a
+ * {@code separator}. For instance {@code ${name:John}} resolves to {@code John} if
+ * the placeholder resolver does not provide a value for the {@code name}
+ * property.
+ *
+ *
Placeholders can also have a more complex structure, and the resolution of
+ * a given key can involve the resolution of nested placeholders. Default values
+ * can also have placeholders.
+ *
+ *
For situations where the syntax of a valid placeholder match a String that
+ * must be rendered as is, the placeholder can be escaped using an {@code escape}
+ * character. For instance {@code \${name}} resolves as {@code ${name}}.
+ *
+ *
The prefix, suffix, separator, and escape characters are configurable. Only
+ * the prefix and suffix are mandatory and the support of default values or
+ * escaping are conditional on providing a non-null value for them.
+ *
+ *
This parser makes sure to resolves placeholders as lazily as possible.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ */
+final class PlaceholderParser {
+
+ private static final Log logger = LogFactory.getLog(PlaceholderParser.class);
+
+ private static final Map wellKnownSimplePrefixes = Map.of(
+ "}", "{", "]", "[", ")", "(");
+
+ private final String prefix;
+
+ private final String suffix;
+
+ private final String simplePrefix;
+
+ @Nullable
+ private final String separator;
+
+ private final boolean ignoreUnresolvablePlaceholders;
+
+ @Nullable
+ private final Character escape;
+
+ /**
+ * Create an instance using the specified input for the parser.
+ *
+ * @param prefix the prefix that denotes the start of a placeholder
+ * @param suffix the suffix that denotes the end of a placeholder
+ * @param ignoreUnresolvablePlaceholders whether unresolvable placeholders
+ * should be ignored ({@code true}) or cause an exception ({@code false})
+ * @param separator the separating character between the placeholder
+ * variable and the associated default value, if any
+ * @param escape the character to use at the beginning of a placeholder
+ * to escape it and render it as is
+ */
+ PlaceholderParser(String prefix, String suffix, boolean ignoreUnresolvablePlaceholders,
+ @Nullable String separator, @Nullable Character escape) {
+ this.prefix = prefix;
+ this.suffix = suffix;
+ String simplePrefixForSuffix = wellKnownSimplePrefixes.get(this.suffix);
+ if (simplePrefixForSuffix != null && this.prefix.endsWith(simplePrefixForSuffix)) {
+ this.simplePrefix = simplePrefixForSuffix;
+ }
+ else {
+ this.simplePrefix = this.prefix;
+ }
+ this.separator = separator;
+ this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
+ this.escape = escape;
+ }
+
+ /**
+ * Replaces all placeholders of format {@code ${name}} with the value returned
+ * from the supplied {@link PlaceholderResolver}.
+ * @param value the value containing the placeholders to be replaced
+ * @param placeholderResolver the {@code PlaceholderResolver} to use for replacement
+ * @return the supplied value with placeholders replaced inline
+ */
+ public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
+ Assert.notNull(value, "'value' must not be null");
+ ParsedValue parsedValue = parse(value);
+ PartResolutionContext resolutionContext = new PartResolutionContext(placeholderResolver,
+ this.prefix, this.suffix, this.ignoreUnresolvablePlaceholders,
+ candidate -> parse(candidate, false));
+ return parsedValue.resolve(resolutionContext);
+ }
+
+ /**
+ * Parse the specified value.
+ * @param value the value containing the placeholders to be replaced
+ * @return the different parts that have been identified
+ */
+ ParsedValue parse(String value) {
+ List parts = parse(value, false);
+ return new ParsedValue(value, parts);
+ }
+
+ private List parse(String value, boolean inPlaceholder) {
+ LinkedList parts = new LinkedList<>();
+ int startIndex = nextStartPrefix(value, 0);
+ if (startIndex == -1) {
+ Part part = inPlaceholder ? createSimplePlaceholderPart(value)
+ : new TextPart(value);
+ parts.add(part);
+ return parts;
+ }
+ int position = 0;
+ while (startIndex != -1) {
+ int endIndex = nextValidEndPrefix(value, startIndex);
+ if (endIndex == -1) { // Not a valid placeholder, consume the prefix and continue
+ addText(value, position, startIndex + this.prefix.length(), parts);
+ position = startIndex + this.prefix.length();
+ startIndex = nextStartPrefix(value, position);
+ }
+ else if (isEscaped(value, startIndex)) { // Not a valid index, accumulate and skip the escape character
+ addText(value, position, startIndex - 1, parts);
+ addText(value, startIndex, startIndex + this.prefix.length(), parts);
+ position = startIndex + this.prefix.length();
+ startIndex = nextStartPrefix(value, position);
+ }
+ else { // Found valid placeholder, recursive parsing
+ addText(value, position, startIndex, parts);
+ String placeholder = value.substring(startIndex + this.prefix.length(), endIndex);
+ List placeholderParts = parse(placeholder, true);
+ parts.addAll(placeholderParts);
+ startIndex = nextStartPrefix(value, endIndex + this.suffix.length());
+ position = endIndex + this.suffix.length();
+ }
+ }
+ // Add rest of text if necessary
+ addText(value, position, value.length(), parts);
+ return inPlaceholder ? List.of(createNestedPlaceholderPart(value, parts)) : parts;
+ }
+
+ private SimplePlaceholderPart createSimplePlaceholderPart(String text) {
+ String[] keyAndDefault = splitKeyAndDefault(text);
+ return (keyAndDefault != null) ? new SimplePlaceholderPart(text, keyAndDefault[0], keyAndDefault[1])
+ : new SimplePlaceholderPart(text, text, null);
+ }
+
+ private NestedPlaceholderPart createNestedPlaceholderPart(String text, List parts) {
+ if (this.separator == null) {
+ return new NestedPlaceholderPart(text, parts, null);
+ }
+ List keyParts = new ArrayList<>();
+ List defaultParts = new ArrayList<>();
+ for (int i = 0; i < parts.size(); i++) {
+ Part part = parts.get(i);
+ if (!(part instanceof TextPart)) {
+ keyParts.add(part);
+ }
+ else {
+ String candidate = part.text();
+ String[] keyAndDefault = splitKeyAndDefault(candidate);
+ if (keyAndDefault != null) {
+ keyParts.add(new TextPart(keyAndDefault[0]));
+ if (keyAndDefault[1] != null) {
+ defaultParts.add(new TextPart(keyAndDefault[1]));
+ }
+ defaultParts.addAll(parts.subList(i + 1, parts.size()));
+ return new NestedPlaceholderPart(text, keyParts, defaultParts);
+ }
+ else {
+ keyParts.add(part);
+ }
+ }
+ }
+ // No separator found
+ return new NestedPlaceholderPart(text, parts, null);
+ }
+
+ @Nullable
+ private String[] splitKeyAndDefault(String value) {
+ if (this.separator == null || !value.contains(this.separator)) {
+ return null;
+ }
+ int position = 0;
+ int index = value.indexOf(this.separator, position);
+ StringBuilder buffer = new StringBuilder();
+ while (index != -1) {
+ if (isEscaped(value, index)) {
+ // Accumulate, without the escape character.
+ buffer.append(value, position, index - 1);
+ buffer.append(value, index, index + this.separator.length());
+ position = index + this.separator.length();
+ index = value.indexOf(this.separator, position);
+ }
+ else {
+ buffer.append(value, position, index);
+ String key = buffer.toString();
+ String fallback = value.substring(index + this.separator.length());
+ return new String[] { key, fallback };
+ }
+ }
+ buffer.append(value, position, value.length());
+ return new String[] { buffer.toString(), null };
+ }
+
+ private static void addText(String value, int start, int end, LinkedList parts) {
+ if (start > end) {
+ return;
+ }
+ String text = value.substring(start, end);
+ if (!text.isEmpty()) {
+ if (!parts.isEmpty()) {
+ Part current = parts.removeLast();
+ if (current instanceof TextPart textPart) {
+ parts.add(new TextPart(textPart.text + text));
+ }
+ else {
+ parts.add(current);
+ parts.add(new TextPart(text));
+ }
+ }
+ else {
+ parts.add(new TextPart(text));
+ }
+ }
+ }
+
+
+ private int nextStartPrefix(String value, int index) {
+ return value.indexOf(this.prefix, index);
+ }
+
+ private int nextValidEndPrefix(String value, int startIndex) {
+ int index = startIndex + this.prefix.length();
+ int withinNestedPlaceholder = 0;
+ while (index < value.length()) {
+ if (StringUtils.substringMatch(value, index, this.suffix)) {
+ if (withinNestedPlaceholder > 0) {
+ withinNestedPlaceholder--;
+ index = index + this.suffix.length();
+ }
+ else {
+ return index;
+ }
+ }
+ else if (StringUtils.substringMatch(value, index, this.simplePrefix)) {
+ withinNestedPlaceholder++;
+ index = index + this.simplePrefix.length();
+ }
+ else {
+ index++;
+ }
+ }
+ return -1;
+ }
+
+ private boolean isEscaped(String value, int index) {
+ return (this.escape != null && index > 0 && value.charAt(index - 1) == this.escape);
+ }
+
+ /**
+ * Provide the necessary to handle and resolve underlying placeholders.
+ */
+ static class PartResolutionContext implements PlaceholderResolver {
+
+ private final String prefix;
+
+ private final String suffix;
+
+ private final boolean ignoreUnresolvablePlaceholders;
+
+ private final Function> parser;
+
+ private final PlaceholderResolver resolver;
+
+ @Nullable
+ private Set visitedPlaceholders;
+
+ PartResolutionContext(PlaceholderResolver resolver, String prefix, String suffix,
+ boolean ignoreUnresolvablePlaceholders, Function> parser) {
+ this.prefix = prefix;
+ this.suffix = suffix;
+ this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
+ this.parser = parser;
+ this.resolver = resolver;
+ }
+
+ @Override
+ public String resolvePlaceholder(String placeholderName) {
+ String value = this.resolver.resolvePlaceholder(placeholderName);
+ if (value != null && logger.isTraceEnabled()) {
+ logger.trace("Resolved placeholder '" + placeholderName + "'");
+ }
+ return value;
+ }
+
+ public String handleUnresolvablePlaceholder(String key, String text) {
+ if (this.ignoreUnresolvablePlaceholders) {
+ return toPlaceholderText(key);
+ }
+ String originalValue = (!key.equals(text) ? toPlaceholderText(text) : null);
+ throw new PlaceholderResolutionException(
+ "Could not resolve placeholder '%s'".formatted(key), key, originalValue);
+ }
+
+ private String toPlaceholderText(String text) {
+ return this.prefix + text + this.suffix;
+ }
+
+ public List parse(String text) {
+ return this.parser.apply(text);
+ }
+
+ public void flagPlaceholderAsVisited(String placeholder) {
+ if (this.visitedPlaceholders == null) {
+ this.visitedPlaceholders = new HashSet<>(4);
+ }
+ if (!this.visitedPlaceholders.add(placeholder)) {
+ throw new PlaceholderResolutionException(
+ "Circular placeholder reference '%s'".formatted(placeholder), placeholder, null);
+ }
+ }
+
+ public void removePlaceholder(String placeholder) {
+ this.visitedPlaceholders.remove(placeholder);
+ }
+
+ }
+
+
+ /**
+ * A part is a section of a String containing placeholders to replace.
+ */
+ interface Part {
+
+ /**
+ * Resolve this part using the specified {@link PartResolutionContext}.
+ * @param resolutionContext the context to use
+ * @return the resolved part
+ */
+ String resolve(PartResolutionContext resolutionContext);
+
+ /**
+ * Provide a textual representation of this part.
+ * @return the raw text that this part defines
+ */
+ String text();
+
+ /**
+ * Return a String that appends the resolution of the specified parts.
+ * @param parts the parts to resolve
+ * @param resolutionContext the context to use for the resolution
+ * @return a concatenation of the supplied parts with placeholders replaced inline
+ */
+ static String resolveAll(Iterable parts, PartResolutionContext resolutionContext) {
+ StringBuilder sb = new StringBuilder();
+ for (Part part : parts) {
+ sb.append(part.resolve(resolutionContext));
+ }
+ return sb.toString();
+ }
+
+ }
+
+ /**
+ * A representation of the parsing of an input string.
+ * @param text the raw input string
+ * @param parts the parts that appear in the string, in order
+ */
+ record ParsedValue(String text, List parts) {
+
+ public String resolve(PartResolutionContext resolutionContext) {
+ try {
+ return Part.resolveAll(this.parts, resolutionContext);
+ }
+ catch (PlaceholderResolutionException ex) {
+ throw ex.withValue(this.text);
+ }
+ }
+
+ }
+
+ /**
+ * A {@link Part} implementation that does not contain a valid placeholder.
+ * @param text the raw (and resolved) text
+ */
+ record TextPart(String text) implements Part {
+
+ @Override
+ public String resolve(PartResolutionContext resolutionContext) {
+ return this.text;
+ }
+
+ }
+
+ /**
+ * A {@link Part} implementation that represents a single placeholder with
+ * a hard-coded fallback.
+ * @param text the raw text
+ * @param key the key of the placeholder
+ * @param fallback the fallback to use, if any
+ */
+ record SimplePlaceholderPart(String text, String key, @Nullable String fallback) implements Part {
+
+ @Override
+ public String resolve(PartResolutionContext resolutionContext) {
+ String resolvedValue = resolveToText(resolutionContext, this.key);
+ if (resolvedValue != null) {
+ return resolvedValue;
+ }
+ else if (this.fallback != null) {
+ return this.fallback;
+ }
+ return resolutionContext.handleUnresolvablePlaceholder(this.key, this.text);
+ }
+
+ @Nullable
+ private String resolveToText(PartResolutionContext resolutionContext, String text) {
+ String resolvedValue = resolutionContext.resolvePlaceholder(text);
+ if (resolvedValue != null) {
+ resolutionContext.flagPlaceholderAsVisited(text);
+ // Let's check if we need to recursively resolve that value
+ List nestedParts = resolutionContext.parse(resolvedValue);
+ String value = toText(nestedParts);
+ if (!isTextOnly(nestedParts)) {
+ value = new ParsedValue(resolvedValue, nestedParts).resolve(resolutionContext);
+ }
+ resolutionContext.removePlaceholder(text);
+ return value;
+ }
+ // Not found
+ return null;
+ }
+
+ private boolean isTextOnly(List parts) {
+ return parts.stream().allMatch(part -> part instanceof TextPart);
+ }
+
+ private String toText(List parts) {
+ StringBuilder sb = new StringBuilder();
+ parts.forEach(part -> sb.append(part.text()));
+ return sb.toString();
+ }
+
+ }
+
+ /**
+ * A {@link Part} implementation that represents a single placeholder
+ * containing nested placeholders.
+ * @param text the raw text of the root placeholder
+ * @param keyParts the parts of the key
+ * @param defaultParts the parts of the fallback, if any
+ */
+ record NestedPlaceholderPart(String text, List keyParts, @Nullable List defaultParts) implements Part {
+
+ @Override
+ public String resolve(PartResolutionContext resolutionContext) {
+ String resolvedKey = Part.resolveAll(this.keyParts, resolutionContext);
+ String value = resolutionContext.resolvePlaceholder(resolvedKey);
+ if (value != null) {
+ return value;
+ }
+ else if (this.defaultParts != null) {
+ return Part.resolveAll(this.defaultParts, resolutionContext);
+ }
+ return resolutionContext.handleUnresolvablePlaceholder(resolvedKey, this.text);
+ }
+
+ }
+
+}
diff --git a/spring-core/src/main/java/org/springframework/util/PlaceholderResolutionException.java b/spring-core/src/main/java/org/springframework/util/PlaceholderResolutionException.java
new file mode 100644
index 0000000000..bc789a9bba
--- /dev/null
+++ b/spring-core/src/main/java/org/springframework/util/PlaceholderResolutionException.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.util;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.springframework.lang.Nullable;
+
+/**
+ * Thrown when the resolution of placeholder failed. This exception provides
+ * the placeholder as well as the hierarchy of values that led to the issue.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ */
+@SuppressWarnings("serial")
+public class PlaceholderResolutionException extends RuntimeException {
+
+ private final String reason;
+
+ private final String placeholder;
+
+ private final List values;
+
+ /**
+ * Create an exception using the specified reason for its message.
+ * @param reason the reason for the exception, should contain the placeholder
+ * @param placeholder the placeholder
+ * @param value the original expression that led to the issue if available
+ */
+ PlaceholderResolutionException(String reason, String placeholder, @Nullable String value) {
+ this(reason, placeholder, (value != null ? List.of(value) : Collections.emptyList()));
+ }
+
+ private PlaceholderResolutionException(String reason, String placeholder, List values) {
+ super(buildMessage(reason, values));
+ this.reason = reason;
+ this.placeholder = placeholder;
+ this.values = values;
+ }
+
+ private static String buildMessage(String reason, List values) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(reason);
+ if (!CollectionUtils.isEmpty(values)) {
+ String valuesChain = values.stream().map(value -> "\"" + value + "\"")
+ .collect(Collectors.joining(" <-- "));
+ sb.append(" in value %s".formatted(valuesChain));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Return a {@link PlaceholderResolutionException} that provides
+ * an additional parent value.
+ * @param value the parent value to add
+ * @return a new exception with the parent value added
+ */
+ PlaceholderResolutionException withValue(String value) {
+ List allValues = new ArrayList<>(this.values);
+ allValues.add(value);
+ return new PlaceholderResolutionException(this.reason, this.placeholder, allValues);
+ }
+
+ /**
+ * Return the placeholder that could not be resolved.
+ * @return the unresolvable placeholder
+ */
+ public String getPlaceholder() {
+ return this.placeholder;
+ }
+
+ /**
+ * Return a contextualized list of the resolution attempts that led to this
+ * exception, where the first element is the value that generated this
+ * exception.
+ * @return the stack of values that led to this exception
+ */
+ public List getValues() {
+ return this.values;
+ }
+
+}
diff --git a/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java
new file mode 100644
index 0000000000..110383eb68
--- /dev/null
+++ b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.util;
+
+import java.util.Properties;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import org.springframework.util.PlaceholderParser.ParsedValue;
+import org.springframework.util.PlaceholderParser.TextPart;
+import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link PlaceholderParser}.
+ *
+ * @author Stephane Nicoll
+ */
+class PlaceholderParserTests {
+
+ @Nested // Tests with only the basic placeholder feature enabled
+ class OnlyPlaceholderTests {
+
+ private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, null, null);
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("placeholders")
+ void placeholderIsReplaced(String text, String expected) {
+ Properties properties = new Properties();
+ properties.setProperty("firstName", "John");
+ properties.setProperty("nested0", "first");
+ properties.setProperty("nested1", "Name");
+ assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
+ }
+
+ static Stream placeholders() {
+ return Stream.of(
+ Arguments.of("${firstName}", "John"),
+ Arguments.of("$${firstName}", "$John"),
+ Arguments.of("}${firstName}", "}John"),
+ Arguments.of("${firstName}$", "John$"),
+ Arguments.of("${firstName}}", "John}"),
+ Arguments.of("${firstName} ${firstName}", "John John"),
+ Arguments.of("First name: ${firstName}", "First name: John"),
+ Arguments.of("${firstName} is the first name", "John is the first name"),
+ Arguments.of("${first${nested1}}", "John"),
+ Arguments.of("${${nested0}${nested1}}", "John")
+ );
+ }
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("nestedPlaceholders")
+ void nestedPlaceholdersAreReplaced(String text, String expected) {
+ Properties properties = new Properties();
+ properties.setProperty("p1", "v1");
+ properties.setProperty("p2", "v2");
+ properties.setProperty("p3", "${p1}:${p2}"); // nested placeholders
+ properties.setProperty("p4", "${p3}"); // deeply nested placeholders
+ properties.setProperty("p5", "${p1}:${p2}:${bogus}"); // unresolvable placeholder
+ assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
+ }
+
+ static Stream nestedPlaceholders() {
+ return Stream.of(
+ Arguments.of("${p1}:${p2}", "v1:v2"),
+ Arguments.of("${p3}", "v1:v2"),
+ Arguments.of("${p4}", "v1:v2"),
+ Arguments.of("${p5}", "v1:v2:${bogus}"),
+ Arguments.of("${p0${p0}}", "${p0${p0}}")
+ );
+ }
+
+ @Test
+ void parseWithSinglePlaceholder() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
+ assertThat(this.parser.replacePlaceholders("${firstName}", resolver))
+ .isEqualTo("John");
+ verify(resolver).resolvePlaceholder("firstName");
+ verifyNoMoreInteractions(resolver);
+ }
+
+ @Test
+ void parseWithPlaceholderAndPrefixText() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
+ assertThat(this.parser.replacePlaceholders("This is ${firstName}", resolver))
+ .isEqualTo("This is John");
+ verify(resolver).resolvePlaceholder("firstName");
+ verifyNoMoreInteractions(resolver);
+ }
+
+ @Test
+ void parseWithMultiplePlaceholdersAndText() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John", "lastName", "Smith");
+ assertThat(this.parser.replacePlaceholders("User: ${firstName} - ${lastName}.", resolver))
+ .isEqualTo("User: John - Smith.");
+ verify(resolver).resolvePlaceholder("firstName");
+ verify(resolver).resolvePlaceholder("lastName");
+ verifyNoMoreInteractions(resolver);
+ }
+
+ @Test
+ void parseWithNestedPlaceholderInKey() {
+ PlaceholderResolver resolver = mockPlaceholderResolver(
+ "nested", "Name", "firstName", "John");
+ assertThat(this.parser.replacePlaceholders("${first${nested}}", resolver))
+ .isEqualTo("John");
+ verifyPlaceholderResolutions(resolver, "nested", "firstName");
+ }
+
+ @Test
+ void parseWithMultipleNestedPlaceholdersInKey() {
+ PlaceholderResolver resolver = mockPlaceholderResolver(
+ "nested0", "first", "nested1", "Name", "firstName", "John");
+ assertThat(this.parser.replacePlaceholders("${${nested0}${nested1}}", resolver))
+ .isEqualTo("John");
+ verifyPlaceholderResolutions(resolver, "nested0", "nested1", "firstName");
+ }
+
+ @Test
+ void placeholdersWithSeparatorAreHandledAsIs() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("my:test", "value");
+ assertThat(this.parser.replacePlaceholders("${my:test}", resolver)).isEqualTo("value");
+ verifyPlaceholderResolutions(resolver, "my:test");
+ }
+
+ @Test
+ void placeholdersWithoutEscapeCharAreNotEscaped() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("test", "value");
+ assertThat(this.parser.replacePlaceholders("\\${test}", resolver)).isEqualTo("\\value");
+ verifyPlaceholderResolutions(resolver, "test");
+ }
+
+ @Test
+ void textWithInvalidPlaceholderIsMerged() {
+ String text = "test${of${with${and${";
+ ParsedValue parsedValue = this.parser.parse(text);
+ assertThat(parsedValue.parts()).singleElement().isInstanceOfSatisfying(
+ TextPart.class, textPart -> assertThat(textPart.text()).isEqualTo(text));
+ }
+
+ }
+
+ @Nested // Tests with the use of a separator
+ class DefaultValueTests {
+
+ private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, ":", null);
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("placeholders")
+ void placeholderIsReplaced(String text, String expected) {
+ Properties properties = new Properties();
+ properties.setProperty("firstName", "John");
+ properties.setProperty("nested0", "first");
+ properties.setProperty("nested1", "Name");
+ assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
+ }
+
+ static Stream placeholders() {
+ return Stream.of(
+ Arguments.of("${invalid:John}", "John"),
+ Arguments.of("${first${invalid:Name}}", "John"),
+ Arguments.of("${invalid:${firstName}}", "John"),
+ Arguments.of("${invalid:${${nested0}${nested1}}}", "John"),
+ Arguments.of("${invalid:$${firstName}}", "$John"),
+ Arguments.of("${invalid: }${firstName}", " John"),
+ Arguments.of("${invalid:}", ""),
+ Arguments.of("${:}", "")
+ );
+ }
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("nestedPlaceholders")
+ void nestedPlaceholdersAreReplaced(String text, String expected) {
+ Properties properties = new Properties();
+ properties.setProperty("p1", "v1");
+ properties.setProperty("p2", "v2");
+ properties.setProperty("p3", "${p1}:${p2}"); // nested placeholders
+ properties.setProperty("p4", "${p3}"); // deeply nested placeholders
+ properties.setProperty("p5", "${p1}:${p2}:${bogus}"); // unresolvable placeholder
+ properties.setProperty("p6", "${p1}:${p2}:${bogus:def}"); // unresolvable w/ default
+ assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
+ }
+
+ static Stream nestedPlaceholders() {
+ return Stream.of(
+ Arguments.of("${p6}", "v1:v2:def"),
+ Arguments.of("${invalid:${p1}:${p2}}", "v1:v2"),
+ Arguments.of("${invalid:${p3}}", "v1:v2"),
+ Arguments.of("${invalid:${p4}}", "v1:v2"),
+ Arguments.of("${invalid:${p5}}", "v1:v2:${bogus}"),
+ Arguments.of("${invalid:${p6}}", "v1:v2:def")
+ );
+ }
+
+ @Test
+ void parseWithHardcodedFallback() {
+ PlaceholderResolver resolver = mockPlaceholderResolver();
+ assertThat(this.parser.replacePlaceholders("${firstName:Steve}", resolver))
+ .isEqualTo("Steve");
+ verifyPlaceholderResolutions(resolver, "firstName");
+ }
+
+ @Test
+ void parseWithNestedPlaceholderInKeyUsingFallback() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
+ assertThat(this.parser.replacePlaceholders("${first${invalid:Name}}", resolver))
+ .isEqualTo("John");
+ verifyPlaceholderResolutions(resolver, "invalid", "firstName");
+ }
+
+ @Test
+ void parseWithFallbackUsingPlaceholder() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
+ assertThat(this.parser.replacePlaceholders("${invalid:${firstName}}", resolver))
+ .isEqualTo("John");
+ verifyPlaceholderResolutions(resolver, "invalid", "firstName");
+ }
+
+ }
+
+ @Nested // Tests with the use of the escape character
+ class EscapedTests {
+
+ private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, ":", '\\');
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("escapedPlaceholders")
+ void escapedPlaceholderIsNotReplaced(String text, String expected) {
+ PlaceholderResolver resolver = mockPlaceholderResolver(
+ "firstName", "John", "nested0", "first", "nested1", "Name",
+ "${test}", "John",
+ "p1", "v1", "p2", "\\${p1:default}", "p3", "${p2}",
+ "p4", "adc${p0:\\${p1}}",
+ "p5", "adc${\\${p0}:${p1}}",
+ "p6", "adc${p0:def\\${p1}}",
+ "p7", "adc\\${");
+ assertThat(this.parser.replacePlaceholders(text, resolver)).isEqualTo(expected);
+ }
+
+ static Stream escapedPlaceholders() {
+ return Stream.of(
+ Arguments.of("\\${firstName}", "${firstName}"),
+ Arguments.of("First name: \\${firstName}", "First name: ${firstName}"),
+ Arguments.of("$\\${firstName}", "$${firstName}"),
+ Arguments.of("\\}${firstName}", "\\}John"),
+ Arguments.of("${\\${test}}", "John"),
+ Arguments.of("${p2}", "${p1:default}"),
+ Arguments.of("${p3}", "${p1:default}"),
+ Arguments.of("${p4}", "adc${p1}"),
+ Arguments.of("${p5}", "adcv1"),
+ Arguments.of("${p6}", "adcdef${p1}"),
+ Arguments.of("${p7}", "adc\\${"));
+
+ }
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @MethodSource("escapedSeparators")
+ void escapedSeparatorIsNotReplaced(String text, String expected) {
+ Properties properties = new Properties();
+ properties.setProperty("first:Name", "John");
+ properties.setProperty("nested0", "first");
+ properties.setProperty("nested1", "Name");
+ assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
+ }
+
+ static Stream escapedSeparators() {
+ return Stream.of(
+ Arguments.of("${first\\:Name}", "John"),
+ Arguments.of("${last\\:Name}", "${last:Name}")
+ );
+ }
+
+ }
+
+ @Nested
+ class ExceptionTests {
+
+ private final PlaceholderParser parser = new PlaceholderParser("${", "}", false, ":", null);
+
+ @Test
+ void textWithCircularReference() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("pL", "${pR}", "pR", "${pL}");
+ assertThatThrownBy(() -> this.parser.replacePlaceholders("${pL}", resolver))
+ .isInstanceOf(PlaceholderResolutionException.class)
+ .hasMessage("Circular placeholder reference 'pL' in value \"${pL}\" <-- \"${pR}\" <-- \"${pL}\"");
+ }
+
+ @Test
+ void unresolvablePlaceholderIsReported() {
+ PlaceholderResolver resolver = mockPlaceholderResolver();
+ assertThatExceptionOfType(PlaceholderResolutionException.class)
+ .isThrownBy(() -> this.parser.replacePlaceholders("${bogus}", resolver))
+ .withMessage("Could not resolve placeholder 'bogus' in value \"${bogus}\"")
+ .withNoCause();
+ }
+
+ @Test
+ void unresolvablePlaceholderInNestedPlaceholderIsReportedWithChain() {
+ PlaceholderResolver resolver = mockPlaceholderResolver("p1", "v1", "p2", "v2",
+ "p3", "${p1}:${p2}:${bogus}");
+ assertThatExceptionOfType(PlaceholderResolutionException.class)
+ .isThrownBy(() -> this.parser.replacePlaceholders("${p3}", resolver))
+ .withMessage("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\" <-- \"${p3}\"")
+ .withNoCause();
+ }
+
+ }
+
+ PlaceholderResolver mockPlaceholderResolver(String... pairs) {
+ if (pairs.length % 2 == 1) {
+ throw new IllegalArgumentException("size must be even, it is a set of key=value pairs");
+ }
+ PlaceholderResolver resolver = mock(PlaceholderResolver.class);
+ for (int i = 0; i < pairs.length; i += 2) {
+ String key = pairs[i];
+ String value = pairs[i + 1];
+ given(resolver.resolvePlaceholder(key)).willReturn(value);
+ }
+ return resolver;
+ }
+
+ void verifyPlaceholderResolutions(PlaceholderResolver mock, String... placeholders) {
+ for (String placeholder : placeholders) {
+ verify(mock).resolvePlaceholder(placeholder);
+ }
+ verifyNoMoreInteractions(mock);
+ }
+
+}