From 0f1507088350b014ebcca26193eb73929378d63d Mon Sep 17 00:00:00 2001 From: Rick Ossendrijver Date: Mon, 13 Feb 2023 09:27:08 +0100 Subject: [PATCH] Introduce `documentation-support` module (#428) This new module provides the initial version of a framework for the extraction of data from bug checkers and Refaster rules, to be used as input for website generation. --- documentation-support/pom.xml | 87 ++++++++++ .../documentation/BugPatternExtractor.java | 103 ++++++++++++ .../documentation/DocumentationGenerator.java | 56 +++++++ .../DocumentationGeneratorTaskListener.java | 91 +++++++++++ .../errorprone/documentation/Extractor.java | 33 ++++ .../documentation/ExtractorType.java | 35 ++++ .../documentation/package-info.java | 7 + .../BugPatternExtractorTest.java | 153 ++++++++++++++++++ .../errorprone/documentation/Compilation.java | 45 ++++++ ...ocumentationGeneratorTaskListenerTest.java | 77 +++++++++ .../DocumentationGeneratorTest.java | 38 +++++ .../bugpattern-documentation-complete.json | 19 +++ .../bugpattern-documentation-minimal.json | 14 ++ ...ocumentation-undocumented-suppression.json | 12 ++ error-prone-contrib/pom.xml | 14 ++ pom.xml | 7 + 16 files changed, 791 insertions(+) create mode 100644 documentation-support/pom.xml create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/BugPatternExtractor.java create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGenerator.java create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListener.java create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/Extractor.java create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/ExtractorType.java create mode 100644 documentation-support/src/main/java/tech/picnic/errorprone/documentation/package-info.java create mode 100644 documentation-support/src/test/java/tech/picnic/errorprone/documentation/BugPatternExtractorTest.java create mode 100644 documentation-support/src/test/java/tech/picnic/errorprone/documentation/Compilation.java create mode 100644 documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListenerTest.java create mode 100644 documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTest.java create mode 100644 documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-complete.json create mode 100644 documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-minimal.json create mode 100644 documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-undocumented-suppression.json diff --git a/documentation-support/pom.xml b/documentation-support/pom.xml new file mode 100644 index 0000000000..27707b22a0 --- /dev/null +++ b/documentation-support/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + tech.picnic.error-prone-support + error-prone-support + 0.8.1-SNAPSHOT + + + documentation-support + + Picnic :: Error Prone Support :: Documentation Support + Data extraction support for the purpose of documentation generation. + + + + ${groupId.error-prone} + error_prone_annotation + + + ${groupId.error-prone} + error_prone_annotations + provided + + + ${groupId.error-prone} + error_prone_check_api + + + ${groupId.error-prone} + error_prone_test_helpers + test + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + com.google.auto + auto-common + + + com.google.auto.service + auto-service-annotations + provided + + + com.google.auto.value + auto-value-annotations + provided + + + com.google.guava + guava + + + org.assertj + assertj-core + test + + + org.jspecify + jspecify + provided + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/BugPatternExtractor.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/BugPatternExtractor.java new file mode 100644 index 0000000000..071b3f91d0 --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/BugPatternExtractor.java @@ -0,0 +1,103 @@ +package tech.picnic.errorprone.documentation; + +import static com.google.common.base.Verify.verify; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Objects.requireNonNull; + +import com.google.auto.common.AnnotationMirrors; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.BugPattern; +import com.google.errorprone.BugPattern.SeverityLevel; +import com.google.errorprone.annotations.Immutable; +import com.google.errorprone.util.ASTHelpers; +import com.sun.source.tree.AnnotationTree; +import com.sun.source.tree.ClassTree; +import com.sun.tools.javac.code.Attribute; +import com.sun.tools.javac.code.Symbol.ClassSymbol; +import com.sun.tools.javac.util.Context; +import javax.lang.model.element.AnnotationValue; +import tech.picnic.errorprone.documentation.BugPatternExtractor.BugPatternDocumentation; + +/** + * An {@link Extractor} that describes how to extract data from a {@code @BugPattern} annotation. + */ +@Immutable +final class BugPatternExtractor implements Extractor { + @Override + public BugPatternDocumentation extract(ClassTree tree, Context context) { + ClassSymbol symbol = ASTHelpers.getSymbol(tree); + BugPattern annotation = symbol.getAnnotation(BugPattern.class); + requireNonNull(annotation, "BugPattern annotation must be present"); + + return new AutoValue_BugPatternExtractor_BugPatternDocumentation( + symbol.getQualifiedName().toString(), + annotation.name().isEmpty() ? tree.getSimpleName().toString() : annotation.name(), + ImmutableList.copyOf(annotation.altNames()), + annotation.link(), + ImmutableList.copyOf(annotation.tags()), + annotation.summary(), + annotation.explanation(), + annotation.severity(), + annotation.disableable(), + annotation.documentSuppression() ? getSuppressionAnnotations(tree) : ImmutableList.of()); + } + + @Override + public boolean canExtract(ClassTree tree) { + return ASTHelpers.hasDirectAnnotationWithSimpleName(tree, BugPattern.class.getSimpleName()); + } + + /** + * Returns the fully-qualified class names of suppression annotations specified by the {@link + * BugPattern} annotation located on the given tree. + * + * @implNote This method cannot simply invoke {@link BugPattern#suppressionAnnotations()}, as that + * will yield an "Attempt to access Class objects for TypeMirrors" exception. + */ + private static ImmutableList getSuppressionAnnotations(ClassTree tree) { + AnnotationTree annotationTree = + ASTHelpers.getAnnotationWithSimpleName( + ASTHelpers.getAnnotations(tree), BugPattern.class.getSimpleName()); + requireNonNull(annotationTree, "BugPattern annotation must be present"); + + Attribute.Array types = + doCast( + AnnotationMirrors.getAnnotationValue( + ASTHelpers.getAnnotationMirror(annotationTree), "suppressionAnnotations"), + Attribute.Array.class); + + return types.getValue().stream() + .map(v -> doCast(v, Attribute.Class.class).classType.toString()) + .collect(toImmutableList()); + } + + @SuppressWarnings("unchecked") + private static T doCast(AnnotationValue value, Class target) { + verify(target.isInstance(value), "Value '%s' is not of type '%s'", value, target); + return (T) value; + } + + @AutoValue + abstract static class BugPatternDocumentation { + abstract String fullyQualifiedName(); + + abstract String name(); + + abstract ImmutableList altNames(); + + abstract String link(); + + abstract ImmutableList tags(); + + abstract String summary(); + + abstract String explanation(); + + abstract SeverityLevel severityLevel(); + + abstract boolean canDisable(); + + abstract ImmutableList suppressionAnnotations(); + } +} diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGenerator.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGenerator.java new file mode 100644 index 0000000000..39169faa2a --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGenerator.java @@ -0,0 +1,56 @@ +package tech.picnic.errorprone.documentation; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.auto.service.AutoService; +import com.google.common.annotations.VisibleForTesting; +import com.sun.source.util.JavacTask; +import com.sun.source.util.Plugin; +import com.sun.tools.javac.api.BasicJavacTask; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A compiler {@link Plugin} that analyzes and extracts relevant information for documentation + * purposes from processed files. + */ +// XXX: Find a better name for this class; it doesn't generate documentation per se. +@AutoService(Plugin.class) +public final class DocumentationGenerator implements Plugin { + @VisibleForTesting static final String OUTPUT_DIRECTORY_FLAG = "-XoutputDirectory"; + private static final Pattern OUTPUT_DIRECTORY_FLAG_PATTERN = + Pattern.compile(Pattern.quote(OUTPUT_DIRECTORY_FLAG) + "=(.*)"); + + /** Instantiates a new {@link DocumentationGenerator} instance. */ + public DocumentationGenerator() {} + + @Override + public String getName() { + return getClass().getSimpleName(); + } + + @Override + public void init(JavacTask javacTask, String... args) { + checkArgument(args.length == 1, "Precisely one path must be provided"); + + javacTask.addTaskListener( + new DocumentationGeneratorTaskListener( + ((BasicJavacTask) javacTask).getContext(), getOutputPath(args[0]))); + } + + @VisibleForTesting + static Path getOutputPath(String pathArg) { + Matcher matcher = OUTPUT_DIRECTORY_FLAG_PATTERN.matcher(pathArg); + checkArgument( + matcher.matches(), "'%s' must be of the form '%s='", pathArg, OUTPUT_DIRECTORY_FLAG); + + String path = matcher.group(1); + try { + return Path.of(path); + } catch (InvalidPathException e) { + throw new IllegalArgumentException(String.format("Invalid path '%s'", path), e); + } + } +} diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListener.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListener.java new file mode 100644 index 0000000000..29fa2203d2 --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListener.java @@ -0,0 +1,91 @@ +package tech.picnic.errorprone.documentation; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.source.tree.ClassTree; +import com.sun.source.util.TaskEvent; +import com.sun.source.util.TaskEvent.Kind; +import com.sun.source.util.TaskListener; +import com.sun.tools.javac.api.JavacTrees; +import com.sun.tools.javac.util.Context; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.tools.JavaFileObject; + +/** + * A {@link TaskListener} that identifies and extracts relevant content for documentation generation + * and writes it to disk. + */ +// XXX: Find a better name for this class; it doesn't generate documentation per se. +final class DocumentationGeneratorTaskListener implements TaskListener { + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().setVisibility(PropertyAccessor.FIELD, Visibility.ANY); + + private final Context context; + private final Path docsPath; + + DocumentationGeneratorTaskListener(Context context, Path path) { + this.context = context; + this.docsPath = path; + } + + @Override + public void started(TaskEvent taskEvent) { + if (taskEvent.getKind() == Kind.ANALYZE) { + createDocsDirectory(); + } + } + + @Override + public void finished(TaskEvent taskEvent) { + if (taskEvent.getKind() != Kind.ANALYZE) { + return; + } + + ClassTree classTree = JavacTrees.instance(context).getTree(taskEvent.getTypeElement()); + JavaFileObject sourceFile = taskEvent.getSourceFile(); + if (classTree == null || sourceFile == null) { + return; + } + + ExtractorType.findMatchingType(classTree) + .ifPresent( + extractorType -> + writeToFile( + extractorType.getIdentifier(), + getSimpleClassName(sourceFile.toUri()), + extractorType.getExtractor().extract(classTree, context))); + } + + private void createDocsDirectory() { + try { + Files.createDirectories(docsPath); + } catch (IOException e) { + throw new IllegalStateException( + String.format("Error while creating directory with path '%s'", docsPath), e); + } + } + + private void writeToFile(String identifier, String className, T data) { + File file = docsPath.resolve(String.format("%s-%s.json", identifier, className)).toFile(); + + try (FileWriter fileWriter = new FileWriter(file, UTF_8)) { + OBJECT_MAPPER.writeValue(fileWriter, data); + } catch (IOException e) { + throw new UncheckedIOException(String.format("Cannot write to file '%s'", file.getPath()), e); + } + } + + private static String getSimpleClassName(URI path) { + return Paths.get(path).getFileName().toString().replace(".java", ""); + } +} diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/Extractor.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/Extractor.java new file mode 100644 index 0000000000..210c84ab94 --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/Extractor.java @@ -0,0 +1,33 @@ +package tech.picnic.errorprone.documentation; + +import com.google.errorprone.annotations.Immutable; +import com.sun.source.tree.ClassTree; +import com.sun.tools.javac.util.Context; + +/** + * Interface implemented by classes that define how to extract data of some type {@link T} from a + * given {@link ClassTree}. + * + * @param The type of data that is extracted. + */ +@Immutable +interface Extractor { + /** + * Extracts and returns an instance of {@link T} using the provided arguments. + * + * @param tree The {@link ClassTree} to analyze and from which to extract instances of {@link T}. + * @param context The {@link Context} in which the current compilation takes place. + * @return A non-null instance of {@link T}. + */ + // XXX: Drop `Context` parameter unless used. + T extract(ClassTree tree, Context context); + + /** + * Tells whether this {@link Extractor} can extract documentation content from the given {@link + * ClassTree}. + * + * @param tree The {@link ClassTree} of interest. + * @return {@code true} iff data extraction is supported. + */ + boolean canExtract(ClassTree tree); +} diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/ExtractorType.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/ExtractorType.java new file mode 100644 index 0000000000..42f5b48abd --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/ExtractorType.java @@ -0,0 +1,35 @@ +package tech.picnic.errorprone.documentation; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.sun.source.tree.ClassTree; +import java.util.EnumSet; +import java.util.Optional; + +/** An enumeration of {@link Extractor} types. */ +enum ExtractorType { + BUG_PATTERN("bugpattern", new BugPatternExtractor()); + + private static final ImmutableSet TYPES = + Sets.immutableEnumSet(EnumSet.allOf(ExtractorType.class)); + + private final String identifier; + private final Extractor extractor; + + ExtractorType(String identifier, Extractor extractor) { + this.identifier = identifier; + this.extractor = extractor; + } + + String getIdentifier() { + return identifier; + } + + Extractor getExtractor() { + return extractor; + } + + static Optional findMatchingType(ClassTree tree) { + return TYPES.stream().filter(type -> type.getExtractor().canExtract(tree)).findFirst(); + } +} diff --git a/documentation-support/src/main/java/tech/picnic/errorprone/documentation/package-info.java b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/package-info.java new file mode 100644 index 0000000000..bdd32c366b --- /dev/null +++ b/documentation-support/src/main/java/tech/picnic/errorprone/documentation/package-info.java @@ -0,0 +1,7 @@ +/** + * A Java compiler plugin that extracts data from compiled classes, in support of the Error Prone + * Support documentation. + */ +@com.google.errorprone.annotations.CheckReturnValue +@org.jspecify.annotations.NullMarked +package tech.picnic.errorprone.documentation; diff --git a/documentation-support/src/test/java/tech/picnic/errorprone/documentation/BugPatternExtractorTest.java b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/BugPatternExtractorTest.java new file mode 100644 index 0000000000..1737f32ac4 --- /dev/null +++ b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/BugPatternExtractorTest.java @@ -0,0 +1,153 @@ +package tech.picnic.errorprone.documentation; + +import static com.google.errorprone.BugPattern.SeverityLevel.ERROR; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.io.Resources; +import com.google.errorprone.BugPattern; +import com.google.errorprone.CompilationTestHelper; +import com.google.errorprone.VisitorState; +import com.google.errorprone.bugpatterns.BugChecker; +import com.google.errorprone.bugpatterns.BugChecker.ClassTreeMatcher; +import com.google.errorprone.matchers.Description; +import com.sun.source.tree.ClassTree; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class BugPatternExtractorTest { + @Test + void noBugPattern(@TempDir Path outputDirectory) { + Compilation.compileWithDocumentationGenerator( + outputDirectory, + "TestCheckerWithoutAnnotation.java", + "import com.google.errorprone.bugpatterns.BugChecker;", + "", + "public final class TestCheckerWithoutAnnotation extends BugChecker {}"); + + assertThat(outputDirectory.toAbsolutePath()).isEmptyDirectory(); + } + + @Test + void minimalBugPattern(@TempDir Path outputDirectory) throws IOException { + Compilation.compileWithDocumentationGenerator( + outputDirectory, + "MinimalBugChecker.java", + "package pkg;", + "", + "import com.google.errorprone.BugPattern;", + "import com.google.errorprone.BugPattern.SeverityLevel;", + "import com.google.errorprone.bugpatterns.BugChecker;", + "", + "@BugPattern(summary = \"MinimalBugChecker summary\", severity = SeverityLevel.ERROR)", + "public final class MinimalBugChecker extends BugChecker {}"); + + verifyFileMatchesResource( + outputDirectory, + "bugpattern-MinimalBugChecker.json", + "bugpattern-documentation-minimal.json"); + } + + @Test + void completeBugPattern(@TempDir Path outputDirectory) throws IOException { + Compilation.compileWithDocumentationGenerator( + outputDirectory, + "CompleteBugChecker.java", + "package pkg;", + "", + "import com.google.errorprone.BugPattern;", + "import com.google.errorprone.BugPattern.SeverityLevel;", + "import com.google.errorprone.bugpatterns.BugChecker;", + "import org.junit.jupiter.api.Test;", + "", + "@BugPattern(", + " name = \"OtherName\",", + " summary = \"CompleteBugChecker summary\",", + " linkType = BugPattern.LinkType.CUSTOM,", + " link = \"https://error-prone.picnic.tech\",", + " explanation = \"Example explanation\",", + " severity = SeverityLevel.SUGGESTION,", + " altNames = \"Check\",", + " tags = BugPattern.StandardTags.SIMPLIFICATION,", + " disableable = false,", + " suppressionAnnotations = {BugPattern.class, Test.class})", + "public final class CompleteBugChecker extends BugChecker {}"); + + verifyFileMatchesResource( + outputDirectory, + "bugpattern-CompleteBugChecker.json", + "bugpattern-documentation-complete.json"); + } + + @Test + void undocumentedSuppressionBugPattern(@TempDir Path outputDirectory) throws IOException { + Compilation.compileWithDocumentationGenerator( + outputDirectory, + "UndocumentedSuppressionBugPattern.java", + "package pkg;", + "", + "import com.google.errorprone.BugPattern;", + "import com.google.errorprone.BugPattern.SeverityLevel;", + "import com.google.errorprone.bugpatterns.BugChecker;", + "", + "@BugPattern(", + " summary = \"UndocumentedSuppressionBugPattern summary\",", + " severity = SeverityLevel.WARNING,", + " documentSuppression = false)", + "public final class UndocumentedSuppressionBugPattern extends BugChecker {}"); + + verifyFileMatchesResource( + outputDirectory, + "bugpattern-UndocumentedSuppressionBugPattern.json", + "bugpattern-documentation-undocumented-suppression.json"); + } + + @Test + void bugPatternAnnotationIsAbsent() { + CompilationTestHelper.newInstance(TestChecker.class, getClass()) + .addSourceLines( + "TestChecker.java", + "import com.google.errorprone.bugpatterns.BugChecker;", + "", + "// BUG: Diagnostic contains: Can extract: false", + "public final class TestChecker extends BugChecker {}") + .doTest(); + } + + private static void verifyFileMatchesResource( + Path outputDirectory, String fileName, String resourceName) throws IOException { + assertThat(Files.readString(outputDirectory.resolve(fileName))) + .isEqualToIgnoringWhitespace(getResource(resourceName)); + } + + // XXX: Once we support only JDK 15+, drop this method in favour of including the resources as + // text blocks in this class. (This also requires renaming the `verifyFileMatchesResource` + // method.) + private static String getResource(String resourceName) throws IOException { + return Resources.toString( + Resources.getResource(BugPatternExtractorTest.class, resourceName), UTF_8); + } + + /** A {@link BugChecker} that validates the {@link BugPatternExtractor}. */ + @BugPattern(summary = "Validates `BugPatternExtractor` extraction", severity = ERROR) + public static final class TestChecker extends BugChecker implements ClassTreeMatcher { + private static final long serialVersionUID = 1L; + + @Override + public Description matchClass(ClassTree tree, VisitorState state) { + BugPatternExtractor extractor = new BugPatternExtractor(); + + assertThatThrownBy(() -> extractor.extract(tree, state.context)) + .isInstanceOf(NullPointerException.class) + .hasMessage("BugPattern annotation must be present"); + + return buildDescription(tree) + .setMessage(String.format("Can extract: %s", extractor.canExtract(tree))) + .build(); + } + } +} diff --git a/documentation-support/src/test/java/tech/picnic/errorprone/documentation/Compilation.java b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/Compilation.java new file mode 100644 index 0000000000..26d5004c58 --- /dev/null +++ b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/Compilation.java @@ -0,0 +1,45 @@ +package tech.picnic.errorprone.documentation; + +import com.google.common.collect.ImmutableList; +import com.google.errorprone.FileManagers; +import com.google.errorprone.FileObjects; +import com.sun.tools.javac.api.JavacTaskImpl; +import com.sun.tools.javac.api.JavacTool; +import com.sun.tools.javac.file.JavacFileManager; +import java.nio.file.Path; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; + +// XXX: Generalize and move this class so that it can also be used by `refaster-compiler`. +// XXX: Add support for this class to the `ErrorProneTestHelperSourceFormat` check. +public final class Compilation { + private Compilation() {} + + public static void compileWithDocumentationGenerator( + Path outputDirectory, String fileName, String... lines) { + compileWithDocumentationGenerator(outputDirectory.toAbsolutePath().toString(), fileName, lines); + } + + public static void compileWithDocumentationGenerator( + String outputDirectory, String fileName, String... lines) { + compile( + ImmutableList.of("-Xplugin:DocumentationGenerator -XoutputDirectory=" + outputDirectory), + FileObjects.forSourceLines(fileName, lines)); + } + + private static void compile(ImmutableList options, JavaFileObject javaFileObject) { + JavacFileManager javacFileManager = FileManagers.testFileManager(); + JavaCompiler compiler = JavacTool.create(); + JavacTaskImpl task = + (JavacTaskImpl) + compiler.getTask( + null, + javacFileManager, + null, + options, + ImmutableList.of(), + ImmutableList.of(javaFileObject)); + + task.call(); + } +} diff --git a/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListenerTest.java b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListenerTest.java new file mode 100644 index 0000000000..48b50ca68e --- /dev/null +++ b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTaskListenerTest.java @@ -0,0 +1,77 @@ +package tech.picnic.errorprone.documentation; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.nio.file.attribute.AclEntryPermission.ADD_SUBDIRECTORY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.condition.OS.WINDOWS; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import java.io.IOException; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclFileAttributeView; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.io.TempDir; + +final class DocumentationGeneratorTaskListenerTest { + @EnabledOnOs(WINDOWS) + @Test + void readOnlyFileSystemWindows(@TempDir Path outputDirectory) throws IOException { + AclFileAttributeView view = + Files.getFileAttributeView(outputDirectory, AclFileAttributeView.class); + view.setAcl( + view.getAcl().stream() + .map( + entry -> + AclEntry.newBuilder(entry) + .setPermissions( + Sets.difference(entry.permissions(), ImmutableSet.of(ADD_SUBDIRECTORY))) + .build()) + .collect(toImmutableList())); + + readOnlyFileSystemFailsToWrite(outputDirectory.resolve("nonexistent")); + } + + @DisabledOnOs(WINDOWS) + @Test + void readOnlyFileSystemNonWindows(@TempDir Path outputDirectory) { + assertThat(outputDirectory.toFile().setWritable(false)) + .describedAs("Failed to make test directory unwritable") + .isTrue(); + + readOnlyFileSystemFailsToWrite(outputDirectory.resolve("nonexistent")); + } + + private static void readOnlyFileSystemFailsToWrite(Path outputDirectory) { + assertThatThrownBy( + () -> + Compilation.compileWithDocumentationGenerator( + outputDirectory, "A.java", "class A {}")) + .hasRootCauseInstanceOf(FileSystemException.class) + .hasCauseInstanceOf(IllegalStateException.class) + .hasMessageEndingWith("Error while creating directory with path '%s'", outputDirectory); + } + + @Test + void noClassNoOutput(@TempDir Path outputDirectory) { + Compilation.compileWithDocumentationGenerator(outputDirectory, "A.java", "package pkg;"); + + assertThat(outputDirectory).isEmptyDirectory(); + } + + @Test + void excessArguments(@TempDir Path outputDirectory) { + assertThatThrownBy( + () -> + Compilation.compileWithDocumentationGenerator( + outputDirectory.toAbsolutePath() + " extra-arg", "A.java", "package pkg;")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Precisely one path must be provided"); + } +} diff --git a/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTest.java b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTest.java new file mode 100644 index 0000000000..2db9f6c3f8 --- /dev/null +++ b/documentation-support/src/test/java/tech/picnic/errorprone/documentation/DocumentationGeneratorTest.java @@ -0,0 +1,38 @@ +package tech.picnic.errorprone.documentation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static tech.picnic.errorprone.documentation.DocumentationGenerator.OUTPUT_DIRECTORY_FLAG; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +final class DocumentationGeneratorTest { + @ParameterizedTest + @ValueSource(strings = {"bar", "foo"}) + void getOutputPath(String path) { + assertThat(DocumentationGenerator.getOutputPath(OUTPUT_DIRECTORY_FLAG + '=' + path)) + .isEqualTo(Path.of(path)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "-XoutputDirectory", "invalidOption=Test", "nothing"}) + void getOutputPathWithInvalidArgument(String pathArg) { + assertThatThrownBy(() -> DocumentationGenerator.getOutputPath(pathArg)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("'%s' must be of the form '%s='", pathArg, OUTPUT_DIRECTORY_FLAG); + } + + @Test + void getOutputPathWithInvalidPath() { + String basePath = "path-with-null-char-\0"; + assertThatThrownBy( + () -> DocumentationGenerator.getOutputPath(OUTPUT_DIRECTORY_FLAG + '=' + basePath)) + .isInstanceOf(IllegalArgumentException.class) + .hasCauseInstanceOf(InvalidPathException.class) + .hasMessageEndingWith("Invalid path '%s'", basePath); + } +} diff --git a/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-complete.json b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-complete.json new file mode 100644 index 0000000000..eb29080c05 --- /dev/null +++ b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-complete.json @@ -0,0 +1,19 @@ +{ + "fullyQualifiedName": "pkg.CompleteBugChecker", + "name": "OtherName", + "altNames": [ + "Check" + ], + "link": "https://error-prone.picnic.tech", + "tags": [ + "Simplification" + ], + "summary": "CompleteBugChecker summary", + "explanation": "Example explanation", + "severityLevel": "SUGGESTION", + "canDisable": false, + "suppressionAnnotations": [ + "com.google.errorprone.BugPattern", + "org.junit.jupiter.api.Test" + ] +} diff --git a/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-minimal.json b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-minimal.json new file mode 100644 index 0000000000..8e64bdc887 --- /dev/null +++ b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-minimal.json @@ -0,0 +1,14 @@ +{ + "fullyQualifiedName": "pkg.MinimalBugChecker", + "name": "MinimalBugChecker", + "altNames": [], + "link": "", + "tags": [], + "summary": "MinimalBugChecker summary", + "explanation": "", + "severityLevel": "ERROR", + "canDisable": true, + "suppressionAnnotations": [ + "java.lang.SuppressWarnings" + ] +} diff --git a/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-undocumented-suppression.json b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-undocumented-suppression.json new file mode 100644 index 0000000000..b738dfd988 --- /dev/null +++ b/documentation-support/src/test/resources/tech/picnic/errorprone/documentation/bugpattern-documentation-undocumented-suppression.json @@ -0,0 +1,12 @@ +{ + "fullyQualifiedName": "pkg.UndocumentedSuppressionBugPattern", + "name": "UndocumentedSuppressionBugPattern", + "altNames": [], + "link": "", + "tags": [], + "summary": "UndocumentedSuppressionBugPattern summary", + "explanation": "", + "severityLevel": "WARNING", + "canDisable": true, + "suppressionAnnotations": [] +} diff --git a/error-prone-contrib/pom.xml b/error-prone-contrib/pom.xml index 7734145c08..b16612d0e5 100644 --- a/error-prone-contrib/pom.xml +++ b/error-prone-contrib/pom.xml @@ -39,6 +39,14 @@ error_prone_test_helpers test + + ${project.groupId} + documentation-support + + provided + ${project.groupId} refaster-support @@ -213,6 +221,11 @@ maven-compiler-plugin + + ${project.groupId} + documentation-support + ${project.version} + ${project.groupId} refaster-compiler @@ -226,6 +239,7 @@ -Xplugin:RefasterRuleCompiler + -Xplugin:DocumentationGenerator -XoutputDirectory=${project.build.directory}/docs diff --git a/pom.xml b/pom.xml index eef8de76ce..6fe01cb382 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ + documentation-support error-prone-contrib refaster-compiler refaster-runner @@ -188,6 +189,11 @@ error_prone_test_helpers ${version.error-prone} + + ${project.groupId} + documentation-support + ${project.version} + ${project.groupId} refaster-compiler @@ -856,6 +862,7 @@ --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED