diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java index be9a9d45..9c68e13a 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java @@ -20,7 +20,6 @@ import static org.springframework.modulith.docs.Asciidoctor.*; import java.io.File; import java.io.FileWriter; import java.io.IOException; -import java.io.Writer; import java.lang.annotation.Annotation; import java.nio.file.Files; import java.nio.file.Path; @@ -71,6 +70,7 @@ import com.tngtech.archunit.core.domain.JavaClass; * * @author Oliver Drotbohm * @author Cora Iberkleid + * @author Tobias Haindl */ public class Documenter { @@ -91,6 +91,8 @@ public class Documenter { private final ConfigurationProperties properties; private final Options options; + private boolean cleared; + private Map components; /** @@ -113,9 +115,17 @@ public class Documenter { this(modules, Options.defaults()); } + /** + * Creates a new {@link Documenter} for the given {@link ApplicationModules} and output folder. + * + * @param modules must not be {@literal null}. + * @param outputFolder must not be {@literal null} or empty. + * @deprecated use {@link Documenter(ApplicationModules, Options)} instead. + */ + @Deprecated(forRemoval = true) public Documenter(ApplicationModules modules, String outputFolder) { - this(modules, new Options(outputFolder, true)); + this(modules, Options.defaults().withOutputFolder(outputFolder)); Assert.hasText(outputFolder, "Output folder must not be null or empty!"); } @@ -125,6 +135,7 @@ public class Documenter { * * @param modules must not be {@literal null}. * @param options must not be {@literal null}. + * @since 1.2 */ public Documenter(ApplicationModules modules, Options options) { @@ -146,10 +157,12 @@ public class Documenter { this.container = system.addContainer(systemName, "", ""); this.properties = new ConfigurationProperties(); + this.cleared = false; } /** - * Customize the output folder to write the generated files to. Defaults to {@value #DEFAULT_LOCATION}. + * Customize the output folder to write the generated files to. Defaults to {@code spring-modulith-docs} in your build + * systems build folder. * * @param outputFolder must not be {@literal null} or empty. * @return will never be {@literal null}. @@ -189,14 +202,12 @@ public class Documenter { */ public Documenter writeDocumentation(DiagramOptions diagramOptions, CanvasOptions canvasOptions) { - if (this.options.clean) { - clearOutputFolder(); - } + potentiallyWipeOutputFolder(); - return writeModulesAsPlantUml(options) - .writeIndividualModulesAsPlantUml(options) + return writeModulesAsPlantUml(diagramOptions) + .writeIndividualModulesAsPlantUml(diagramOptions) .writeModuleCanvases(canvasOptions) - .writeAggregatingDocument(options, canvasOptions); + .writeAggregatingDocument(diagramOptions, canvasOptions); } /** @@ -211,26 +222,29 @@ public class Documenter { } /** - * Writes aggregating document called 'all-docs.adoc' that includes any existing component diagrams and canvases. + * Writes aggregating document called {@code all-docs.adoc} that includes any existing component diagrams and + * canvases. * - * @param options must not be {@literal null}. + * @param diagramOptions must not be {@literal null}. * @param canvasOptions must not be {@literal null}. * @return the current instance, will never be {@literal null}. * @since 1.2.2 */ - public Documenter writeAggregatingDocument(DiagramOptions options, CanvasOptions canvasOptions) { + public Documenter writeAggregatingDocument(DiagramOptions diagramOptions, CanvasOptions canvasOptions) { - Assert.notNull(options, "DiagramOptions must not be null!"); + Assert.notNull(diagramOptions, "DiagramOptions must not be null!"); Assert.notNull(canvasOptions, "CanvasOptions must not be null!"); + potentiallyWipeOutputFolder(); + var asciidoctor = Asciidoctor.withJavadocBase(modules, canvasOptions.getApiBase()); - var outputFolder = new OutputFolder(this.outputFolder); // Get file name for module overview diagram - var componentsFilename = options.getTargetFileName().orElse(DEFAULT_COMPONENTS_FILE); + var componentsFilename = diagramOptions.getTargetFileName().orElse(DEFAULT_COMPONENTS_FILE); var componentsDoc = new StringBuilder(); + var folder = options.outputFolder; - if (outputFolder.contains(componentsFilename)) { + if (folder.contains(componentsFilename)) { componentsDoc .append(asciidoctor.renderHeadline(2, getDefaultedSystemName())) @@ -242,13 +256,13 @@ public class Documenter { var moduleDocs = modules.stream().map(it -> { // Get diagram file name, e.g. module-inventory.puml - var fileNamePattern = options.getTargetFileName().orElse(DEFAULT_MODULE_COMPONENTS_FILE); + var fileNamePattern = diagramOptions.getTargetFileName().orElse(DEFAULT_MODULE_COMPONENTS_FILE); var filename = fileNamePattern.formatted(it.getName()); var canvasFilename = canvasOptions.getTargetFileName(it.getName()); var content = new StringBuilder(); - content.append(outputFolder.contains(filename) ? asciidoctor.renderPlantUmlInclude(filename) : "") - .append(outputFolder.contains(canvasFilename) ? asciidoctor.renderGeneralInclude(canvasFilename) : ""); + content.append(folder.contains(filename) ? asciidoctor.renderPlantUmlInclude(filename) : "") + .append(folder.contains(canvasFilename) ? asciidoctor.renderGeneralInclude(canvasFilename) : ""); if (!content.isEmpty()) { @@ -265,14 +279,7 @@ public class Documenter { // Write file to all-docs.adoc if (!allDocs.isBlank()) { - - var file = recreateFile("all-docs.adoc"); - - try (Writer writer = new FileWriter(file.toFile())) { - writer.write(allDocs); - } catch (IOException o_O) { - throw new RuntimeException(o_O); - } + options.outputFolder.writeToFile("all-docs.adoc", allDocs); } return this; @@ -290,20 +297,17 @@ public class Documenter { /** * Writes the PlantUML component diagram for all {@link ApplicationModules} with the given {@link DiagramOptions}. * - * @param options must not be {@literal null}. + * @param diagramOptions must not be {@literal null}. * @return the current instance, will never be {@literal null}. */ - public Documenter writeModulesAsPlantUml(DiagramOptions options) { + public Documenter writeModulesAsPlantUml(DiagramOptions diagramOptions) { - Assert.notNull(options, "Options must not be null!"); + Assert.notNull(diagramOptions, "Options must not be null!"); - Path file = recreateFile(options.getTargetFileName().orElse(DEFAULT_COMPONENTS_FILE)); + potentiallyWipeOutputFolder(); - try (Writer writer = new FileWriter(file.toFile())) { - writer.write(createPlantUml(options)); - } catch (IOException o_O) { - throw new RuntimeException(o_O); - } + options.outputFolder.writeToFile(diagramOptions.getTargetFileName().orElse(DEFAULT_COMPONENTS_FILE), + createPlantUml(diagramOptions)); return this; } @@ -322,6 +326,8 @@ public class Documenter { Assert.notNull(options, "DiagramOptions must not be null!"); + potentiallyWipeOutputFolder(); + modules.forEach(it -> writeModuleAsPlantUml(it, options)); return this; @@ -353,6 +359,8 @@ public class Documenter { Assert.notNull(module, "Module must not be null!"); Assert.notNull(options, "Options must not be null!"); + potentiallyWipeOutputFolder(); + var view = createComponentView(options, module); view.setTitle(options.defaultDisplayName.apply(module)); @@ -375,25 +383,20 @@ public class Documenter { /** * Writes all module canvases using the given {@link DiagramOptions}. * - * @param options must not be {@literal null}. + * @param canvasOptions must not be {@literal null}. * @return the current instance, will never be {@literal null}. */ - public Documenter writeModuleCanvases(CanvasOptions options) { + public Documenter writeModuleCanvases(CanvasOptions canvasOptions) { - Assert.notNull(options, "CanvasOptions must not be null!"); + Assert.notNull(canvasOptions, "CanvasOptions must not be null!"); + + potentiallyWipeOutputFolder(); modules.forEach(module -> { - var filename = options.getTargetFileName(module.getName()); - var file = recreateFile(filename); + var filename = canvasOptions.getTargetFileName(module.getName()); - try (FileWriter writer = new FileWriter(file.toFile())) { - - writer.write(toModuleCanvas(module, options)); - - } catch (IOException o_O) { - throw new RuntimeException(o_O); - } + options.outputFolder.writeToFile(filename, toModuleCanvas(module, canvasOptions)); }); return this; @@ -552,19 +555,11 @@ public class Documenter { .findFirst().ifPresent(view::remove); } - private Documenter writeViewAsPlantUml(ComponentView view, String filename, DiagramOptions options) { + private Documenter writeViewAsPlantUml(ComponentView view, String filename, DiagramOptions diagramOptions) { - Path file = recreateFile(filename); + options.outputFolder.writeToFile(filename, render(view, diagramOptions)); - try (Writer writer = new FileWriter(file.toFile())) { - - writer.write(render(view, options)); - - return this; - - } catch (IOException o_O) { - throw new RuntimeException(o_O); - } + return this; } private String render(ComponentView view, DiagramOptions options) { @@ -610,34 +605,13 @@ public class Documenter { .createComponentView(container, prefix + options.toString(), ""); } - private void clearOutputFolder() { + private void potentiallyWipeOutputFolder() { - Path outputPath = Paths.get(options.outputFolder); - if (!outputPath.toFile().exists()) { - return; - } + if (options.clean && !cleared) { - try (Stream paths = Files.walk(outputPath)) { - paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); - } catch (IOException o_O) { - throw new RuntimeException(o_O); - } - } + options.outputFolder.deleteIfExists(); - private Path recreateFile(String name) { - - try { - - var outputFolder = options.outputFolder; - - Files.createDirectories(Paths.get(outputFolder)); - Path filePath = Paths.get(outputFolder, name); - Files.deleteIfExists(filePath); - - return Files.createFile(filePath); - - } catch (IOException o_O) { - throw new RuntimeException(o_O); + this.cleared = true; } } @@ -678,15 +652,6 @@ public class Documenter { return options.hideEmptyLines && types.isEmpty() ? "" : writeTableRow(header, mapper.apply(types)); } - /** - * Returns the default output directory based on the detected build system. - * - * @return will never be {@literal null}. - */ - private static String getDefaultOutputDirectory() { - return (new File("pom.xml").exists() ? "target" : "build").concat("/").concat(DEFAULT_LOCATION); - } - private String getDefaultedSystemName() { return modules.getSystemName().orElse("Modules"); } @@ -1277,39 +1242,35 @@ public class Documenter { public static class Options { - private static final String DEFAULT_LOCATION = (new File("pom.xml").exists() ? "target" : "build") - .concat("/spring-modulith-docs"); - - private final String outputFolder; - + private final OutputFolder outputFolder; private final boolean clean; /** * @param outputFolder the folder to write the files to, can be {@literal null}. * @param clean whether to clean the target directory on rendering. */ - private Options(@Nullable String outputFolder, boolean clean) { + private Options(OutputFolder outputFolder, boolean clean) { - this.outputFolder = outputFolder == null ? DEFAULT_LOCATION : outputFolder; + this.outputFolder = outputFolder; this.clean = clean; } /** - * Creates a default {@link Options} instance configuring a default output folder based on the detected build tool (see {@link Options#DEFAULT_LOCATION}). - * Use {@link #withOutputFolder(String)} if you want to customize the output folder. - * Per default the output folder is wiped before any files are written to it. - * Use {@link #withoutClean()} to disable cleaning of the output folder. + * Creates a default {@link Options} instance configuring a default output folder based on the detected build tool + * (see {@link OutputFolder#DEFAULT_LOCATION}). Use {@link #withOutputFolder(String)} if you want to customize the + * output folder. By default, the output folder is wiped before any files are written to it. Use + * {@link #withoutClean()} to disable cleaning of the output folder. * * @return will never be {@literal null}. * @see #withoutClean() * @see #withOutputFolder(String) */ public static Options defaults() { - return new Options(DEFAULT_LOCATION, true); + return new Options(OutputFolder.forDefaultLocation(), true); } /** - * Disables the cleaning of the output folder before any file is written. + * Disables the cleaning of the output folder before any files are written. * * @return will never be {@literal null}. */ @@ -1318,15 +1279,111 @@ public class Documenter { } /** - * Configures the output folder for the created files. - * The given directory is wiped before any files are written to it. + * Configures the output folder for the created files. The given directory is wiped before any files are written to + * it. * - * @param folder if null the default location based on the detected build tool will be used (see {@link Options#DEFAULT_LOCATION}). - * The given folder will be created if it does not exist already. Existing folders are supported as well. + * @param folder if null the default location based on the detected build tool will be used (see + * {@link OutputFolder#DEFAULT_LOCATION}). The given folder will be created if it does not exist already. + * Existing folders are supported as well. * @return will never be {@literal null}. */ - public Options withOutputFolder(String folder) { - return new Options(folder, clean); + public Options withOutputFolder(@Nullable String folder) { + return new Options(OutputFolder.forLocation(folder), clean); + } + + OutputFolder getOutputFolder() { + return outputFolder; + } + } + + static class OutputFolder { + + private static final String DEFAULT_LOCATION = (new File("pom.xml").exists() ? "target" : "build") + .concat("/spring-modulith-docs"); + + private final String path; + + private OutputFolder(String path) { + + this.path = path; + + try { + Files.createDirectories(Path.of(path)); + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + static OutputFolder forLocation(@Nullable String location) { + return new OutputFolder(location == null ? DEFAULT_LOCATION : location); + } + + static OutputFolder forDefaultLocation() { + return new OutputFolder(DEFAULT_LOCATION); + } + + boolean contains(String filename) { + return Files.exists(Paths.get(path, filename)); + } + + OutputFolder deleteIfExists() { + + var path = Path.of(this.path); + + if (!Files.exists(path)) { + return this; + } + + try { + + Files.walk(path) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + + return this; + } + + void writeToFile(String name, String content) { + + var path = deleteIfExists(name).createFile(name); + + try (FileWriter writer = new FileWriter(path.toFile())) { + + writer.write(content); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + private OutputFolder deleteIfExists(String name) { + + try { + Files.deleteIfExists(Path.of(path, name)); + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + + return this; + } + + private Path createFile(String name) { + + var path = Path.of(this.path); + + try { + + Files.createDirectories(path); + return Files.createFile(path.resolve(name)); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } } } @@ -1343,17 +1400,4 @@ public class Documenter { @Override protected void endContainerBoundary(ModelView view, IndentingWriter writer) {}; }; - - private static class OutputFolder { - - private final String path; - - OutputFolder(String path) { - this.path = path; - } - - boolean contains(String filename) { - return Files.exists(Paths.get(path, filename)); - } - } } diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java index d3c1deb9..3ba63c0c 100644 --- a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java @@ -21,9 +21,9 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Comparator; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -33,7 +33,6 @@ import org.springframework.modulith.core.ApplicationModules; import org.springframework.modulith.core.DependencyType; import org.springframework.modulith.docs.Documenter.DiagramOptions; import org.springframework.modulith.docs.Documenter.Options; -import org.springframework.util.function.ThrowingConsumer; import com.acme.myproject.Application; @@ -42,6 +41,7 @@ import com.acme.myproject.Application; * * @author Oliver Drotbohm * @author Cora Iberkleid + * @author Tobias Haindl */ class DocumenterTest { @@ -79,12 +79,9 @@ class DocumenterTest { @Test void customizesOutputLocation() throws Exception { - String customOutputFolder = "build/spring-modulith"; - Path path = Paths.get(customOutputFolder); + doWith("target/custom-spring-modulith", (path, documenter) -> { - doWith(path, it -> { - - new Documenter(ApplicationModules.of(Application.class), customOutputFolder).writeModuleCanvases(); + documenter.writeModuleCanvases(); assertThat(Files.list(path)).isNotEmpty(); assertThat(path).exists(); @@ -94,22 +91,20 @@ class DocumenterTest { @Test // GH-638 void createsAggregatingDocumentOnlyIfPartialsExist() throws Exception { - var customOutputFolder = "build/spring-modulith"; - var path = Paths.get(customOutputFolder); - var documenter = new Documenter(ApplicationModules.of(Application.class), customOutputFolder); - - doWith(path, it -> { + doWith("build/spring-modulith", (path, documenter) -> { // all-docs.adoc should be created documenter.writeDocumentation(); + var numberOfModules = documenter.getModules().stream().count(); + // 2 per module (PlantUML + Canvas) + component overview + aggregating doc - var expectedFiles = documenter.getModules().stream().count() * 2 + 2; + var expectedFiles = numberOfModules * 2 + 2; // 3 per module (headline + PlantUML + Canvas) + component headline + component PlantUML - var expectedLines = documenter.getModules().stream().count() * 3 + 2; + var expectedLines = numberOfModules * 3 + 2; - assertThat(Files.walk(it).filter(Files::isRegularFile).count()) + assertThat(Files.walk(path).filter(Files::isRegularFile).count()) .isEqualTo(expectedFiles); assertThat(path.resolve("all-docs.adoc")).exists().satisfies(doc -> { @@ -123,15 +118,11 @@ class DocumenterTest { @Test // GH-638 void doesNotCreateAggregatingDocumentIfNoPartialsExist() throws Exception { - var customOutputFolder = "build/spring-modulith"; - var path = Paths.get(customOutputFolder); + doWith("build/spring-modulith", (path, documenter) -> { - doWith(path, it -> { + documenter.writeDocumentation(); - var documenter = new Documenter(ApplicationModules.of(Application.class), customOutputFolder) - .writeDocumentation(); - - deleteDirectoryContents(it); + deleteDirectoryContents(path); documenter.writeAggregatingDocument(); @@ -141,32 +132,37 @@ class DocumenterTest { }); } - @Test - void shouldCleanOutputLocation(@TempDir Path outputDirectory) throws IOException { + @Test // GH-644 + void cleansOutputDirectoryByDefault(@TempDir Path outputDirectory) { - var filePath = createTestFile(outputDirectory); - var nestedFiledPath = createTestFileInSubdirectory(outputDirectory); + doWith(outputDirectory.toString(), (path, documenter) -> { - new Documenter(ApplicationModules.of(Application.class), outputDirectory.toString()).writeDocumentation(); + var filePath = createTestFile(path); + var nestedFiledPath = createTestFileInSubdirectory(path); + + documenter.writeDocumentation(); + + assertThat(filePath).doesNotExist(); + assertThat(nestedFiledPath).doesNotExist(); + assertThat(Files.list(path)).isNotEmpty(); + }); - assertThat(filePath).doesNotExist(); - assertThat(nestedFiledPath).doesNotExist(); - assertThat(Files.list(outputDirectory)).isNotEmpty(); } - @Test - void shouldNotCleanOutputLocation(@TempDir Path outputDirectory) throws IOException { - - var filePath = createTestFile(outputDirectory); - var nestedFiledPath = createTestFileInSubdirectory(outputDirectory); + @Test // GH-644 + void doesNotCleanOutputDirectoryIfConfigured(@TempDir Path outputDirectory) throws IOException { - new Documenter(ApplicationModules.of(Application.class), - Options.defaults().withOutputFolder(outputDirectory.toString()).withoutClean()) - .writeDocumentation(); + doWith(outputDirectory.toString(), it -> it.withoutClean(), (path, documenter) -> { - assertThat(filePath).exists(); - assertThat(nestedFiledPath).exists(); - assertThat(Files.list(outputDirectory)).isNotEmpty(); + var filePath = createTestFile(path); + var nestedFiledPath = createTestFileInSubdirectory(path); + + documenter.writeDocumentation(); + + assertThat(filePath).exists(); + assertThat(nestedFiledPath).exists(); + assertThat(Files.list(path)).isNotEmpty(); + }); } private static Path createTestFile(Path tempDir) throws IOException { @@ -193,18 +189,26 @@ class DocumenterTest { } } - private static void deleteDirectory(Path path) throws IOException { - - deleteDirectoryContents(path); - Files.deleteIfExists(path); + private static void doWith(String path, ThrowingBiConsumer consumer) { + doWith(path, Function.identity(), consumer); } - private static void doWith(Path path, ThrowingConsumer consumer) throws Exception { + private static void doWith(String path, Function customizer, + ThrowingBiConsumer consumer) { + + var options = customizer.apply(Options.defaults().withOutputFolder(path)); + var modules = ApplicationModules.of(Application.class); try { - consumer.accept(path); + consumer.accept(Path.of(path), new Documenter(modules, options)); + } catch (Exception o_O) { + throw new RuntimeException(o_O); } finally { - deleteDirectory(path); + options.getOutputFolder().deleteIfExists(); } } + + private interface ThrowingBiConsumer { + void accept(T t, S s) throws Exception; + } }