diff --git a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java index 102e655484b5..6c0dbaa52ed4 100644 --- a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java +++ b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java @@ -21,6 +21,7 @@ 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 SUPPORTED_OPTIONS = Set.of(ADDITIONAL_METADATA_LOCATIONS_OPTION); + private static final Set 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 classFileBackedTypes = new HashSet<>(); + protected String configurationPropertiesAnnotation() { return CONFIGURATION_PROPERTIES_ANNOTATION; } @@ -189,6 +197,10 @@ public synchronized void init(ProcessingEnvironment env) { 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 @@ private void processTypeElement(String prefix, TypeElement element, ExecutableEl Deque 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 @@ protected void writeSourceMetadata() throws Exception { 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()); diff --git a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/DescriptionCache.java b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/DescriptionCache.java new file mode 100644 index 000000000000..7e0f762f8ed9 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/DescriptionCache.java @@ -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 + } + } + +} diff --git a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java index ae018ed094a1..3dffac8cd6d6 100644 --- a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java +++ b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java @@ -137,6 +137,10 @@ TypeUtils getTypeUtils() { return this.typeUtils; } + boolean hasSourceTree(TypeElement element) { + return this.fieldValuesParser.hasSourceTree(element); + } + Messager getMessager() { return this.messager; } diff --git a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/FieldValuesParser.java b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/FieldValuesParser.java index 7aa7f8bef302..8600f346d816 100644 --- a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/FieldValuesParser.java +++ b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/FieldValuesParser.java @@ -30,13 +30,19 @@ * @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 getFieldValues(TypeElement element) { + return Collections.emptyMap(); + } + + }; /** * Return the field values for the given element. @@ -46,4 +52,16 @@ public interface FieldValuesParser { */ Map 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; + } + } diff --git a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/javac/JavaCompilerFieldValuesParser.java b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/javac/JavaCompilerFieldValuesParser.java index 8283136d9539..a110f1ad9ba5 100644 --- a/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/javac/JavaCompilerFieldValuesParser.java +++ b/configuration-metadata/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/javac/JavaCompilerFieldValuesParser.java @@ -57,6 +57,16 @@ public Map getFieldValues(TypeElement element) throws Exception 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. */ diff --git a/configuration-metadata/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DescriptionCacheTests.java b/configuration-metadata/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DescriptionCacheTests.java new file mode 100644 index 000000000000..854322bc07ff --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DescriptionCacheTests.java @@ -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 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 barOnly = List.of( + inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource)); + List 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 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 barOnly = List.of( + inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource)); + List 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 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 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 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 barOnly = List.of( + inMemorySource("org.springframework.boot.configurationsample.incremental.BarProperties", barSource)); + List 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 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 sources, List 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 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 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; + } + }; + } + +}