diff --git a/README.md b/README.md index 7cf0cdfc..74f4cd5f 100644 --- a/README.md +++ b/README.md @@ -916,3 +916,44 @@ Which generates this: ``` Use `$T` when referencing types in Javadoc to get automatic imports. + +### Markdown in Javadoc + +To output Markdown styled Javadoc (see [JEP 467](https://openjdk.org/jeps/467)), +th method `useMarkdownJavadoc()` of the `JavaFile` builder has to be called: + +```java +TypeSpec emptyClass = TypeSpec.classBuilder("EmptyClass") + .addJavadoc("# Empty Class\n" + + "\n" + + "A representation of nothing: /* empty */\n") + .addJavadoc("\n") + .addJavadoc("This is not to be confused with the [$T] datatype which\n" + + "is an uninstantiable placeholder class to hold a reference\n" + + "to the *Class* object representing the Java keyword `void`.\n", Void.class) + .build(); + +JavaFile javaFile = JavaFile.builder("com.example.empty", emptyClass) + .useMarkdownJavadoc() // sets the Markdown output flag + .build(); + +javaFile.writeTo(System.out); +``` + +which outputs: + +```java +package com.example.empty; + +import java.lang.Void; + +/// # Empty Class +/// +/// A representation of nothing: /* empty */ +/// +/// This is not to be confused with the [Void] datatype which +/// is an uninstantiable placeholder class to hold a reference +/// to the *Class* object representing the Java keyword `void`. +class EmptyClass { +} +``` diff --git a/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java b/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java index cbdcdb32..edf7bc32 100644 --- a/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java +++ b/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java @@ -42,6 +42,13 @@ * honors imports, indentation, and deferred variable names. */ final class CodeWriter { + + private enum CommentType { + LINE, + JAVADOC, + MARKDOWN_JAVADOC + } + /** Sentinel value that indicates that no user-provided package has been set. */ private static final String NO_PACKAGE = new String(); @@ -51,13 +58,13 @@ final class CodeWriter { private final LineWrapper out; private int indentLevel; - private boolean javadoc = false; - private boolean comment = false; + private CommentType comment = null; private String packageName = NO_PACKAGE; private final List typeSpecStack = new ArrayList<>(); private final Set staticImportClassNames; private final Set staticImports; private final Set alwaysQualify; + private final boolean useMarkdownJavadoc; private final Map importedTypes; private final Map importableTypes = new LinkedHashMap<>(); private final Set referencedNames = new LinkedHashSet<>(); @@ -77,7 +84,7 @@ final class CodeWriter { } CodeWriter(Appendable out, String indent, Set staticImports, Set alwaysQualify) { - this(out, indent, Collections.emptyMap(), staticImports, alwaysQualify); + this(out, indent, Collections.emptyMap(), staticImports, alwaysQualify, false); } CodeWriter( @@ -85,12 +92,14 @@ final class CodeWriter { String indent, Map importedTypes, Set staticImports, - Set alwaysQualify) { + Set alwaysQualify, + boolean useMarkdownJavadoc) { this.out = new LineWrapper(out, indent, 100); this.indent = checkNotNull(indent, "indent == null"); this.importedTypes = checkNotNull(importedTypes, "importedTypes == null"); this.staticImports = checkNotNull(staticImports, "staticImports == null"); this.alwaysQualify = checkNotNull(alwaysQualify, "alwaysQualify == null"); + this.useMarkdownJavadoc = useMarkdownJavadoc; this.staticImportClassNames = new LinkedHashSet<>(); for (String signature : staticImports) { staticImportClassNames.add(signature.substring(0, signature.lastIndexOf('.'))); @@ -144,12 +153,12 @@ public CodeWriter popType() { public void emitComment(CodeBlock codeBlock) throws IOException { trailingNewline = true; // Force the '//' prefix for the comment. - comment = true; + comment = CommentType.LINE; try { emit(codeBlock); emit("\n"); } finally { - comment = false; + comment = null; } } @@ -158,14 +167,22 @@ public void emitJavadoc(CodeBlock javadocCodeBlock) throws IOException { return; } - emit("/**\n"); - javadoc = true; + if (useMarkdownJavadoc) { + emit("/// "); + comment = CommentType.MARKDOWN_JAVADOC; + } else { + emit("/**\n"); + comment = CommentType.JAVADOC; + } try { emit(javadocCodeBlock, true); } finally { - javadoc = false; + comment = null; + } + + if (!useMarkdownJavadoc) { + emit(" */\n"); } - emit(" */\n"); } public void emitAnnotations(List annotations, boolean inline) throws IOException { @@ -497,9 +514,14 @@ CodeWriter emitAndIndent(String s) throws IOException { for (String line : LINE_BREAKING_PATTERN.split(s, -1)) { // Emit a newline character. Make sure blank lines in Javadoc & comments look good. if (!first) { - if ((javadoc || comment) && trailingNewline) { + if ((comment != null) && trailingNewline) { emitIndentation(); - out.append(javadoc ? " *" : "//"); + out.append( + switch (comment) { + case LINE -> "//"; + case JAVADOC -> " *"; + case MARKDOWN_JAVADOC -> "///"; + }); } out.append("\n"); trailingNewline = true; @@ -519,10 +541,13 @@ CodeWriter emitAndIndent(String s) throws IOException { // Emit indentation and comment prefix if necessary. if (trailingNewline) { emitIndentation(); - if (javadoc) { - out.append(" * "); - } else if (comment) { - out.append("// "); + if (comment != null) { + out.append( + switch (comment) { + case LINE -> "// "; + case JAVADOC -> " * "; + case MARKDOWN_JAVADOC -> "/// "; + }); } } diff --git a/javapoet/src/main/java/com/palantir/javapoet/JavaFile.java b/javapoet/src/main/java/com/palantir/javapoet/JavaFile.java index 23b59205..ebe9872e 100644 --- a/javapoet/src/main/java/com/palantir/javapoet/JavaFile.java +++ b/javapoet/src/main/java/com/palantir/javapoet/JavaFile.java @@ -69,6 +69,7 @@ public Appendable append(char _c) { private final Set staticImports; private final Set alwaysQualify; private final String indent; + private final boolean useMarkdownJavadoc; private JavaFile(Builder builder) { this.fileComment = builder.fileComment.build(); @@ -77,6 +78,7 @@ private JavaFile(Builder builder) { this.skipJavaLangImports = builder.skipJavaLangImports; this.staticImports = Util.immutableSet(builder.staticImports); this.indent = builder.indent; + this.useMarkdownJavadoc = builder.useMarkdownJavadoc; Set alwaysQualifiedNames = new LinkedHashSet<>(); fillAlwaysQualifiedNames(builder.typeSpec, alwaysQualifiedNames); @@ -105,7 +107,8 @@ public void writeTo(Appendable out) throws IOException { Map suggestedImports = importsCollector.suggestedImports(); // Second pass: write the code, taking advantage of the imports. - CodeWriter codeWriter = new CodeWriter(out, indent, suggestedImports, staticImports, alwaysQualify); + CodeWriter codeWriter = + new CodeWriter(out, indent, suggestedImports, staticImports, alwaysQualify, useMarkdownJavadoc); emit(codeWriter); } @@ -292,6 +295,7 @@ public Builder toBuilder() { builder.fileComment.add(fileComment); builder.skipJavaLangImports = skipJavaLangImports; builder.indent = indent; + builder.useMarkdownJavadoc = useMarkdownJavadoc; return builder; } @@ -301,6 +305,7 @@ public static final class Builder { private final CodeBlock.Builder fileComment = CodeBlock.builder(); private boolean skipJavaLangImports; private String indent = " "; + private boolean useMarkdownJavadoc; private final Set staticImports = new TreeSet<>(); @@ -351,6 +356,11 @@ public Builder indent(String indent) { return this; } + public Builder useMarkdownJavadoc() { + this.useMarkdownJavadoc = true; + return this; + } + public JavaFile build() { return new JavaFile(this); } diff --git a/javapoet/src/test/java/com/palantir/javapoet/CodeWriterTest.java b/javapoet/src/test/java/com/palantir/javapoet/CodeWriterTest.java index 2ff19d7a..9642bd5f 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/CodeWriterTest.java +++ b/javapoet/src/test/java/com/palantir/javapoet/CodeWriterTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.util.Collections; import org.junit.jupiter.api.Test; public class CodeWriterTest { @@ -35,4 +36,17 @@ public void emptyLineInJavaDocDosEndings() throws IOException { */ """); } + + @Test + public void markdownJavadocStyle() throws IOException { + CodeBlock javadocCodeBlock = CodeBlock.of("A\r\n\r\nB\r\n"); + StringBuilder out = new StringBuilder(); + new CodeWriter(out, " ", Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), true) + .emitJavadoc(javadocCodeBlock); + assertThat(out.toString()).isEqualTo(""" + /// A + /// + /// B + """); + } } diff --git a/javapoet/src/test/java/com/palantir/javapoet/MethodSpecTest.java b/javapoet/src/test/java/com/palantir/javapoet/MethodSpecTest.java index dab3347f..0bd4e9bd 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/MethodSpecTest.java +++ b/javapoet/src/test/java/com/palantir/javapoet/MethodSpecTest.java @@ -30,6 +30,7 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Target; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -354,6 +355,28 @@ void getTaco(double money, int count) { """); } + @Test + public void withParameterMarkdownJavaDoc() throws IOException { + MethodSpec methodSpec = MethodSpec.methodBuilder("getTaco") + .addParameter(ParameterSpec.builder(TypeName.DOUBLE, "money") + .addJavadoc("the amount required to buy the taco.\n") + .build()) + .addParameter(ParameterSpec.builder(TypeName.INT, "count") + .addJavadoc("the number of Tacos to buy.\n") + .build()) + .build(); + + StringBuilder out = new StringBuilder(); + methodSpec.emit(TestUtil.codeWriterWithMarkdownJavadoc(out), "Constructor", Collections.emptySet()); + + assertThat(out.toString()).isEqualTo(""" + /// @param money the amount required to buy the taco. + /// @param count the number of Tacos to buy. + void getTaco(double money, int count) { + } + """); + } + @Test public void duplicateExceptionsIgnored() { ClassName ioException = ClassName.get(IOException.class); diff --git a/javapoet/src/test/java/com/palantir/javapoet/TestUtil.java b/javapoet/src/test/java/com/palantir/javapoet/TestUtil.java index 40b82a4d..811dedbb 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/TestUtil.java +++ b/javapoet/src/test/java/com/palantir/javapoet/TestUtil.java @@ -16,6 +16,7 @@ package com.palantir.javapoet; import java.util.Collection; +import java.util.Collections; import javax.lang.model.element.Element; final class TestUtil { @@ -30,4 +31,8 @@ static E findFirst(Collection elements, String name) { } throw new IllegalArgumentException(name + " not found in " + elements); } + + static CodeWriter codeWriterWithMarkdownJavadoc(Appendable out) { + return new CodeWriter(out, " ", Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), true); + } } diff --git a/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java b/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java index ede70d66..64c02e9b 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java +++ b/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java @@ -887,6 +887,29 @@ record Taco(String id) { """); } + @Test + public void recordWithMarkdownJavadoc() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(String.class, "id") + .addJavadoc("Id of the taco.") + .build()) + .build()) + .addJavadoc("A taco class that stores the id of a taco.") + .build(); + + assertThat(toStringWithMarkdownJavadocs(typeSpec)).isEqualTo(""" + package com.palantir.tacos; + + import java.lang.String; + + /// A taco class that stores the id of a taco. + /// @param id Id of the taco. + record Taco(String id) { + } + """); + } + @Test public void recordWithAnnotationOnParam() { TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") @@ -1448,6 +1471,48 @@ void refold(Locale locale) { """); } + @Test + public void markdownJavadoc() { + TypeSpec taco = TypeSpec.classBuilder("Taco") + .addJavadoc("A hard or soft tortilla, loosely folded and filled with whatever\n") + .addJavadoc("[$T random] tex-mex stuff we could find in the pantry\n", Random.class) + .addJavadoc(CodeBlock.of("and some [$T] cheese.\n", String.class)) + .addField(FieldSpec.builder(boolean.class, "soft") + .addJavadoc("`true` for a soft flour tortilla; `false` for a crunchy corn tortilla.\n") + .build()) + .addMethod(MethodSpec.methodBuilder("refold") + .addJavadoc(""" + # Folds the back of this taco to reduce sauce leakage. + + For [$T#KOREAN], the front may also be folded. + """, Locale.class) + .addParameter(Locale.class, "locale") + .build()) + .build(); + + assertThat(toStringWithMarkdownJavadocs(taco)).isEqualTo(""" + package com.palantir.tacos; + + import java.lang.String; + import java.util.Locale; + import java.util.Random; + + /// A hard or soft tortilla, loosely folded and filled with whatever + /// [Random random] tex-mex stuff we could find in the pantry + /// and some [String] cheese. + class Taco { + /// `true` for a soft flour tortilla; `false` for a crunchy corn tortilla. + boolean soft; + + /// # Folds the back of this taco to reduce sauce leakage. + /// + /// For [Locale#KOREAN], the front may also be folded. + void refold(Locale locale) { + } + } + """); + } + @Test public void annotationsInAnnotations() { ClassName beef = ClassName.get(tacosPackage, "Beef"); @@ -1984,6 +2049,13 @@ private String toString(TypeSpec typeSpec) { return JavaFile.builder(tacosPackage, typeSpec).build().toString(); } + private String toStringWithMarkdownJavadocs(TypeSpec typeSpec) { + return JavaFile.builder(tacosPackage, typeSpec) + .useMarkdownJavadoc() + .build() + .toString(); + } + @Test public void multilineStatement() { TypeSpec taco = TypeSpec.classBuilder("Taco") @@ -2831,6 +2903,21 @@ class Taco { """); } + @Test + public void markdownJavadocWithTrailingLineDoesNotAddAnother() { + TypeSpec spec = TypeSpec.classBuilder("Taco") + .addJavadoc("Some doc with a newline\n") + .build(); + + assertThat(toStringWithMarkdownJavadocs(spec)).isEqualTo(""" + package com.palantir.tacos; + + /// Some doc with a newline + class Taco { + } + """); + } + @Test public void javadocEnsuresTrailingLine() { TypeSpec spec = TypeSpec.classBuilder("Taco") @@ -2848,6 +2935,21 @@ class Taco { """); } + @Test + public void markdownJavadocEnsuresTrailingLine() { + TypeSpec spec = TypeSpec.classBuilder("Taco") + .addJavadoc("Some doc with a newline") + .build(); + + assertThat(toStringWithMarkdownJavadocs(spec)).isEqualTo(""" + package com.palantir.tacos; + + /// Some doc with a newline + class Taco { + } + """); + } + @Test public void longImplementsList() { TypeSpec typeSpec = TypeSpec.classBuilder("Taco")