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); + } + +}