diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/annotation-processor.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/annotation-processor.adoc index 5219c77c14a4..2f00e998f9f4 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/annotation-processor.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/annotation-processor.adoc @@ -79,6 +79,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-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc index 74a44e6aacbf..0be15d21c0db 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc @@ -256,6 +256,8 @@ include::{docs-java}/features/developingautoconfiguration/customstarter/configur 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-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 d2fdc0787d19..7c737a10cc54 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2022 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. @@ -23,6 +23,7 @@ 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 @@ * 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 @@ protected Object resolveDefaultValue(MetadataGenerationEnvironment environment) return getSource().asType().accept(DefaultPrimitiveTypeVisitor.INSTANCE, null); } + @Override + protected String resolveDescription(MetadataGenerationEnvironment environment) { + // record components descriptions are written using @param tag + if (this.recordComponent != null) { + return environment.getTypeUtils().getJavaDoc(this.recordComponent); + } + return super.resolveDescription(environment); + } + private Object getDefaultValueFromAnnotation(MetadataGenerationEnvironment environment, Element element) { AnnotationMirror annotation = environment.getDefaultValueAnnotation(element); List 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 355ac02b1a84..113ce58f41cf 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2022 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. @@ -155,7 +155,7 @@ private String resolveType(MetadataGenerationEnvironment environment) { 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 084e61f2bc03..75c565d8a76e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -25,6 +25,7 @@ import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.ExecutableElement; 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; @@ -35,6 +36,7 @@ * * @author Stephane Nicoll * @author Phillip Webb + * @author Pavel Anisimov */ class PropertyDescriptorResolver { @@ -82,8 +84,9 @@ Stream> resolveConstructorProperties(TypeElement type, Exe 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, factoryMethod, parameter, name, - propertyType, field, getter, setter)); + propertyType, 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 f51290c654b7..3c157e3c26a4 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2022 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. @@ -27,6 +27,7 @@ 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; @@ -38,6 +39,7 @@ * * @author Stephane Nicoll * @author Phillip Webb + * @author Pavel Anisimov */ class TypeElementMembers { @@ -49,6 +51,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<>(); @@ -66,6 +70,9 @@ private void process(TypeElement element) { for (VariableElement field : ElementFilter.fieldsIn(element.getEnclosedElements())) { processField(field); } + 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())) { process((TypeElement) superType); @@ -163,10 +170,21 @@ private void processField(VariableElement 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 3e2810e3185b..996e188d695f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2022 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. @@ -24,11 +24,13 @@ 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 @@ * * @author Stephane Nicoll * @author Phillip Webb + * @author Pavel Anisimov */ class TypeUtils { @@ -176,6 +179,9 @@ boolean isCollectionOrMap(TypeMirror type) { } 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(); @@ -246,6 +252,24 @@ private void process(TypeDescriptor descriptor, TypeMirror type) { } } + 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 756d4b9a4ca5..3729cd8be779 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -74,6 +74,7 @@ * @author Andy Wilkinson * @author Kris De Volder * @author Jonas Keßler + * @author Pavel Anisimov */ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGenerationTests { @@ -460,4 +461,45 @@ void multiConstructorRecordProperties(@TempDir File temp) throws IOException { assertThat(metadata).doesNotHave(Metadata.withProperty("multi.some-integer")); } + @Test + @EnabledForJreRange(min = JRE.JAVA_16) + void recordPropertiesWithDescriptions(@TempDir File temp) throws IOException { + File exampleRecord = new File(temp, "ExampleRecord.java"); + try (PrintWriter writer = new PrintWriter(new FileWriter(exampleRecord))) { + writer.println("/**"); + writer.println(" * ExampleRecord Javadoc sample"); + writer.println(" *"); + writer.println(" * @author Pavel Anisimov"); + writer.println(" * @param someString very long description that doesn't fit"); + writer.println(" * single line"); + writer.println(" * @param someInteger description with @param and @ pitfalls"); + writer.println(" * @param someBoolean description with extra spaces"); + writer.println(" *@param someLong description without space after asterisk"); + writer.println(" * @since 1.0.0"); + writer.println(" * @param someByte last description in Javadoc"); + writer.println(" */"); + writer.println( + "@org.springframework.boot.configurationsample.ConfigurationProperties(\"record.descriptions\")"); + writer.println("public record ExampleRecord("); + writer.println("String someString,"); + writer.println("Integer someInteger,"); + writer.println("Boolean someBoolean,"); + writer.println("Long someLong,"); + writer.println("Byte someByte"); + writer.println(") {"); + writer.println("}"); + } + ConfigurationMetadata metadata = compile(exampleRecord); + 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 d3d6589315c7..c664f333d5bf 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2022 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. @@ -126,7 +126,7 @@ void constructorParameterDeprecatedPropertyOnGetter() throws IOException { 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(); }); } @@ -213,7 +213,7 @@ protected ConstructorParameterPropertyDescriptor createPropertyDescriptor(TypeEl 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) {