Merge branch '4.1.x'

Closes gh-51926
This commit is contained in:
Andy Wilkinson
2026-10-01 12:04:34 +01:00
12 changed files with 574 additions and 18 deletions
@@ -90,7 +90,7 @@ final class JavaPluginAction implements PluginApplicationAction {
configureBootTestRunTask(project, resolveMainTestClassName);
project.afterEvaluate(this::configureUtf8Encoding);
configureParametersCompilerArg(project);
configureAdditionalMetadataLocations(project);
configureConfigurationProcessorCompilerArgs(project);
configureSpringBootStarterTestToDependOnJUnitPlatformLauncher(project);
}
@@ -263,13 +263,13 @@ final class JavaPluginAction implements PluginApplicationAction {
});
}
private void configureAdditionalMetadataLocations(Project project) {
private void configureConfigurationProcessorCompilerArgs(Project project) {
project.afterEvaluate((evaluated) -> evaluated.getTasks()
.withType(JavaCompile.class)
.configureEach(this::configureAdditionalMetadataLocations));
.configureEach(this::configureConfigurationProcessorCompilerArgs));
}
private void configureAdditionalMetadataLocations(JavaCompile compile) {
private void configureConfigurationProcessorCompilerArgs(JavaCompile compile) {
SourceSetContainer sourceSets = compile.getProject()
.getExtensions()
.getByType(JavaPluginExtension.class)
@@ -278,7 +278,12 @@ final class JavaPluginAction implements PluginApplicationAction {
.filter((candidate) -> candidate.getCompileJavaTaskName().equals(compile.getName()))
.map((match) -> match.getResources().getSrcDirs())
.findFirst()
.ifPresent((locations) -> compile.doFirst(new AdditionalMetadataLocationsConfigurer(locations)));
.ifPresent((locations) -> {
File descriptionCache = new File(compile.getTemporaryDir(),
"previous-spring-configuration-metadata.json");
compile.getOutputs().file(descriptionCache);
compile.doFirst(new ConfigurationProcessorCompilerArgsConfigurer(locations, descriptionCache));
});
}
private void configureProductionRuntimeClasspathConfiguration(Project project) {
@@ -342,16 +347,19 @@ final class JavaPluginAction implements PluginApplicationAction {
}
/**
* Task {@link Action} to add additional meta-data locations. We need to use an
* inner-class rather than a lambda due to
* https://github.com/gradle/gradle/issues/5510.
* Task {@link Action} to add compiler args used by
* {@code spring-boot-configuration-processor}. We need to use an inner-class rather
* than a lambda due to https://github.com/gradle/gradle/issues/5510.
*/
private static final class AdditionalMetadataLocationsConfigurer implements Action<Task> {
private static final class ConfigurationProcessorCompilerArgsConfigurer implements Action<Task> {
private final Set<File> locations;
private AdditionalMetadataLocationsConfigurer(Set<File> locations) {
private final File descriptionCache;
private ConfigurationProcessorCompilerArgsConfigurer(Set<File> locations, File descriptionCache) {
this.locations = locations;
this.descriptionCache = descriptionCache;
}
@Override
@@ -361,6 +369,7 @@ final class JavaPluginAction implements PluginApplicationAction {
}
if (hasConfigurationProcessorOnClasspath(compile)) {
configureAdditionalMetadataLocations(compile);
configureDescriptionCacheLocation(compile);
}
}
@@ -379,6 +388,13 @@ final class JavaPluginAction implements PluginApplicationAction {
+ StringUtils.collectionToCommaDelimitedString(this.locations));
}
private void configureDescriptionCacheLocation(JavaCompile compile) {
compile.getOptions()
.getCompilerArgs()
.add("-Aorg.springframework.boot.configurationprocessor.descriptionCacheLocation="
+ this.descriptionCache.getAbsolutePath());
}
}
}
@@ -129,7 +129,7 @@ class JavaPluginActionIntegrationTests {
}
@TestTemplate
void additionalMetadataLocationsConfiguredWhenProcessorIsPresent() throws IOException {
void configurationPropertiesCompilerArgumentsConfiguredWhenProcessorIsPresent() throws IOException {
createMinimalMainSource();
File libs = new File(this.gradleBuild.getProjectDir(), "libs");
libs.mkdirs();
@@ -139,13 +139,19 @@ class JavaPluginActionIntegrationTests {
BuildTask task = result.task(":compileJava");
assertThat(task).isNotNull();
assertThat(task.getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("compileJava compiler args: [-parameters, -Aorg.springframework.boot."
+ "configurationprocessor.additionalMetadataLocations="
+ new File(this.gradleBuild.getProjectDir(), "src/main/resources").getCanonicalPath());
assertThat(result.getOutput())
.contains("compileJava compiler args: [-parameters, -Aorg.springframework.boot."
+ "configurationprocessor.additionalMetadataLocations="
+ new File(this.gradleBuild.getProjectDir(), "src/main/resources").getCanonicalPath()
+ ", -Aorg.springframework.boot.configurationprocessor.descriptionCacheLocation="
+ new File(this.gradleBuild.getProjectDir(),
"build/tmp/compileJava/previous-spring-configuration-metadata.json")
.getCanonicalPath()
+ "]");
}
@TestTemplate
void additionalMetadataLocationsNotConfiguredWhenProcessorIsAbsent() throws IOException {
void configurationPropertiesCompilerArgumentsNotConfiguredWhenProcessorIsAbsent() throws IOException {
createMinimalMainSource();
BuildResult result = this.gradleBuild.build("compileJava");
BuildTask task = result.task(":compileJava");
@@ -154,6 +160,32 @@ class JavaPluginActionIntegrationTests {
assertThat(result.getOutput()).contains("compileJava compiler args: [-parameters]");
}
@TestTemplate
void descriptionCacheLocationIsAnOutputOfCompileJava() throws IOException {
createMinimalMainSource();
BuildResult result = this.gradleBuild.build("compileJava");
BuildTask task = result.task(":compileJava");
assertThat(task).isNotNull();
assertThat(task.getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("compileJava outputs: ")
.contains(new File(this.gradleBuild.getProjectDir(),
"build/tmp/compileJava/previous-spring-configuration-metadata.json")
.getCanonicalPath());
}
@TestTemplate
void compileJavaIsUpToDateOnSecondInvocation() throws IOException {
createMinimalMainSource();
BuildResult result = this.gradleBuild.build("compileJava");
BuildTask task = result.task(":compileJava");
assertThat(task).isNotNull();
assertThat(task.getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
result = this.gradleBuild.build("compileJava");
task = result.task(":compileJava");
assertThat(task).isNotNull();
assertThat(task.getOutcome()).isEqualTo(TaskOutcome.UP_TO_DATE);
}
@TestTemplate
void applyingJavaPluginCreatesDevelopmentOnlyConfiguration() {
assertThat(this.gradleBuild.build("help").getOutput()).contains("developmentOnly exists = true");
@@ -0,0 +1,20 @@
/*
* Copyright 2012-present 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.
*/
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
@@ -0,0 +1,26 @@
/*
* Copyright 2012-present 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.
*/
plugins {
id 'java'
id 'org.springframework.boot' version '{version}'
}
compileJava {
doLast {
println "${name} outputs: ${outputs.files.files}"
}
}
@@ -21,6 +21,7 @@ import java.io.StringWriter;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -77,6 +78,8 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
static final String ADDITIONAL_METADATA_LOCATIONS_OPTION = "org.springframework.boot.configurationprocessor.additionalMetadataLocations";
static final String DESCRIPTION_CACHE_LOCATION_OPTION = "org.springframework.boot.configurationprocessor.descriptionCacheLocation";
static final String CONFIGURATION_PROPERTIES_ANNOTATION = "org.springframework.boot.context.properties.ConfigurationProperties";
static final String CONFIGURATION_PROPERTIES_SOURCE_ANNOTATION = "org.springframework.boot.context.properties.ConfigurationPropertiesSource";
@@ -113,7 +116,8 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
static final String ENDPOINT_ACCESS_ENUM = "org.springframework.boot.actuate.endpoint.Access";
private static final Set<String> SUPPORTED_OPTIONS = Set.of(ADDITIONAL_METADATA_LOCATIONS_OPTION);
private static final Set<String> SUPPORTED_OPTIONS = Set.of(ADDITIONAL_METADATA_LOCATIONS_OPTION,
DESCRIPTION_CACHE_LOCATION_OPTION);
private MetadataStore metadataStore;
@@ -123,6 +127,10 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
private MetadataGenerationEnvironment metadataEnv;
private DescriptionCache descriptionCache;
private final Set<String> classFileBackedTypes = new HashSet<>();
protected String configurationPropertiesAnnotation() {
return CONFIGURATION_PROPERTIES_ANNOTATION;
}
@@ -189,6 +197,10 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
configurationPropertiesSourceAnnotation(), nestedConfigurationPropertyAnnotation(),
deprecatedConfigurationPropertyAnnotation(), constructorBindingAnnotation(), autowiredAnnotation(),
defaultValueAnnotation(), endpointAnnotations(), readOperationAnnotation(), nameAnnotation());
String cacheLocation = env.getOptions().get(DESCRIPTION_CACHE_LOCATION_OPTION);
if (cacheLocation != null) {
this.descriptionCache = new DescriptionCache(cacheLocation);
}
}
@Override
@@ -289,6 +301,9 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
Deque<TypeElement> seen) {
if (!seen.contains(element)) {
seen.push(element);
if (this.descriptionCache != null && !this.metadataEnv.hasSourceTree(element)) {
this.classFileBackedTypes.add(this.metadataEnv.getTypeUtils().getQualifiedName(element));
}
new PropertyDescriptorResolver(this.metadataEnv).resolve(element, source).forEach((descriptor) -> {
this.metadataCollector.add(descriptor.resolveItemMetadata(prefix, this.metadataEnv));
ItemHint itemHint = descriptor.resolveItemHint(prefix, this.metadataEnv);
@@ -405,14 +420,38 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor
protected ConfigurationMetadata writeMetadata() throws Exception {
ConfigurationMetadata metadata = this.metadataCollector.getMetadata();
metadata = mergeAdditionalMetadata(metadata, () -> this.metadataStore.readAdditionalMetadata());
fillCachedDescriptions(metadata);
removeIgnored(metadata);
if (!metadata.getItems().isEmpty()) {
this.metadataStore.writeMetadata(metadata);
updateDescriptionCache(metadata);
return metadata;
}
return null;
}
private void fillCachedDescriptions(ConfigurationMetadata metadata) {
if (this.descriptionCache == null) {
return;
}
for (ItemMetadata item : metadata.getItems()) {
if (item.isOfItemType(ItemMetadata.ItemType.PROPERTY) && item.getDescription() == null
&& item.getSourceType() != null && this.classFileBackedTypes.contains(item.getSourceType())) {
String cached = this.descriptionCache.getDescription(item.getName());
if (cached != null) {
item.setDescription(cached);
}
}
}
}
private void updateDescriptionCache(ConfigurationMetadata metadata) {
if (this.descriptionCache == null) {
return;
}
this.descriptionCache.update(metadata);
}
private void removeIgnored(ConfigurationMetadata metadata) {
for (ItemIgnore itemIgnore : metadata.getIgnored()) {
metadata.removeMetadata(itemIgnore.getType(), itemIgnore.getName());
@@ -0,0 +1,99 @@
/*
* Copyright 2012-present 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.boot.configurationprocessor;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata;
import org.springframework.boot.configurationprocessor.metadata.ItemMetadata;
import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller;
/**
* Cache for property descriptions that persists across incremental builds. Stores
* metadata in a location outside of {@code CLASS_OUTPUT} so that Gradle's incremental
* compilation does not delete it.
*
* @author Agustin Palazzo
* @see ConfigurationMetadataAnnotationProcessor
*/
class DescriptionCache {
private final File cacheFile;
private ConfigurationMetadata cachedMetadata;
DescriptionCache(String cacheFilePath) {
this.cacheFile = new File(cacheFilePath);
this.cachedMetadata = read();
}
/**
* Look up a cached description for the given property name.
* @param propertyName the fully qualified property name
* @return the cached description or {@code null}
*/
String getDescription(String propertyName) {
if (this.cachedMetadata == null) {
return null;
}
for (ItemMetadata item : this.cachedMetadata.getItems()) {
if (item.isOfItemType(ItemMetadata.ItemType.PROPERTY) && propertyName.equals(item.getName())) {
return item.getDescription();
}
}
return null;
}
/**
* Replace the cache with the given metadata. After
* {@link ConfigurationMetadataAnnotationProcessor#fillCachedDescriptions} has
* restored descriptions from the cache, the metadata is the complete picture of the
* current build. Replacing (instead of merging) ensures that entries for deleted or
* de-annotated types are automatically pruned.
* @param metadata the current build's metadata with descriptions already filled
*/
void update(ConfigurationMetadata metadata) {
write(metadata);
this.cachedMetadata = new ConfigurationMetadata(metadata);
}
private ConfigurationMetadata read() {
if (!this.cacheFile.exists()) {
return null;
}
try (FileInputStream in = new FileInputStream(this.cacheFile)) {
return new JsonMarshaller().read(in);
}
catch (Exception ex) {
return null;
}
}
private void write(ConfigurationMetadata metadata) {
this.cacheFile.getParentFile().mkdirs();
try (FileOutputStream out = new FileOutputStream(this.cacheFile)) {
new JsonMarshaller().write(metadata, out);
}
catch (IOException ex) {
// Cache write failure is non-fatal
}
}
}
@@ -137,6 +137,10 @@ class MetadataGenerationEnvironment {
return this.typeUtils;
}
boolean hasSourceTree(TypeElement element) {
return this.fieldValuesParser.hasSourceTree(element);
}
Messager getMessager() {
return this.messager;
}
@@ -30,13 +30,19 @@ import org.springframework.boot.configurationprocessor.fieldvalues.javac.JavaCom
* @since 1.1.2
* @see JavaCompilerFieldValuesParser
*/
@FunctionalInterface
public interface FieldValuesParser {
/**
* Implementation of {@link FieldValuesParser} that always returns an empty result.
*/
FieldValuesParser NONE = (element) -> Collections.emptyMap();
FieldValuesParser NONE = new FieldValuesParser() {
@Override
public Map<String, Object> getFieldValues(TypeElement element) {
return Collections.emptyMap();
}
};
/**
* Return the field values for the given element.
@@ -46,4 +52,16 @@ public interface FieldValuesParser {
*/
Map<String, Object> getFieldValues(TypeElement element) throws Exception;
/**
* Return whether the given element has a source tree available. Elements loaded from
* {@code .class} files during incremental compilation will not have a source tree,
* meaning javadoc comments are unavailable.
* @param element the element to check
* @return {@code true} if the element has source, {@code false} if backed by
* {@code .class}
*/
default boolean hasSourceTree(TypeElement element) {
return true;
}
}
@@ -57,6 +57,16 @@ public class JavaCompilerFieldValuesParser implements FieldValuesParser {
return Collections.emptyMap();
}
@Override
public boolean hasSourceTree(TypeElement element) {
try {
return this.trees.getTree(element) != null;
}
catch (Exception ex) {
return true;
}
}
/**
* {@link TreeVisitor} to collect fields.
*/
@@ -0,0 +1,292 @@
/*
* Copyright 2012-present 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.boot.configurationprocessor;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.StandardLocation;
import javax.tools.ToolProvider;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata;
import org.springframework.boot.configurationprocessor.metadata.ItemMetadata;
import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller;
import org.springframework.boot.configurationprocessor.test.TestConfigurationMetadataAnnotationProcessor;
import org.springframework.boot.configurationsample.incremental.BarProperties;
import org.springframework.boot.configurationsample.incremental.FooProperties;
import org.springframework.boot.configurationsample.record.ExampleRecord;
import org.springframework.core.test.tools.SourceFile;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DescriptionCache} verifying that property descriptions survive
* Gradle-style incremental compilation where unchanged types arrive as {@code .class}
* files (without javadoc).
*
* @author Agustin Palazzo
*/
class DescriptionCacheTests {
private static final String METADATA_PATH = "META-INF/spring-configuration-metadata.json";
@Test
void incrementalBuildWithoutCacheLosesDescription(@TempDir Path tempDir) throws Exception {
Path classOutput = tempDir.resolve("classes");
Path sourceOutput = tempDir.resolve("sources");
Files.createDirectories(classOutput);
Files.createDirectories(sourceOutput);
String classpath = System.getProperty("java.class.path");
String fooSource = SourceFile.forTestClass(FooProperties.class).getContent();
String barSource = SourceFile.forTestClass(BarProperties.class).getContent();
List<JavaFileObject> allSources = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.FooProperties", fooSource),
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
boolean ok = compile(classOutput, sourceOutput, classpath, null, allSources, null);
assertThat(ok).as("Full compilation").isTrue();
assertThat(descriptionOf(readMetadata(classOutput), "foo.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(readMetadata(classOutput), "bar.counter")).isEqualTo("A nice counter description.");
Files.deleteIfExists(classOutput.resolve(METADATA_PATH));
String incrementalCp = classpath + File.pathSeparator + classOutput;
List<JavaFileObject> barOnly = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
List<String> fooAsClass = List
.of("org.springframework.boot.configurationsample.incremental.FooProperties");
ok = compile(classOutput, sourceOutput, incrementalCp, null, barOnly, fooAsClass);
assertThat(ok).as("Incremental compilation").isTrue();
ConfigurationMetadata incremental = readMetadata(classOutput);
assertThat(descriptionOf(incremental, "bar.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(incremental, "foo.counter")).as("BUG: description lost without cache").isNull();
}
@Test
void incrementalBuildWithCachePreservesDescription(@TempDir Path tempDir) throws Exception {
Path classOutput = tempDir.resolve("classes");
Path sourceOutput = tempDir.resolve("sources");
Path cacheFile = tempDir.resolve("description-cache.json");
Files.createDirectories(classOutput);
Files.createDirectories(sourceOutput);
String classpath = System.getProperty("java.class.path");
String fooSource = SourceFile.forTestClass(FooProperties.class).getContent();
String barSource = SourceFile.forTestClass(BarProperties.class).getContent();
List<JavaFileObject> allSources = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.FooProperties", fooSource),
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
boolean ok = compile(classOutput, sourceOutput, classpath, cacheFile, allSources, null);
assertThat(ok).as("Full compilation").isTrue();
assertThat(descriptionOf(readMetadata(classOutput), "foo.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(readMetadata(classOutput), "bar.counter")).isEqualTo("A nice counter description.");
assertThat(Files.exists(cacheFile)).as("Cache file created").isTrue();
Files.deleteIfExists(classOutput.resolve(METADATA_PATH));
String incrementalCp = classpath + File.pathSeparator + classOutput;
List<JavaFileObject> barOnly = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
List<String> fooAsClass = List
.of("org.springframework.boot.configurationsample.incremental.FooProperties");
ok = compile(classOutput, sourceOutput, incrementalCp, cacheFile, barOnly, fooAsClass);
assertThat(ok).as("Incremental compilation").isTrue();
ConfigurationMetadata incremental = readMetadata(classOutput);
assertThat(descriptionOf(incremental, "bar.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(incremental, "foo.counter")).as("Description preserved from cache")
.isEqualTo("A nice counter description.");
}
@Test
void cacheIsPrunedWhenTypeIsRemoved(@TempDir Path tempDir) throws Exception {
Path classOutput = tempDir.resolve("classes");
Path sourceOutput = tempDir.resolve("sources");
Path cacheFile = tempDir.resolve("description-cache.json");
Files.createDirectories(classOutput);
Files.createDirectories(sourceOutput);
String classpath = System.getProperty("java.class.path");
String fooSource = SourceFile.forTestClass(FooProperties.class).getContent();
String barSource = SourceFile.forTestClass(BarProperties.class).getContent();
List<JavaFileObject> allSources = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.FooProperties", fooSource),
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
boolean ok = compile(classOutput, sourceOutput, classpath, cacheFile, allSources, null);
assertThat(ok).as("Full compilation").isTrue();
ConfigurationMetadata cache1 = readJson(cacheFile);
assertThat(descriptionOf(cache1, "foo.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(cache1, "bar.counter")).isEqualTo("A nice counter description.");
Files.deleteIfExists(classOutput.resolve(METADATA_PATH));
List<JavaFileObject> barOnly = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
ok = compile(classOutput, sourceOutput, classpath, cacheFile, barOnly, null);
assertThat(ok).as("Compile without FooProperties").isTrue();
ConfigurationMetadata cache2 = readJson(cacheFile);
assertThat(descriptionOf(cache2, "bar.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(cache2, "foo.counter")).as("Pruned from cache").isNull();
}
@Test
void incrementalBuildWithCachePreservesRecordDescription(@TempDir Path tempDir) throws Exception {
Path classOutput = tempDir.resolve("classes");
Path sourceOutput = tempDir.resolve("sources");
Path cacheFile = tempDir.resolve("description-cache.json");
Files.createDirectories(classOutput);
Files.createDirectories(sourceOutput);
String classpath = System.getProperty("java.class.path");
String recordSource = SourceFile.forTestClass(ExampleRecord.class).getContent();
String barSource = SourceFile.forTestClass(BarProperties.class).getContent();
List<JavaFileObject> allSources = List.of(
inMemorySource("org.springframework.boot.configurationsample.record.ExampleRecord", recordSource),
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
boolean ok = compile(classOutput, sourceOutput, classpath, cacheFile, allSources, null);
assertThat(ok).as("Full compilation").isTrue();
assertThat(descriptionOf(readMetadata(classOutput), "record.descriptions.some-string"))
.isEqualTo("very long description that doesn't fit single line and is indented");
Files.deleteIfExists(classOutput.resolve(METADATA_PATH));
String incrementalCp = classpath + File.pathSeparator + classOutput;
List<JavaFileObject> barOnly = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource));
List<String> recordAsClass = List
.of("org.springframework.boot.configurationsample.record.ExampleRecord");
ok = compile(classOutput, sourceOutput, incrementalCp, cacheFile, barOnly, recordAsClass);
assertThat(ok).as("Incremental compilation").isTrue();
ConfigurationMetadata incremental = readMetadata(classOutput);
assertThat(descriptionOf(incremental, "bar.counter")).isEqualTo("A nice counter description.");
assertThat(descriptionOf(incremental, "record.descriptions.some-string"))
.as("Record description preserved from cache")
.isEqualTo("very long description that doesn't fit single line and is indented");
}
@Test
void corruptCacheFileIsIgnored(@TempDir Path tempDir) throws Exception {
Path classOutput = tempDir.resolve("classes");
Path sourceOutput = tempDir.resolve("sources");
Path cacheFile = tempDir.resolve("description-cache.json");
Files.createDirectories(classOutput);
Files.createDirectories(sourceOutput);
Files.writeString(cacheFile, "{ this is not valid json !!!");
String classpath = System.getProperty("java.class.path");
String fooSource = SourceFile.forTestClass(FooProperties.class).getContent();
List<JavaFileObject> sources = List.of(
inMemorySource("org.springframework.boot.configurationsample.incremental.FooProperties", fooSource));
boolean ok = compile(classOutput, sourceOutput, classpath, cacheFile, sources, null);
assertThat(ok).as("Compilation with corrupt cache").isTrue();
ConfigurationMetadata metadata = readMetadata(classOutput);
assertThat(metadata).isNotNull();
assertThat(descriptionOf(metadata, "foo.counter")).isEqualTo("A nice counter description.");
}
private boolean compile(Path classOutput, Path sourceOutput, String classpath, Path cacheFile,
List<JavaFileObject> sources, List<String> classes) throws IOException {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) {
fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutput.toFile()));
fileManager.setLocation(StandardLocation.SOURCE_OUTPUT, List.of(sourceOutput.toFile()));
fileManager.setLocation(StandardLocation.CLASS_PATH, classpathFiles(classpath));
List<String> options = (cacheFile != null)
? List.of("-A" + ConfigurationMetadataAnnotationProcessor.DESCRIPTION_CACHE_LOCATION_OPTION + "="
+ cacheFile.toAbsolutePath())
: Collections.emptyList();
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, options, classes, sources);
task.setProcessors(List.of(new TestConfigurationMetadataAnnotationProcessor()));
return task.call();
}
}
private List<File> classpathFiles(String classpath) {
return Arrays.stream(classpath.split(File.pathSeparator)).map(File::new).toList();
}
private ConfigurationMetadata readJson(Path file) throws Exception {
if (!Files.exists(file)) {
return null;
}
try (InputStream in = Files.newInputStream(file)) {
return new JsonMarshaller().read(in);
}
}
private ConfigurationMetadata readMetadata(Path classOutput) throws Exception {
return readJson(classOutput.resolve(METADATA_PATH));
}
private String descriptionOf(ConfigurationMetadata metadata, String propertyName) {
if (metadata == null) {
return null;
}
return metadata.getItems()
.stream()
.filter((item) -> item.isOfItemType(ItemMetadata.ItemType.PROPERTY))
.filter((item) -> propertyName.equals(item.getName()))
.findFirst()
.map(ItemMetadata::getDescription)
.orElse(null);
}
private JavaFileObject inMemorySource(String className, String content) {
URI uri = URI.create("string:///" + className.replace('.', '/') + ".java");
return new SimpleJavaFileObject(uri, JavaFileObject.Kind.SOURCE) {
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return content;
}
};
}
}