diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/developing-auto-configuration.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/developing-auto-configuration.adoc index 485ce1b307..80eac6fd96 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/developing-auto-configuration.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/developing-auto-configuration.adoc @@ -255,6 +255,8 @@ include-code::AcmeProperties[] NOTE: You should only use plain text with `@ConfigurationProperties` field Javadoc, since they are not processed before being added to the JSON. +If you use `@ConfigurationProperties` with record class then record components' descriptions should be provided via class-level Javadoc tag `@param` (there are no explicit instance fields in record classes to put regular field-level Javadocs on). + Here are some rules we follow internally to make sure descriptions are consistent: * Do not start the description by "The" or "A". diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc index d7504c81c5..6276bae645 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc @@ -89,6 +89,8 @@ The Javadoc on fields is used to populate the `description` attribute. For insta NOTE: You should only use plain text with `@ConfigurationProperties` field Javadoc, since they are not processed before being added to the JSON. +If you use `@ConfigurationProperties` with record class then record components' descriptions should be provided via class-level Javadoc tag `@param` (there are no explicit instance fields in record classes to put regular field-level Javadocs on). + The annotation processor applies a number of heuristics to extract the default value from the source model. Default values have to be provided statically. In particular, do not refer to a constant defined in another class. Also, the annotation processor cannot auto-detect default values for ``Enum``s and ``Collections``s. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java index a567a94658..09d3bacc03 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java @@ -23,6 +23,7 @@ import java.util.function.Function; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.PrimitiveType; @@ -34,13 +35,17 @@ import javax.tools.Diagnostic.Kind; * A {@link PropertyDescriptor} for a constructor parameter. * * @author Stephane Nicoll + * @author Pavel Anisimov */ class ConstructorParameterPropertyDescriptor extends PropertyDescriptor { + private final RecordComponentElement recordComponent; + ConstructorParameterPropertyDescriptor(TypeElement ownerElement, ExecutableElement factoryMethod, - VariableElement source, String name, TypeMirror type, VariableElement field, ExecutableElement getter, - ExecutableElement setter) { + VariableElement source, String name, TypeMirror type, VariableElement field, + RecordComponentElement recordComponent, ExecutableElement getter, ExecutableElement setter) { super(ownerElement, factoryMethod, source, name, type, field, getter, setter); + this.recordComponent = recordComponent; } @Override @@ -59,6 +64,15 @@ class ConstructorParameterPropertyDescriptor extends PropertyDescriptor defaultValue = getDefaultValue(environment, annotation); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java index 83cc347042..4bca3c692c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java @@ -155,7 +155,7 @@ abstract class PropertyDescriptor { return environment.getTypeUtils().getType(getOwnerElement(), getType()); } - private String resolveDescription(MetadataGenerationEnvironment environment) { + protected String resolveDescription(MetadataGenerationEnvironment environment) { return environment.getTypeUtils().getJavaDoc(getField()); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java index c4808a93a7..c554122396 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java @@ -26,6 +26,7 @@ import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Modifier; import javax.lang.model.element.NestingKind; +import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; @@ -36,6 +37,7 @@ import javax.lang.model.util.ElementFilter; * * @author Stephane Nicoll * @author Phillip Webb + * @author Pavel Anisimov */ class PropertyDescriptorResolver { @@ -82,8 +84,9 @@ class PropertyDescriptorResolver { ExecutableElement getter = members.getPublicGetter(name, propertyType); ExecutableElement setter = members.getPublicSetter(name, propertyType); VariableElement field = members.getFields().get(name); + RecordComponentElement recordComponent = members.getRecordComponents().get(name); register(candidates, new ConstructorParameterPropertyDescriptor(type, null, parameter, name, propertyType, - field, getter, setter)); + field, recordComponent, getter, setter)); }); return candidates.values().stream(); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeElementMembers.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeElementMembers.java index d7b073e711..046cb9dc77 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeElementMembers.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeElementMembers.java @@ -27,6 +27,7 @@ import java.util.function.Function; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Modifier; +import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeKind; @@ -39,6 +40,7 @@ import javax.lang.model.util.ElementFilter; * @author Stephane Nicoll * @author Phillip Webb * @author Moritz Halbritter + * @author Pavel Anisimov */ class TypeElementMembers { @@ -54,6 +56,8 @@ class TypeElementMembers { private final Map fields = new LinkedHashMap<>(); + private final Map recordComponents = new LinkedHashMap<>(); + private final Map> publicGetters = new LinkedHashMap<>(); private final Map> publicSetters = new LinkedHashMap<>(); @@ -72,6 +76,9 @@ class TypeElementMembers { for (ExecutableElement method : ElementFilter.methodsIn(element.getEnclosedElements())) { processMethod(method); } + for (RecordComponentElement recordComponent : ElementFilter.recordComponentsIn(element.getEnclosedElements())) { + processRecordComponent(recordComponent); + } Element superType = this.env.getTypeUtils().asElement(element.getSuperclass()); if (superType instanceof TypeElement && !OBJECT_CLASS_NAME.equals(superType.toString()) && !RECORD_CLASS_NAME.equals(superType.toString())) { @@ -189,10 +196,21 @@ class TypeElementMembers { this.fields.putIfAbsent(name, field); } + private void processRecordComponent(RecordComponentElement recordComponent) { + String name = recordComponent.getSimpleName().toString(); + if (!this.recordComponents.containsKey(name)) { + this.recordComponents.put(name, recordComponent); + } + } + Map getFields() { return Collections.unmodifiableMap(this.fields); } + Map getRecordComponents() { + return Collections.unmodifiableMap(this.recordComponents); + } + Map> getPublicGetters() { return Collections.unmodifiableMap(this.publicGetters); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeUtils.java index 7f94f1667b..568b9e6e24 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeUtils.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/TypeUtils.java @@ -24,11 +24,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.Element; +import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; @@ -44,6 +46,7 @@ import javax.lang.model.util.Types; * * @author Stephane Nicoll * @author Phillip Webb + * @author Pavel Anisimov */ class TypeUtils { @@ -176,6 +179,9 @@ class TypeUtils { } String getJavaDoc(Element element) { + if (element instanceof RecordComponentElement) { + return getJavaDoc((RecordComponentElement) element); + } String javadoc = (element != null) ? this.env.getElementUtils().getDocComment(element) : null; if (javadoc != null) { javadoc = NEW_LINE_PATTERN.matcher(javadoc).replaceAll("").trim(); @@ -247,6 +253,24 @@ class TypeUtils { } } + private String getJavaDoc(RecordComponentElement recordComponent) { + String recordJavadoc = this.env.getElementUtils().getDocComment(recordComponent.getEnclosingElement()); + if (recordJavadoc != null) { + Pattern paramJavadocPattern = paramJavadocPattern(recordComponent.getSimpleName().toString()); + Matcher paramJavadocMatcher = paramJavadocPattern.matcher(recordJavadoc); + if (paramJavadocMatcher.find()) { + String paramJavadoc = NEW_LINE_PATTERN.matcher(paramJavadocMatcher.group()).replaceAll("").trim(); + return paramJavadoc.isEmpty() ? null : paramJavadoc; + } + } + return null; + } + + private Pattern paramJavadocPattern(String paramName) { + String pattern = String.format("(?<=@param +%s).*?(?=([\r\n]+ *@)|$)", paramName); + return Pattern.compile(pattern, Pattern.DOTALL); + } + /** * A visitor that extracts the fully qualified name of a type, including generic * information. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java index bc58efcde3..8aaa49d316 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java @@ -22,6 +22,7 @@ import org.springframework.boot.configurationprocessor.metadata.ConfigurationMet import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; import org.springframework.boot.configurationprocessor.metadata.Metadata; import org.springframework.boot.configurationsample.deprecation.Dbcp2Configuration; +import org.springframework.boot.configurationsample.record.ExampleRecord; import org.springframework.boot.configurationsample.record.RecordWithGetter; import org.springframework.boot.configurationsample.recursive.RecursiveProperties; import org.springframework.boot.configurationsample.simple.ClassWithNestedProperties; @@ -516,4 +517,19 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene assertThat(metadata).has(Metadata.withProperty("spring.datasource.dbcp2.password").withNoDeprecation()); } + @Test + void recordPropertiesWithDescriptions() { + ConfigurationMetadata metadata = compile(ExampleRecord.class); + assertThat(metadata).has(Metadata.withProperty("record.descriptions.some-string", String.class) + .withDescription("very long description that doesn't fit single line")); + assertThat(metadata).has(Metadata.withProperty("record.descriptions.some-integer", Integer.class) + .withDescription("description with @param and @ pitfalls")); + assertThat(metadata).has(Metadata.withProperty("record.descriptions.some-boolean", Boolean.class) + .withDescription("description with extra spaces")); + assertThat(metadata).has(Metadata.withProperty("record.descriptions.some-long", Long.class) + .withDescription("description without space after asterisk")); + assertThat(metadata).has(Metadata.withProperty("record.descriptions.some-byte", Byte.class) + .withDescription("last description in Javadoc")); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptorTests.java index 01284252ba..d5554aab88 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptorTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptorTests.java @@ -131,7 +131,7 @@ class ConstructorParameterPropertyDescriptorTests extends PropertyDescriptorTest VariableElement field = getField(ownerElement, "flag"); VariableElement constructorParameter = getConstructorParameter(ownerElement, "flag"); ConstructorParameterPropertyDescriptor property = new ConstructorParameterPropertyDescriptor(ownerElement, - null, constructorParameter, "flag", field.asType(), field, getter, null); + null, constructorParameter, "flag", field.asType(), field, null, getter, null); assertItemMetadata(metadataEnv, property).isProperty().isDeprecatedWithNoInformation(); }); } @@ -223,7 +223,7 @@ class ConstructorParameterPropertyDescriptorTests extends PropertyDescriptorTest ExecutableElement getter = getMethod(ownerElement, createAccessorMethodName("get", name)); ExecutableElement setter = getMethod(ownerElement, createAccessorMethodName("set", name)); return new ConstructorParameterPropertyDescriptor(ownerElement, null, constructorParameter, name, - field.asType(), field, getter, setter); + field.asType(), field, null, getter, setter); } private VariableElement getConstructorParameter(TypeElement ownerElement, String name) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/record/ExampleRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/record/ExampleRecord.java new file mode 100644 index 0000000000..fdffebfbcb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/record/ExampleRecord.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.configurationsample.record; + +/** + * Example Record Javadoc sample + * + * @param someString very long description that doesn't fit single line + * @param someInteger description with @param and @ pitfalls + * @param someBoolean description with extra spaces + * @param someLong description without space after asterisk + * @param someByte last description in Javadoc + * @since 1.0.0 + * @author Pavel Anisimov + */ +@org.springframework.boot.configurationsample.ConfigurationProperties("record.descriptions") +public record ExampleRecord(String someString, Integer someInteger, Boolean someBoolean, Long someLong, Byte someByte) { +}