diff --git a/maven-resolver-tools/pom.xml b/maven-resolver-tools/pom.xml index edd1f926e..eb57659aa 100644 --- a/maven-resolver-tools/pom.xml +++ b/maven-resolver-tools/pom.xml @@ -172,8 +172,8 @@ org.slf4j - slf4j-nop - runtime + slf4j-simple + test diff --git a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java index 387bcc952..79d662b6b 100644 --- a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java +++ b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/CollectConfiguration.java @@ -233,7 +233,7 @@ private static List resolveClasspath() { * Reads back the intermediate properties file produced by {@link ConfigurationCollectorDoclet} into the list of * maps consumed by the Velocity templates. */ - protected List> readDiscoveredKeys(Path intermediateFile) throws Exception { + static List> readDiscoveredKeys(Path intermediateFile) throws Exception { Properties properties = new Properties(); try (Reader reader = Files.newBufferedReader(intermediateFile, StandardCharsets.UTF_8)) { properties.load(reader); diff --git a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java index 5ed5fab57..b4eecab79 100644 --- a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java +++ b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/ConfigurationCollectorDoclet.java @@ -26,22 +26,30 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.PrimitiveType; +import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import javax.tools.Diagnostic; import java.io.IOException; -import java.io.UncheckedIOException; +import java.io.PrintWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Properties; import java.util.Set; @@ -52,6 +60,7 @@ import com.sun.source.doctree.EntityTree; import com.sun.source.doctree.LinkTree; import com.sun.source.doctree.LiteralTree; +import com.sun.source.doctree.ReferenceTree; import com.sun.source.doctree.SinceTree; import com.sun.source.doctree.StartElementTree; import com.sun.source.doctree.SystemPropertyTree; @@ -90,15 +99,52 @@ public class ConfigurationCollectorDoclet implements Doclet { */ private static final String MAVEN_CONFIG_ANNOTATION = "org.apache.maven.api.annotations.Config"; + private static final MethodReference METHOD_REFERENCE_SESSION_CONFIGURATION = new MethodReference( + "org.eclipse.aether.RepositorySystemSession", "getConfigProperties", "java.util.Map", List.of()); + private static final MethodReference METHOD_REFERENCE_SYSTEM_PROPERTY = new MethodReference( + "java.lang.System", "getProperty", "java.lang.String", List.of("java.lang.String", "java.lang.String")); + private Reporter reporter; private Path output; + private enum Mode { + RESOLVER, + MAVEN + } + + /** + * Represents a configuration key and its associated metadata. + * @param key The configuration key (e.g. {@code "aether.connector.resume"}). + * @param description The description of the configuration key, rendered as HTML. + * @param defaultValue The default value of the configuration key, if any. + * @param fqName The fully qualified name of the field that declares the configuration key. + * @param since The {@code @since} version of the configuration key, if any. + * @param source From where the configuration key is sourced (e.g. "Session Configuration", "User Properties", "System Properties"). + * @param type The Java type of the configuration key (e.g. "String", "Integer", "Boolean"). + * @param supportsRepoIdSuffix Whether the configuration key supports a repository ID suffix (e.g. {@code "aether.connector.resume."}). + */ + public record ConfigurationEntry( + String key, + String description, + String defaultValue, + String fqName, + String since, + String source, + String type, + boolean supportsRepoIdSuffix) { + + public ConfigurationEntry { + Objects.requireNonNull(key); + Objects.requireNonNull(description); + } + } + /** * The scanning mode; either {@code resolver} (Javadoc block tags) or {@code maven} (the {@code @Config} * annotation). Defaults to {@code resolver}. */ - private String mode = "resolver"; + private Mode mode = Mode.RESOLVER; private DocTrees docTrees; @@ -115,18 +161,31 @@ public String getName() { @Override public Set getSupportedOptions() { return Set.of( - new SimpleOption( + new SingleArgumentOption( List.of("--output", "-o"), - 1, "The intermediate properties file to write discovered keys to", "", - args -> output = Paths.get(args.get(0))), - new SimpleOption( - List.of("--mode", "-m"), - 1, - "The scanning mode, either 'resolver' or 'maven'", - "", - args -> mode = args.get(0))); + arg -> { + try { + output = Paths.get(arg); + } catch (InvalidPathException e) { + throw new IllegalArgumentException("Invalid output file path: " + arg, e); + } + }), + new SingleArgumentOption( + List.of("--mode", "-m"), "The scanning mode, either 'resolver' or 'maven'", "", arg -> { + try { + Mode.valueOf(arg.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid mode: " + arg + ". Must be one of (case-insensitive): " + + String.join( + ", ", + Arrays.stream(Mode.values()) + .map(Enum::name) + .toArray(String[]::new))); + } + })); } @Override @@ -139,82 +198,154 @@ public boolean run(DocletEnvironment environment) { try { return doRun(environment); } catch (RuntimeException e) { - reporter.print(Diagnostic.Kind.ERROR, "Error running ConfigurationCollectorDoclet: " + e.getMessage()); - e.printStackTrace(reporter.getStandardWriter()); + reportError("Error running ConfigurationCollectorDoclet: " + e.getMessage()); return false; } } private boolean doRun(DocletEnvironment environment) { if (output == null) { - throw new IllegalStateException("Missing required --output option"); + reportError("Missing required --output option"); + return false; } docTrees = environment.getDocTrees(); - List> discoveredKeys = new ArrayList<>(); + List configurationEntries = new ArrayList<>(); Set types = ElementFilter.typesIn(environment.getIncludedElements()); for (TypeElement type : types) { for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { + // check if relevant metadata is present before processing the field, so that we can skip any fields + // that don't have a constant value or Javadoc if (field.getConstantValue() == null) { continue; } DocCommentTree docComment = docTrees.getDocCommentTree(field); - if ("maven".equals(mode)) { - processMavenField(type, field, docComment, discoveredKeys); - } else if ("resolver".equals(mode)) { - processResolverField(type, field, docComment, discoveredKeys); - } else { - throw new IllegalArgumentException("Unknown mode: " + mode); + if (docComment == null) { + // javadoc is mandatory for configuration keys, so skip any fields that don't have a doc comment + // reporter.print(Diagnostic.Kind., field, "Skipping field because it has no Javadoc"); + continue; + } + DocTreePath rootPath = new DocTreePath(docTrees.getPath(field), docComment); + try { + ConfigurationEntry entry; + switch (mode) { + case MAVEN: + entry = processMavenField(rootPath, type, field); + break; + case RESOLVER: + entry = processResolverField(rootPath, type, field); + break; + default: + throw new IllegalStateException("Unknown mode: " + mode); + } + if (entry != null) { + configurationEntries.add(entry); + } + } catch (DocTreePathAwareRuntimeException e) { + reportError(e.getDocTreePath(), e.getMessage()); + } catch (IllegalArgumentException e) { + reportError(rootPath, e.getMessage()); + } catch (RuntimeException e) { + // log with stacktrace for unexpected errors, but continue + reportError(rootPath, e); } } } - writeProperties(discoveredKeys); + try { + writeProperties(configurationEntries); + } catch (IOException e) { + reportError("Failed to write properties file: " + e.getMessage()); + return false; + } return true; } - private void processResolverField( - TypeElement type, VariableElement field, DocCommentTree docComment, List> discovered) { - if (docComment == null) { - return; + /** + * Reports an error message at a specific DocTreePath location. + * + * @param path the DocTreePath where the error occurred + * @param message the error message + */ + private void reportError(DocTreePath path, Throwable throwable) { + reportError(path, throwable.getMessage()); + // also emit stack trace + PrintWriter pw = reporter.getDiagnosticWriter(); + if (pw == null) { + pw = new PrintWriter(System.err); + } + throwable.printStackTrace(pw); + } + + /** + * Reports an error message at a specific DocTreePath location. + * + * @param path the DocTreePath where the error occurred + * @param message the error message + */ + private void reportError(DocTreePath path, String message) { + if (path != null) { + reporter.print(Diagnostic.Kind.ERROR, path, message); + } else { + reportError(message); } - Map> blockTags = collectBlockTags(docComment); + } + + /** + * Reports a global error message without location information. + * + * @param message the error message + */ + private void reportError(String message) { + reporter.print(Diagnostic.Kind.ERROR, message); + } + + /** + * Processes a configuration key field declared in Javadoc sources. + * @param path + * @param type + * @param field + * @return the extracted configuration entry (or {@code null}) + */ + private ConfigurationEntry processResolverField(DocTreePath path, TypeElement type, VariableElement field) { + Objects.requireNonNull(path); + Objects.requireNonNull(field); + Map blockTags = collectBlockTags(path.getDocComment()); if (!blockTags.containsKey("configurationSource")) { - return; + return null; } + return new ConfigurationEntry( + String.valueOf(field.getConstantValue()), + getFullBodyContent(path), + resolveDefaultValue(path, blockTags).orElse(""), + type.getQualifiedName() + "." + field.getSimpleName(), + getSince(path, type).orElse(""), + getConfigurationSource(path, blockTags).orElse(""), + getConfigurationType(path, blockTags), + isSupportsRepoIdSuffix(path, blockTags)); + } - String configurationType = - getConfigurationType(extractClassLink(field, docComment, blockTags.get("configurationType"))); - String defValue = resolveDefaultValue(type, field, docComment, blockTags.get("configurationDefaultValue")); - - Map entry = new LinkedHashMap<>(); - entry.put("key", String.valueOf(field.getConstantValue())); - entry.put("defaultValue", Objects.toString(defValue, "")); - entry.put("fqName", type.getQualifiedName() + "." + field.getSimpleName()); - entry.put("description", renderContent(docComment.getFullBody(), field, docComment, true)); - entry.put("since", Objects.toString(getSince(type, docComment, field), "")); - entry.put( - "configurationSource", - getConfigurationSource(renderContent(blockTags.get("configurationSource"), field, docComment, true))); - entry.put("configurationType", configurationType); - entry.put( - "supportRepoIdSuffix", - toYesNo(renderContent(blockTags.get("configurationRepoIdSuffix"), field, docComment, true))); - discovered.add(entry); + private boolean isSupportsRepoIdSuffix(DocTreePath path, Map blockTags) { + UnknownBlockTagTree repoIdTag = blockTags.get("configurationRepoIdSuffix"); + if (repoIdTag != null) { + String content = renderContent(DocTreePath.getPath(path, repoIdTag), RenderMode.PLAIN, true); + return "yes".equalsIgnoreCase(content) || "true".equalsIgnoreCase(content); + } + return false; } /** * Processes a constant field declared in Maven sources. Maven declares configuration keys via the * {@code org.apache.maven.api.annotations.Config} annotation (rather than the custom Javadoc block tags used by * Resolver), so the metadata is read from that annotation's attributes. + * @return */ // TODO: move to Maven repository module and use the Maven annotation type directly (currently we don't have a // dependency on Maven API) - private void processMavenField( - TypeElement type, VariableElement field, DocCommentTree docComment, List> discovered) { + private ConfigurationEntry processMavenField(DocTreePath path, TypeElement type, VariableElement field) { AnnotationMirror config = getAnnotation(field, MAVEN_CONFIG_ANNOTATION); if (config == null) { - return; + return null; } String source = "USER_PROPERTIES"; @@ -226,8 +357,8 @@ private void processMavenField( Object value = attribute.getValue().getValue(); switch (name) { case "source": - source = value instanceof VariableElement - ? ((VariableElement) value).getSimpleName().toString() + source = value instanceof VariableElement variableElement + ? variableElement.getSimpleName().toString() : String.valueOf(value); break; case "defaultValue": @@ -262,19 +393,15 @@ private void processMavenField( configurationType = configurationType.substring("java.util.".length()); } - String description = docComment != null ? renderContent(docComment.getFullBody(), field, docComment, true) : ""; - description = description.replace("*", "\\*"); - - Map entry = new LinkedHashMap<>(); - entry.put("key", String.valueOf(field.getConstantValue())); - entry.put("defaultValue", Objects.toString(defaultValue, "")); - entry.put("fqName", ""); - entry.put("description", Objects.toString(description, "")); - entry.put("since", Objects.toString(getSince(type, docComment, field), "")); - entry.put("configurationSource", source); - entry.put("configurationType", configurationType); - entry.put("supportRepoIdSuffix", ""); - discovered.add(entry); + return new ConfigurationEntry( + String.valueOf(field.getConstantValue()), + path.getDocComment() != null ? getFullBodyContent(path) : "", + Objects.toString(defaultValue, ""), + type.getQualifiedName() + "." + field.getSimpleName(), + Objects.toString(getSince(path, type), ""), + source, + configurationType, + false); } private AnnotationMirror getAnnotation(Element element, String fqName) { @@ -288,69 +415,76 @@ private AnnotationMirror getAnnotation(Element element, String fqName) { return null; } - private void writeProperties(List> discoveredKeys) { + private void writeProperties(List configurationEntries) throws IOException { Properties properties = new Properties(); - properties.setProperty("keys.count", String.valueOf(discoveredKeys.size())); - for (int i = 0; i < discoveredKeys.size(); i++) { - Map entry = discoveredKeys.get(i); - for (Map.Entry field : entry.entrySet()) { - properties.setProperty("keys." + i + "." + field.getKey(), field.getValue()); - } + properties.setProperty("keys.count", String.valueOf(configurationEntries.size())); + for (int i = 0; i < configurationEntries.size(); i++) { + ConfigurationEntry entry = configurationEntries.get(i); + writeEntry(properties, entry, "keys." + i + "."); } - try { - if (output.getParent() != null) { - Files.createDirectories(output.getParent()); - } - try (Writer writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { - properties.store(writer, "Generated by ConfigurationCollectorDoclet - DO NOT EDIT"); - } - } catch (IOException e) { - throw new UncheckedIOException(e); + if (output.getParent() != null) { + Files.createDirectories(output.getParent()); + } + try (Writer writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { + properties.store(writer, "Generated by ConfigurationCollectorDoclet - DO NOT EDIT"); } } + private void writeEntry(Properties properties, ConfigurationEntry entry, String prefix) { + properties.setProperty(prefix + "key", entry.key()); + properties.setProperty(prefix + "defaultValue", entry.defaultValue()); + properties.setProperty(prefix + "fqName", entry.fqName()); + properties.setProperty(prefix + "description", entry.description()); + properties.setProperty(prefix + "since", entry.since()); + properties.setProperty(prefix + "configurationSource", entry.source()); + properties.setProperty(prefix + "configurationType", entry.type()); + properties.setProperty(prefix + "supportRepoIdSuffix", toYesNo(entry.supportsRepoIdSuffix())); + } + // --- Javadoc extraction helpers ------------------------------------------------------------------------------- - private Map> collectBlockTags(DocCommentTree docComment) { - Map> result = new LinkedHashMap<>(); + private Map collectBlockTags(DocCommentTree docComment) { + Map result = new LinkedHashMap<>(); for (DocTree tag : docComment.getBlockTags()) { - if (tag instanceof UnknownBlockTagTree) { - UnknownBlockTagTree unknown = (UnknownBlockTagTree) tag; - result.put(unknown.getTagName(), unknown.getContent()); + if (tag instanceof UnknownBlockTagTree unknownBlockTree) { + result.put(unknownBlockTree.getTagName(), unknownBlockTree); } } return result; } - private String resolveDefaultValue( - TypeElement type, - VariableElement contextField, - DocCommentTree docComment, - List content) { - if (content == null || content.isEmpty()) { - return null; + private String getFullBodyContent(DocTreePath path) { + return renderContent(path, RenderMode.HTML, true, path.getDocComment().getFullBody()); + } + + private Optional resolveDefaultValue(DocTreePath path, Map blockTags) { + UnknownBlockTagTree defaultValueTag = blockTags.get("configurationDefaultValue"); + if (defaultValueTag == null) { + return Optional.empty(); } - for (DocTree tree : content) { - if (tree instanceof LinkTree) { - LinkTree link = (LinkTree) tree; - if (link.getReference() != null) { - String signature = link.getReference().getSignature(); - // resolve the referenced constant using the fully qualified signature, so that references - // to constants declared in other types (e.g. {@link OtherType#CONSTANT}) can be resolved - VariableElement referenced = resolveReferencedField(contextField, docComment, link); - String value = referenced != null - ? lookupConstant(referenced) - : lookupConstant(type, signature.substring(signature.indexOf('#') + 1)); - if (value == null) { - // hard fail as in the original implementation: default value constants must be resolvable - throw new IllegalArgumentException("Could not look up {@link " + signature - + "} for configuration " + type.getQualifiedName()); - } - return value; + DocTreePath defaultValuePath = DocTreePath.getPath(path, defaultValueTag); + for (DocTree tree : defaultValueTag.getContent()) { + if (tree instanceof LinkTree link) { + String signature = link.getReference().getSignature(); + DocTreePath linkTreePath = DocTreePath.getPath(path, tree); + // resolve the referenced constant using the fully qualified signature, so that references + // to constants declared in other types (e.g. {@link OtherType#CONSTANT}) can be resolved + VariableElement referenced = resolveReferencedField(linkTreePath, link); + String value = referenced != null ? lookupConstant(referenced) : null; + if (value == null) { + // hard fail: default value constants must be resolvable; report at the precise + // link-reference location if we can resolve a path to it, otherwise at the block tag + DocTreePath linkRefPath = DocTreePath.getPath(linkTreePath, link.getReference()); + throw new DocTreePathAwareRuntimeException( + linkRefPath != null ? linkRefPath : linkTreePath, + "Could not resolve link to determine default value: " + signature); } + return Optional.ofNullable(value); } } - return renderContent(content, contextField, docComment, true); + // fallback: render the content of the block tag as-is (e.g. if it contains a literal value rather than a {@code + // {@link ...}} reference) + return Optional.of(renderContent(defaultValuePath, RenderMode.PLAIN, true)); } /** @@ -358,30 +492,13 @@ private String resolveDefaultValue( * signature (so references into other types are supported). Returns {@code null} if the reference cannot be * resolved to a field. */ - private VariableElement resolveReferencedField( - VariableElement contextField, DocCommentTree docComment, LinkTree link) { - if (contextField == null || docComment == null) { - return null; - } - DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), docComment); - DocTreePath refPath = DocTreePath.getPath(rootPath, link.getReference()); + private VariableElement resolveReferencedField(DocTreePath path, LinkTree link) { + DocTreePath refPath = DocTreePath.getPath(path, link.getReference()); if (refPath == null) { return null; } Element element = docTrees.getElement(refPath); - return element instanceof VariableElement ? (VariableElement) element : null; - } - - private String lookupConstant(TypeElement type, String constantName) { - for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { - if (field.getSimpleName().contentEquals(constantName)) { - String value = lookupConstant(field); - if (value != null) { - return value; - } - } - } - return null; + return element instanceof VariableElement variableElement ? variableElement : null; } private String lookupConstant(VariableElement field) { @@ -426,24 +543,35 @@ private String resolveEnumReference(VariableElement field) { return enumConstant; } - private String extractClassLink( - VariableElement contextField, DocCommentTree docComment, List content) { - if (content == null || content.isEmpty()) { - throw new IllegalArgumentException("Missing content for @configurationDefaultValue"); - } - for (DocTree tree : content) { - // just use the first link, ignore any other content (e.g. text) in the tag + private Optional getFirstLinkInBlockTag(UnknownBlockTagTree tag) { + for (DocTree tree : tag.getContent()) { if (tree instanceof LinkTree link) { - String signature = link.getReference().getSignature(); - if (signature.contains("#")) { - throw new IllegalArgumentException( - "Expected a class link in @configurationDefaultValue, but got a member reference: " - + signature); - } - return resolveReferencedType(contextField, docComment, link, signature); + return Optional.of(link); } } - throw new IllegalArgumentException("No valid {@link ...} reference found in @configurationDefaultValue"); + return Optional.empty(); + } + + /** + * Resolves the fully qualified type name a {@code {@link ...}} reference points to. + * @param path the path of the given inline link tag + * @param link the inline link tag + * @return + */ + private String getType(DocTreePath path, LinkTree link) { + String signature = link.getReference().getSignature(); + if (signature.contains("#")) { + // report at the precise link reference node within the block tag + DocTreePath linkRefPath = DocTreePath.getPath(path, link.getReference()); + throw new DocTreePathAwareRuntimeException( + linkRefPath != null ? linkRefPath : path, + "Expected a class link, but got a member reference: " + signature); + } + // resolve the referenced type and return its fully qualified name, falling back to the raw signature if it + // cannot be resolved + return resolveReferencedType(path, link.getReference()) + .map(t -> t.getQualifiedName().toString()) + .orElse(signature); } /** @@ -451,50 +579,54 @@ private String extractClassLink( * declared via imports are expanded). Falls back to the raw signature if the reference cannot be resolved to a * type. */ - private String resolveReferencedType( - VariableElement contextField, DocCommentTree docComment, LinkTree link, String signature) { - if (contextField == null || docComment == null) { - return signature; - } - DocTreePath rootPath = new DocTreePath(docTrees.getPath(contextField), docComment); - DocTreePath refPath = DocTreePath.getPath(rootPath, link.getReference()); + private Optional resolveReferencedType(DocTreePath path, ReferenceTree reference) { + // TODO: try to resolve from type outside the current compilation unit (e.g. from imports) + DocTreePath refPath = DocTreePath.getPath(path, reference); if (refPath == null) { - return signature; + return Optional.empty(); } Element element = docTrees.getElement(refPath); - return element instanceof TypeElement - ? ((TypeElement) element).getQualifiedName().toString() - : signature; + return element instanceof TypeElement typeElement ? Optional.of(typeElement) : Optional.empty(); + } + + enum RenderMode { + /** Render the content as plain text. Stripping any rich text markup */ + PLAIN, + /** Render the content as HTML, escaping special characters and rendering inline tags. */ + HTML + } + + private String renderContent(DocTreePath docTreePath, RenderMode mode, boolean trim) { + return renderContent(docTreePath, mode, trim, null); } /** * Renders the content of a Javadoc tag into an HTML string, escaping HTML special characters and rendering inline tags. - * @param content the javadoc content to render, e.g. the body of a {@code @configurationSource} tag - * @param context the field element the content is associated with, used for resolving {@code {@link ...}} references - * @param contextDoc the top-level doc comment tree the content is associated with, used for resolving {@code {@link ...}} references + * + * @param docTreePath encapsulates the doc comment tree and the path to the content being rendered. + * The latter is used for resolving {@code {@link ...}} references and emitting error messages. * @param trim if true, trims the result string (may destroy {@code
 
} formatting). - * @return the rendered content, or null if the input content is null + * @param docTrees the doc trees for which to render the content. If {@code null}, the leaf of the {@code docTreePath} is rendered. + * @return the rendered content (never {@code null}) * @see Javadoc tags * @see InlineTagTree (common superinterface of all inline tags) */ private String renderContent( - List content, VariableElement context, DocCommentTree contextDoc, boolean trim) { - if (content == null) { - return null; - } + DocTreePath docTreePath, RenderMode mode, boolean trim, Collection docTreesToRender) { + Objects.requireNonNull(docTreePath, "docTreePath must not be null"); StringBuilder sb = new StringBuilder(); SimpleDocTreeVisitor visitor = new SimpleDocTreeVisitor() { @Override public String visitText(TextTree node, Void p) { - return escapeHtml(node.getBody()); + return escape(mode, node.getBody()); } @Override public String visitLink(LinkTree node, Void p) { String ref = node.getReference() != null ? node.getReference().getSignature() : ""; - String label = renderContent(node.getLabel(), null, null, false); + String label = renderContent(DocTreePath.getPath(docTreePath, node.getReference()), mode, false); String text = label == null || label.isEmpty() ? ref : label; - return node.getKind() == DocTree.Kind.LINK_PLAIN ? escapeHtml(text) : renderAsCode(text); + return node.getKind() == DocTree.Kind.LINK_PLAIN ? escape(mode, text) : renderAsCode(text); } @Override @@ -502,7 +634,7 @@ public String visitLiteral(LiteralTree node, Void p) { if (node.getKind() == DocTree.Kind.CODE) { return renderAsCode(node.getBody().getBody()); } else { - return escapeHtml(node.getBody().getBody()); + return escape(mode, node.getBody().getBody()); } } @@ -512,14 +644,17 @@ public String visitSystemProperty(SystemPropertyTree node, Void p) { } private String renderAsCode(String text) { - return "" + escapeHtml(text) + ""; + if (mode == RenderMode.HTML) { + return "" + escape(mode, text) + ""; + } else { + return escape(mode, text); + } } @Override public String visitValue(ValueTree node, Void p) { - if (node.getReference() != null && context != null && contextDoc != null) { - DocTreePath rootPath = new DocTreePath(docTrees.getPath(context), contextDoc); - DocTreePath refPath = DocTreePath.getPath(rootPath, node.getReference()); + if (node.getReference() != null) { + DocTreePath refPath = DocTreePath.getPath(docTreePath, node.getReference()); if (refPath != null) { Element element = docTrees.getElement(refPath); if (element instanceof VariableElement ve) { @@ -545,7 +680,7 @@ public String visitStartElement(StartElementTree node, Void p) { if (a.getValueKind() != AttributeTree.ValueKind.EMPTY) { String quote = a.getValueKind() == AttributeTree.ValueKind.SINGLE ? "'" : "\""; sb.append("=").append(quote); - sb.append(renderContent(a.getValue(), null, null, false)); + sb.append(renderContent(DocTreePath.getPath(docTreePath, a), mode, trim)); sb.append(quote); } } else { @@ -566,14 +701,30 @@ public String visitEntity(EntityTree node, Void p) { return "&" + node.getName() + ";"; } + @Override + public String visitUnknownBlockTag(UnknownBlockTagTree node, Void p) { + StringBuilder sb = new StringBuilder(); + node.getContent().forEach(child -> sb.append(child.accept(this, p))); + return sb.toString(); + } + + @Override + public String visitSince(SinceTree node, Void p) { + return escape(mode, node.getBody().toString()); + } + @Override protected String defaultAction(DocTree node, Void p) { return node.toString(); } }; - for (DocTree tree : content) { - sb.append(tree.accept(visitor, null)); + if (docTreesToRender == null) { + docTreesToRender = Collections.singleton(docTreePath.getLeaf()); + } + for (DocTree docTreeToRender : docTreesToRender) { + sb.append(docTreeToRender.accept(visitor, null)); } + if (trim) { // normalize whitespace not relevant for HTML rendering, // trimming behaviour already differs between different Javadoc @@ -584,73 +735,158 @@ protected String defaultAction(DocTree node, Void p) { } } - private String escapeHtml(String text) { - return text.replace("&", "&").replace("<", "<").replace(">", ">"); + private static String escape(RenderMode mode, String text) { + if (mode == RenderMode.HTML) { + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } else { + return text; + } } - private String getSince(TypeElement type, DocCommentTree docComment, VariableElement fieldContext) { - String since = getSinceTag(docComment, fieldContext); + private Optional getSince(DocTreePath path, TypeElement type) { + String since = getSinceTag(path); if (since == null && type != null) { // fall back to the enclosing type's @since - since = getSinceTag(docTrees.getDocCommentTree(type), null); + DocCommentTree typeDocTree = docTrees.getDocCommentTree(type); + if (typeDocTree != null) { + since = getSinceTag(DocTreePath.getPath(path, docTrees.getDocCommentTree(type))); + } } - return since; + return Optional.ofNullable(since); } - private String getSinceTag(DocCommentTree docComment, VariableElement fieldContext) { - if (docComment == null) { + private String getSinceTag(DocTreePath path) { + if (path == null) { + // may be non existent return null; } - for (DocTree tag : docComment.getBlockTags()) { - if (tag instanceof SinceTree sinceTree) { - return renderContent(sinceTree.getBody(), fieldContext, docComment, true); + for (DocTree tag : path.getDocComment().getBlockTags()) { + if (tag instanceof SinceTree) { + return renderContent(DocTreePath.getPath(path, tag), RenderMode.PLAIN, true); } } return null; } - private String getConfigurationType(String type) { - if (type != null) { - String javaLangPackage = "java.lang."; - if (type.startsWith(javaLangPackage)) { - type = type.substring(javaLangPackage.length()); + private String getConfigurationType(DocTreePath path, Map blockTags) { + UnknownBlockTagTree typeTag = blockTags.get("configurationType"); + if (typeTag == null) { + throw new IllegalStateException("Missing block tag @configurationType"); + } + DocTreePath configurationTypePath = DocTreePath.getPath(path, typeTag); + LinkTree linkTree = getFirstLinkInBlockTag(typeTag) + .orElseThrow(() -> new DocTreePathAwareRuntimeException( + configurationTypePath, "No valid {@link ...} reference found in @" + typeTag.getTagName())); + + String type = getType(configurationTypePath, linkTree); + String javaLangPackage = "java.lang."; + if (type.startsWith(javaLangPackage)) { + type = type.substring(javaLangPackage.length()); + } + return type; + } + + private Optional getConfigurationSource(DocTreePath path, Map blockTags) { + UnknownBlockTagTree configurationSourceTag = blockTags.get("configurationSource"); + if (configurationSourceTag == null) { + return Optional.empty(); + } + DocTreePath configurationSourcePath = DocTreePath.getPath(path, configurationSourceTag); + LinkTree linkTree = getFirstLinkInBlockTag(configurationSourceTag) + .orElseThrow(() -> new DocTreePathAwareRuntimeException( + configurationSourcePath, + "No valid {@link ...} reference found in @" + configurationSourceTag.getTagName())); + + // javadoc signature is not normalized, use the resolved reference (leveraging ReferenceParser) to get a unique + // canonical representation of the referenced method + MethodReference methodReference = getReferencedMethod(configurationSourcePath, linkTree); + if (methodReference.equals(METHOD_REFERENCE_SESSION_CONFIGURATION)) { + return Optional.of("Session Configuration"); + } else if (methodReference.equals(METHOD_REFERENCE_SYSTEM_PROPERTY)) { + return Optional.of("Java System Properties"); + } else { + reporter.print( + Diagnostic.Kind.WARNING, + path, + "Unknown configuration source: " + linkTree.getReference().getSignature() + + ", using raw signature as source"); + return Optional.of(linkTree.getReference().getSignature()); + } + } + + /** + * Represents a reference to a method, including the fully qualified class name, method name, return type, and parameter types. + * This is supposed to be unique as well as canonical. + * @param fullyQualifiedClassName the fully qualified name of the class containing the method + * @param methodName the name of the method + * @param returnType the fully qualified name (for declared types) or the simple name (for primitive types) of the return type of the method + * @param fullyQualifiedParameterTypes a list of fully qualified names (for declared types) or simple names (for primitive types) of the parameter types of the method + */ + public record MethodReference( + String fullyQualifiedClassName, + String methodName, + String returnType, + List fullyQualifiedParameterTypes) {} + + private MethodReference getReferencedMethod(DocTreePath path, LinkTree link) { + ExecutableElement ee = getReferencedExecutableElement(path, link); + String fullyQualifiedClassName = + ((TypeElement) ee.getEnclosingElement()).getQualifiedName().toString(); + String methodName = ee.getSimpleName().toString(); + String returnType = getFullyQualifiedOrSimpleTypeName(ee.getReturnType()); + List parameterTypes = ee.getParameters().stream() + .map(p -> getFullyQualifiedOrSimpleTypeName(p.asType())) + .toList(); + return new MethodReference(fullyQualifiedClassName, methodName, returnType, parameterTypes); + } + + static String getFullyQualifiedOrSimpleTypeName(TypeMirror typeMirror) { + if (typeMirror instanceof DeclaredType declaredType) { + Element element = declaredType.asElement(); + if (element instanceof TypeElement typeElement) { + return typeElement.getQualifiedName().toString(); } + } else if (typeMirror instanceof PrimitiveType primitiveType) { + return primitiveType.toString(); } - return Objects.toString(type, "n/a"); + throw new IllegalArgumentException("TypeMirror is neither a declared type nor primitive type: " + typeMirror); } - private String getConfigurationSource(String source) { - if ("RepositorySystemSession#getConfigProperties()".equals(source)) { - return "Session Configuration"; - } else if ("System#getProperty(String,String)".equals(source)) { - return "Java System Properties"; + private ExecutableElement getReferencedExecutableElement(DocTreePath path, LinkTree link) { + DocTreePath linkRefPath = DocTreePath.getPath(path, link.getReference()); + if (linkRefPath == null) { + throw new DocTreePathAwareRuntimeException( + path, + "Could not resolve link reference: " + link.getReference().getSignature()); + } + Element element = docTrees.getElement(linkRefPath); + if (element instanceof ExecutableElement ee) { + return ee; } else { - return source; + throw new DocTreePathAwareRuntimeException( + linkRefPath, "Expected an executable element, but got: " + element); } } - private String toYesNo(String value) { - return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No"; + private static String toYesNo(boolean value) { + return value ? "Yes" : "No"; } /** * Minimal {@link Option} implementation. */ - private static final class SimpleOption implements Option { + private static final class SingleArgumentOption implements Option { private final List names; - private final int argumentCount; private final String description; private final String parameters; - private final java.util.function.Consumer> processor; + private final java.util.function.Consumer processor; - SimpleOption( + SingleArgumentOption( List names, - int argumentCount, String description, String parameters, - java.util.function.Consumer> processor) { + java.util.function.Consumer processor) { this.names = names; - this.argumentCount = argumentCount; this.description = description; this.parameters = parameters; this.processor = processor; @@ -658,7 +894,7 @@ private static final class SimpleOption implements Option { @Override public int getArgumentCount() { - return argumentCount; + return 1; } @Override @@ -683,7 +919,9 @@ public String getParameters() { @Override public boolean process(String option, List arguments) { - processor.accept(arguments); + processor.accept(arguments.get(0)); + // returning false just leads to a very generic error message (not even exposing the affected option) so + // rather rely on custom runtime exceptions for validation errors return true; } } diff --git a/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/DocTreePathAwareRuntimeException.java b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/DocTreePathAwareRuntimeException.java new file mode 100644 index 000000000..ea3d9bd9b --- /dev/null +++ b/maven-resolver-tools/src/main/java/org/eclipse/aether/tools/DocTreePathAwareRuntimeException.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.eclipse.aether.tools; + +import com.sun.source.util.DocTreePath; + +public class DocTreePathAwareRuntimeException extends RuntimeException { + + private static final long serialVersionUID = -4295354135012887795L; + private final DocTreePath docTreePath; + + public DocTreePathAwareRuntimeException(DocTreePath docTreePath, String message) { + super(message); + this.docTreePath = docTreePath; + } + + public DocTreePathAwareRuntimeException(DocTreePath docTreePath, String message, Throwable cause) { + super(message, cause); + this.docTreePath = docTreePath; + } + + public DocTreePath getDocTreePath() { + return docTreePath; + } +} diff --git a/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java b/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java index d839ed7ac..56fa38f7b 100644 --- a/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java +++ b/maven-resolver-tools/src/test/java/org/eclipse/aether/tools/ConfigurationCollectorDocletTest.java @@ -18,49 +18,71 @@ */ package org.eclipse.aether.tools; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticListener; import javax.tools.DocumentationTool; import javax.tools.JavaFileObject; import javax.tools.StandardJavaFileManager; import javax.tools.ToolProvider; import java.io.InputStream; -import java.io.Reader; import java.io.StringWriter; +import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Collection; +import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Properties; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class ConfigurationCollectorDocletTest { - /** - * Classpath location of the fixture source declaring configuration keys of type {@link Boolean}, {@link String} - * and a custom enum, using the same Javadoc block tags that the doclet extracts. - */ + /** Classpath location of the fixture source declaring configuration keys of type {@link Boolean}, {@link String} and a custom enum, + * using the same Javadoc block tags that the doclet extracts. */ private static final String FIXTURE = "/org/eclipse/aether/sample/SampleConfigurationKeys.java"; - @Test - void extractsBooleanStringAndEnumConfigurations(@TempDir Path tempDir) throws Exception { - Path sourceDir = Files.createDirectories(tempDir.resolve("org/eclipse/aether/sample")); - Path sourceFile = sourceDir.resolve("SampleConfigurationKeys.java"); - try (InputStream in = ConfigurationCollectorDocletTest.class.getResourceAsStream(FIXTURE)) { - assertNotNull(in, "fixture source not found on classpath: " + FIXTURE); + /** Classpath location of the a fixture with invalid javadoc (missing/invalid elements). */ + private static final String INVALID_FIXTURE = "/org/eclipse/aether/sample/InvalidSampleConfigurationKeys.java"; + + private Path output; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + output = tempDir.resolve("configuration-keys.properties"); + } + + private Path getSourceFile(String resourcePath, Path tempDir) throws Exception { + if (!resourcePath.startsWith("/")) { + throw new IllegalArgumentException("resource path must start with '/': " + resourcePath); + } + Path sourceDir = + Files.createDirectories(tempDir.resolve(resourcePath.substring(1, resourcePath.lastIndexOf('/')))); + Path sourceFile = sourceDir.resolve(resourcePath.substring(resourcePath.lastIndexOf('/') + 1)); + try (InputStream in = ConfigurationCollectorDocletTest.class.getResourceAsStream(resourcePath)) { + assertNotNull(in, "resource path not found on classpath: " + resourcePath); Files.copy(in, sourceFile); } - Path output = tempDir.resolve("configuration-keys.properties"); + return sourceFile; + } - runDoclet(sourceFile, output); + @Test + void extractsBooleanStringAndEnumConfigurations(@TempDir Path tempDir) throws Exception { + StringWriter out = new StringWriter(); + assertTrue( + runDoclet(out, getSourceFile(FIXTURE, tempDir), output), "doclet run should succeed, output:\n" + out); Map> keys = readKeys(output); assertEquals(4, keys.size(), "expected four configuration keys"); @@ -99,43 +121,129 @@ void extractsBooleanStringAndEnumConfigurations(@TempDir Path tempDir) throws Ex assertEquals("No", enum2Key.get("supportRepoIdSuffix")); } - private static void runDoclet(Path sourceFile, Path output) throws Exception { + static final class CapturingDiagnosticsListener + implements javax.tools.DiagnosticListener { + private final javax.tools.Diagnostic.Kind threshold; + private final Collection> diagnostics = new ArrayList<>(); + + public CapturingDiagnosticsListener(javax.tools.Diagnostic.Kind threshold) { + this.threshold = threshold; + } + + @Override + public void report(javax.tools.Diagnostic diagnostic) { + if (diagnostic.getKind().compareTo(threshold) <= 0) { + diagnostics.add(diagnostic); + } + } + + public Collection> getDiagnostics() { + return diagnostics; + } + } + + static final class LoggingDiagnosticsListener + implements javax.tools.DiagnosticListener { + private final Logger logger; + + public LoggingDiagnosticsListener(Logger logger) { + this.logger = logger; + } + + @Override + public void report(javax.tools.Diagnostic diagnostic) { + switch (diagnostic.getKind()) { + case ERROR: + logger.error(diagnostic.getMessage(null)); + break; + case WARNING: + logger.warn(diagnostic.getMessage(null)); + break; + case MANDATORY_WARNING: + logger.warn(diagnostic.getMessage(null)); + break; + case NOTE: + logger.info(diagnostic.getMessage(null)); + break; + case OTHER: + logger.debug(diagnostic.getMessage(null)); + break; + } + } + } + + @Test + void invalidMode() throws Exception { + CapturingDiagnosticsListener listener = + new CapturingDiagnosticsListener<>(javax.tools.Diagnostic.Kind.ERROR); + StringWriter out = new StringWriter(); + assertFalse(runDoclet(out, getSourceFile(FIXTURE, output.getParent()), output, "invalid-mode", listener)); + // check that the diagnostics contain an error message about the invalid mode + Diagnostic diagnostic = + listener.getDiagnostics().iterator().next(); + assertEquals(javax.tools.Diagnostic.Kind.ERROR, diagnostic.getKind()); + // IAE thrown via Doclet.Option#parseOptions, which is caught and reported as a diagnostic + String substring = "java.lang.IllegalArgumentException: Invalid mode: invalid-mode"; + assertTrue( + diagnostic.getMessage(null).contains(substring), + "expected diagnostic message to contain: " + substring + " but was: " + diagnostic.getMessage(null)); + } + + @Test + void invalidTaglets() throws Exception { + CapturingDiagnosticsListener listener = + new CapturingDiagnosticsListener<>(javax.tools.Diagnostic.Kind.ERROR); + StringWriter out = new StringWriter(); + Path sourceFile = getSourceFile(INVALID_FIXTURE, output.getParent()); + assertFalse(runDoclet(out, sourceFile, output, "resolver", listener)); + // check that the diagnostics contain two error messages + Iterator> iterator = + listener.getDiagnostics().iterator(); + Diagnostic diagnostic = iterator.next(); + assertEquals(javax.tools.Diagnostic.Kind.ERROR, diagnostic.getKind()); + assertEquals("Missing block tag @configurationType", diagnostic.getMessage(null)); + assertEquals(sourceFile.toString(), diagnostic.getSource().getName()); + assertEquals(30, diagnostic.getLineNumber()); + assertEquals(2, listener.getDiagnostics().size(), "expected two error diagnostics"); + diagnostic = iterator.next(); + assertEquals(javax.tools.Diagnostic.Kind.ERROR, diagnostic.getKind()); + assertEquals("No valid {@link ...} reference found in @configurationType", diagnostic.getMessage(null)); + assertEquals(sourceFile.toString(), diagnostic.getSource().getName()); + assertEquals(47, diagnostic.getLineNumber()); + } + + private static Boolean runDoclet(Writer writer, Path sourceFile, Path output) throws Exception { + return runDoclet( + writer, + sourceFile, + output, + null, + new LoggingDiagnosticsListener<>( + org.slf4j.LoggerFactory.getLogger(ConfigurationCollectorDocletTest.class))); + } + + private static Boolean runDoclet( + Writer writer, Path sourceFile, Path output, String mode, DiagnosticListener listener) + throws Exception { DocumentationTool documentationTool = ToolProvider.getSystemDocumentationTool(); try (StandardJavaFileManager fileManager = documentationTool.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { Iterable units = fileManager.getJavaFileObjectsFromFiles(List.of(sourceFile.toFile())); - List options = List.of("--output", output.toString(), "-encoding", "UTF-8"); - StringWriter out = new StringWriter(); + final List options; + if (mode != null) { + options = List.of("--output", output.toString(), "--mode", mode, "-encoding", "UTF-8"); + } else { + options = List.of("--output", output.toString(), "-encoding", "UTF-8"); + } DocumentationTool.DocumentationTask task = documentationTool.getTask( - out, fileManager, null, ConfigurationCollectorDoclet.class, options, units); - assertTrue(task.call(), "doclet run should succeed, output:\n" + out); + writer, fileManager, listener, ConfigurationCollectorDoclet.class, options, units); + return task.call(); } } private static Map> readKeys(Path output) throws Exception { - Properties properties = new Properties(); - try (Reader reader = Files.newBufferedReader(output, StandardCharsets.UTF_8)) { - properties.load(reader); - } - int count = Integer.parseInt(properties.getProperty("keys.count", "0")); - List fields = new ArrayList<>(List.of( - "key", - "defaultValue", - "fqName", - "description", - "since", - "configurationSource", - "configurationType", - "supportRepoIdSuffix")); - Map> result = new LinkedHashMap<>(); - for (int i = 0; i < count; i++) { - Map entry = new LinkedHashMap<>(); - for (String field : fields) { - entry.put(field, properties.getProperty("keys." + i + "." + field, "")); - } - result.put(entry.get("key"), entry); - } - return result; + Collection> keys = CollectConfiguration.readDiscoveredKeys(output); + return keys.stream().collect(Collectors.toMap(key -> key.get(CollectConfiguration.KEY), key -> key)); } } diff --git a/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/InvalidSampleConfigurationKeys.java b/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/InvalidSampleConfigurationKeys.java new file mode 100644 index 000000000..df76db4af --- /dev/null +++ b/maven-resolver-tools/src/test/resources/org/eclipse/aether/sample/InvalidSampleConfigurationKeys.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.eclipse.aether.sample; + + +/** + * Sample source declaring configuration keys of type {@link Boolean}, {@link String} and a custom enum, using the same + * Javadoc block tags that {@code ConfigurationCollectorDoclet} extracts. Used as a fixture by the doclet test. + */ +public final class InvalidSampleConfigurationKeys { + + // missing mandatory @configurationType tag, should be reported as an error by the doclet + /** + * A boolean flag. + * + * @since 1.2.3 + * @configurationSource {@link System#getProperty(String,String)} + * @configurationDefaultValue {@link #DEFAULT_BOOL} + * @configurationRepoIdSuffix No + */ + public static final String BOOL_KEY = "sample.bool"; + + public static final boolean DEFAULT_BOOL = true; + + // invalid @configurationType tag value, should be reported as an error by the doclet + // also unknown @configurationSource tag value, should be reported as a warning by the doclet + /** + * A string value. + * + * @configurationSource {@link System#getProperty(String,String)} + * @configurationType invalid + * @configurationDefaultValue {@link #DEFAULT_STRING} + * @configurationRepoIdSuffix Yes + */ + public static final String STRING_KEY = "sample.string"; + + public static final String DEFAULT_STRING = "hello"; + + private InvalidSampleConfigurationKeys() {} +}