From 9ed0558745e37116a43b83323d3e69eccfbd8739 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 01:37:49 +0200 Subject: [PATCH 01/25] First implementation of custom formatting --- .../builders/processor/BuilderProcessor.java | 6 +- .../roaster/RoasterCodeGenerator.java | 119 ++++++++- .../processing/CompilerArgumentsEnum.java | 7 + .../RoasterCodeGeneratorResilienceTest.java | 3 +- .../processor/SkipFormattingTest.java | 248 ++++++++++++++++++ 5 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 90fea6b4..66201669 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -92,8 +92,12 @@ public synchronized void init(ProcessingEnvironment processingEnv) { logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); this.context = new ProcessingContext(logger, globalConfig, processingEnv); + boolean skipFormatting = + new CompilerArgumentsReader(processingEnv) + .readBooleanValue(CompilerArgumentsEnum.SKIP_FORMATTING); this.codeGenerator = - new RoasterCodeGenerator(processingEnv, logger, context.getPerformanceTracker()); + new RoasterCodeGenerator( + processingEnv, logger, context.getPerformanceTracker(), skipFormatting); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger); // Initialize GeneratorRegistry once during processor initialization diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index 95807c23..a0b0ac64 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -88,6 +88,8 @@ public class RoasterCodeGenerator { private final Properties formatterProperties; + private final boolean skipFormatting; + /** * Constructor for RoasterCodeGenerator. * @@ -95,10 +97,14 @@ public class RoasterCodeGenerator { * @param logger Logger for debug output */ public RoasterCodeGenerator( - ProcessingEnvironment processingEnv, ProcessingLogger logger, PerformanceTracker tracker) { + ProcessingEnvironment processingEnv, + ProcessingLogger logger, + PerformanceTracker tracker, + boolean skipFormatting) { this.processingEnv = processingEnv; this.logger = logger; this.performanceTracker = tracker; + this.skipFormatting = skipFormatting; this.formatterProperties = loadFormatterProperties(); } @@ -556,8 +562,8 @@ private void addParameter( } private String formatSource(String rawSource) { - if (formatterProperties.isEmpty()) { - return rawSource; + if (skipFormatting || formatterProperties.isEmpty()) { + return lightweightFormat(rawSource); } try { return Roaster.format(formatterProperties, rawSource); @@ -569,6 +575,113 @@ private String formatSource(String rawSource) { } } + /** + * Lightweight post-processing of Roaster's unformatted output. + * + *

Applies minimal cosmetic fixes that are much cheaper than the full Eclipse JDT formatter: + * + *

+ */ + private String lightweightFormat(String source) { + // Use a list so we can insert new lines when splitting concatenated code + List lines = new java.util.ArrayList<>(java.util.Arrays.asList(source.split("\n", -1))); + boolean inJavadoc = false; + int javadocIndent = 0; + + for (int i = 0; i < lines.size(); i++) { + // 1. Convert leading tabs to 2-space indentation + lines.set(i, convertTabsToSpaces(lines.get(i))); + String converted = lines.get(i); + String convertedStripped = converted.strip(); + + // 2. Handle import/code concatenated with /** (e.g. "import ...;/**") + if (!inJavadoc) { + int jdStart = converted.indexOf("/**"); + if (jdStart >= 0) { + String afterOpen = converted.substring(jdStart + 3); + if (!afterOpen.contains("*/")) { + String before = converted.substring(0, jdStart).stripTrailing(); + String indent = getLeadingIndent(converted); + if (!before.isEmpty()) { + // Split: code stays on this line, /** goes on next line + lines.set(i, before); + lines.add(i + 1, indent + "/**"); + converted = indent + "/**"; + convertedStripped = "/**"; + } + inJavadoc = true; + javadocIndent = getLeadingIndent(converted).length(); + continue; + } + } + } + + // 3. Javadoc asterisk and indentation fixup + if (inJavadoc && !convertedStripped.startsWith("/**")) { + if (convertedStripped.endsWith("*/")) { + // Closing line + if (!convertedStripped.equals("*/") && !convertedStripped.startsWith("*")) { + String content = converted.substring(0, converted.indexOf("*/")).strip(); + lines.set(i, " ".repeat(javadocIndent) + " * " + content + " */"); + } + inJavadoc = false; + } else if (!convertedStripped.startsWith("*") && !convertedStripped.isBlank()) { + // Body line missing asterisk — add " * " prefix with proper indentation + lines.set(i, " ".repeat(javadocIndent) + " * " + convertedStripped); + } + } + } + + // Second pass: collapse consecutive blank lines to one + List result = new java.util.ArrayList<>(); + boolean prevBlank = false; + for (String line : lines) { + boolean isBlank = line.isBlank(); + if (isBlank && prevBlank) { + continue; + } + result.add(line); + prevBlank = isBlank; + } + + return String.join("\n", result); + } + + /** Convert leading tab characters to 2 spaces per tab. */ + private String convertTabsToSpaces(String line) { + if (!line.contains("\t")) { + return line; + } + StringBuilder sb = new StringBuilder(line.length()); + for (int j = 0; j < line.length(); j++) { + char c = line.charAt(j); + if (c == '\t') { + sb.append(" "); + } else if (c == ' ') { + sb.append(' '); + } else { + sb.append(line, j, line.length()); + break; + } + } + return sb.toString(); + } + + /** Extract leading whitespace (spaces and tabs) from a line. */ + private String getLeadingIndent(String line) { + int end = 0; + while (end < line.length() && (line.charAt(end) == ' ' || line.charAt(end) == '\t')) { + end++; + } + return line.substring(0, end); + } + private Properties loadFormatterProperties() { try (InputStream inputStream = RoasterCodeGenerator.class diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java index 9f45f6f4..258eae56 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java @@ -136,6 +136,13 @@ public enum CompilerArgumentsEnum { /** Option for verbose logging output. */ VERBOSE("verbose"), + // === Performance === + /** + * Option to skip Eclipse code formatting for faster generation. Output is still valid Java but + * not style-formatted. + */ + SKIP_FORMATTING("skipFormatting"), + // === Performance Tracking === /** Option for performance tracking during annotation processing. */ PERFORMANCE_TRACKING("performanceTracking"), diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java index cd9caa34..0919f0b9 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java @@ -87,7 +87,8 @@ void shouldWrapRenderingRuntimeExceptionInBuilderException() { ProcessingEnvironment env = new NoopProcessingEnvironment(); RoasterCodeGenerator generator = - new RoasterCodeGenerator(env, new ProcessingLogger(env), new NoOpPerformanceTracker()); + new RoasterCodeGenerator( + env, new ProcessingLogger(env), new NoOpPerformanceTracker(), false); BuilderException thrown = assertThrows(BuilderException.class, () -> generator.generateClass(classDef)); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java new file mode 100644 index 00000000..9ed9c81f --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java @@ -0,0 +1,248 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; + +import com.google.testing.compile.Compilation; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +/** + * Tests for the {@code skipFormatting} compiler option. + * + *

Verifies that when {@code -Asimplebuilder.skipFormatting=true} is set: + * + *

    + *
  • The generated code compiles successfully + *
  • Tab indentation is converted to 2-space indentation + *
  • Javadoc body lines have proper {@code " * "} prefixes + *
  • Import statements are not concatenated with javadoc {@code /**} + *
  • Consecutive blank lines are collapsed to one + *
  • The generated code structure matches the expected lightweight-formatted output + *
+ */ +class SkipFormattingTest { + + @Test + void skipFormatting_producesValidCodeWithLightweightFormatting() { + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class FormatTestDto { + private String name; + private int count; + + public FormatTestDto(String name, int count) { + this.name = name; + this.count = count; + } + + public String getName() { + return name; + } + + public int getCount() { + return count; + } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions("-Asimplebuilder.skipFormatting=true") + .compile(sourceFile); + + assertThat(compilation).succeeded(); + + String generatedCode = loadGeneratedSource(compilation, "FormatTestDtoBuilder"); + + // Normalize trailing whitespace per line for text block comparison (lightweight formatter + // may leave trailing spaces on blank javadoc lines, but text blocks strip them) + generatedCode = + java.util.Arrays.stream(generatedCode.split("\n", -1)) + .map(String::stripTrailing) + .collect(java.util.stream.Collectors.joining("\n")); + + // Verify 2-space indentation (no tabs) + org.junit.jupiter.api.Assertions.assertFalse( + generatedCode.contains("\t"), + "Generated code should not contain tab characters when skipFormatting is enabled"); + + // Verify import is not concatenated with /** (should be on separate lines) + org.junit.jupiter.api.Assertions.assertFalse( + generatedCode.contains(";/**"), + "Import statements should not be concatenated with javadoc '/**' — expected newline between them"); + + // Verify no consecutive blank lines + org.junit.jupiter.api.Assertions.assertFalse( + generatedCode.contains("\n\n\n"), + "Generated code should not have consecutive blank lines (collapsed to one by lightweight formatter)"); + + // Verify javadoc body lines have " * " prefix + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains(" * Builder for {@code test.FormatTestDto}."), + "Class-level javadoc should have ' * ' prefix on body lines"); + + // Verify field-level javadoc has proper indentation and asterisk + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains(" /**\n * Tracked value for name"), + "Field-level javadoc should be indented with 2 spaces and have ' * ' prefix"); + + // Verify specific formatting properties using text block snippets + + // 1. Import and class-level javadoc: import should NOT be concatenated with /** + // (lightweight formatter splits "import ...;/**" into separate lines) + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + import org.javahelpers.simple.builders.core.util.TrackedValue; + /** + * Builder for {@code test.FormatTestDto}. + """), + "Last import should be followed by '/**' on its own line (not concatenated)"); + + // 2. Class-level javadoc body lines should have " * " prefix with correct indentation + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + * This builder provides a fluent API for creating instances of test.FormatTestDto with + * method chaining and validation. Use the static {@code create()} method + """), + "Class-level javadoc body lines should have ' * ' prefix"); + + // 3. Field-level javadoc should be indented with 2 spaces and have " * " prefix + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + /** + * Tracked value for name: name. + */ + private TrackedValue name = unsetValue(); + """), + "Field-level javadoc should be indented with 2 spaces and have ' * ' prefix"); + + // 4. Method-level javadoc should have proper indentation and " * " prefixes + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + /** + * Sets the value for name. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+               * builder.name("example value");
+               * }
+ * @param name name + * @return current instance of builder + */ + public FormatTestDtoBuilder name(String name) { + """), + "Method-level javadoc should have proper 2-space indentation and ' * ' prefixes"); + + // 5. The create() method javadoc should have proper formatting + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + /** + * Creating a new builder for {@code test.FormatTestDto}. + + *

Example:

{@code
+               * FormatTestDtoBuilder builder = FormatTestDtoBuilder.create();
+               * }
+ * @return builder for {@code test.FormatTestDto} + */ + public static FormatTestDtoBuilder create() { + """), + "create() method javadoc should have proper formatting with ' * ' prefixes"); + + // 6. Build method should be present with javadoc + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + """ + @Override + public FormatTestDto build() { + """), + "build() method should be present with @Override annotation"); + + // 7. With interface should be present at the end + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains("public interface With {"), "With interface should be generated"); + + // 8. Class annotations should be present + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains("@Generated("), "@Generated annotation should be present"); + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains("@BuilderImplementation("), + "@BuilderImplementation annotation should be present"); + } + + @Test + void skipFormatting_disabledByDefault_usesEclipseFormatter() { + JavaFileObject sourceFile = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class DefaultFormatDto { + private String value; + + public DefaultFormatDto(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(sourceFile); + + assertThat(compilation).succeeded(); + + String generatedCode = loadGeneratedSource(compilation, "DefaultFormatDtoBuilder"); + + // When formatting is NOT skipped, the Eclipse formatter adds a blank line between + // the last import and the class-level javadoc (Roaster's unformatted output concatenates them) + org.junit.jupiter.api.Assertions.assertTrue( + generatedCode.contains( + "import org.javahelpers.simple.builders.core.util.TrackedValue;\n\n/**"), + "Formatted output should have a blank line between last import and class javadoc"); + + // Verify no tabs in formatted output either (Eclipse formatter uses spaces) + org.junit.jupiter.api.Assertions.assertFalse( + generatedCode.contains("\t"), "Formatted output should not contain tab characters"); + } +} From 1a6a1f7fce4bcea9393eed2cff0698e1142d180d Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 01:41:34 +0200 Subject: [PATCH 02/25] Moving formatting to a separated SourceFormatter class --- .../roaster/RoasterCodeGenerator.java | 153 +----------- .../classgen/roaster/SourceFormatter.java | 231 ++++++++++++++++++ 2 files changed, 234 insertions(+), 150 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index a0b0ac64..bad8a25c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -29,12 +29,10 @@ import static org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker.*; import java.io.IOException; -import java.io.InputStream; import java.io.Writer; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Properties; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.processing.ProcessingEnvironment; @@ -71,12 +69,9 @@ import org.jboss.forge.roaster.model.source.MethodSource; import org.jboss.forge.roaster.model.source.ParameterSource; import org.jboss.forge.roaster.model.source.TypeVariableSource; -import org.jboss.forge.roaster.model.util.FormatterProfileReader; /** Roaster-based code generator for builder source files. */ public class RoasterCodeGenerator { - private static final String FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; - /** Processing environment for accessing filer and element utilities. */ private final ProcessingEnvironment processingEnv; @@ -86,9 +81,7 @@ public class RoasterCodeGenerator { /** Performance tracker for sub-phase timing (Source Construction, File Writing). */ private final PerformanceTracker performanceTracker; - private final Properties formatterProperties; - - private final boolean skipFormatting; + private final SourceFormatter sourceFormatter; /** * Constructor for RoasterCodeGenerator. @@ -104,8 +97,7 @@ public RoasterCodeGenerator( this.processingEnv = processingEnv; this.logger = logger; this.performanceTracker = tracker; - this.skipFormatting = skipFormatting; - this.formatterProperties = loadFormatterProperties(); + this.sourceFormatter = new SourceFormatter(logger, skipFormatting); } /** @@ -562,146 +554,7 @@ private void addParameter( } private String formatSource(String rawSource) { - if (skipFormatting || formatterProperties.isEmpty()) { - return lightweightFormat(rawSource); - } - try { - return Roaster.format(formatterProperties, rawSource); - } catch (Exception ex) { - logger.warning( - "simple-builders: Failed to format generated source with bundled Eclipse formatter profile: %s", - StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); - return rawSource; - } - } - - /** - * Lightweight post-processing of Roaster's unformatted output. - * - *

Applies minimal cosmetic fixes that are much cheaper than the full Eclipse JDT formatter: - * - *

    - *
  • Convert tab indentation to 2-space indentation - *
  • Remove duplicate blank lines (collapse 2+ consecutive blanks to 1) - *
  • Insert newline between a trailing import and an adjacent {@code /**} javadoc opening - *
  • Add missing {@code " * "} prefixes to javadoc body lines - *
  • Normalize javadoc body indentation to match the enclosing member - *
- */ - private String lightweightFormat(String source) { - // Use a list so we can insert new lines when splitting concatenated code - List lines = new java.util.ArrayList<>(java.util.Arrays.asList(source.split("\n", -1))); - boolean inJavadoc = false; - int javadocIndent = 0; - - for (int i = 0; i < lines.size(); i++) { - // 1. Convert leading tabs to 2-space indentation - lines.set(i, convertTabsToSpaces(lines.get(i))); - String converted = lines.get(i); - String convertedStripped = converted.strip(); - - // 2. Handle import/code concatenated with /** (e.g. "import ...;/**") - if (!inJavadoc) { - int jdStart = converted.indexOf("/**"); - if (jdStart >= 0) { - String afterOpen = converted.substring(jdStart + 3); - if (!afterOpen.contains("*/")) { - String before = converted.substring(0, jdStart).stripTrailing(); - String indent = getLeadingIndent(converted); - if (!before.isEmpty()) { - // Split: code stays on this line, /** goes on next line - lines.set(i, before); - lines.add(i + 1, indent + "/**"); - converted = indent + "/**"; - convertedStripped = "/**"; - } - inJavadoc = true; - javadocIndent = getLeadingIndent(converted).length(); - continue; - } - } - } - - // 3. Javadoc asterisk and indentation fixup - if (inJavadoc && !convertedStripped.startsWith("/**")) { - if (convertedStripped.endsWith("*/")) { - // Closing line - if (!convertedStripped.equals("*/") && !convertedStripped.startsWith("*")) { - String content = converted.substring(0, converted.indexOf("*/")).strip(); - lines.set(i, " ".repeat(javadocIndent) + " * " + content + " */"); - } - inJavadoc = false; - } else if (!convertedStripped.startsWith("*") && !convertedStripped.isBlank()) { - // Body line missing asterisk — add " * " prefix with proper indentation - lines.set(i, " ".repeat(javadocIndent) + " * " + convertedStripped); - } - } - } - - // Second pass: collapse consecutive blank lines to one - List result = new java.util.ArrayList<>(); - boolean prevBlank = false; - for (String line : lines) { - boolean isBlank = line.isBlank(); - if (isBlank && prevBlank) { - continue; - } - result.add(line); - prevBlank = isBlank; - } - - return String.join("\n", result); - } - - /** Convert leading tab characters to 2 spaces per tab. */ - private String convertTabsToSpaces(String line) { - if (!line.contains("\t")) { - return line; - } - StringBuilder sb = new StringBuilder(line.length()); - for (int j = 0; j < line.length(); j++) { - char c = line.charAt(j); - if (c == '\t') { - sb.append(" "); - } else if (c == ' ') { - sb.append(' '); - } else { - sb.append(line, j, line.length()); - break; - } - } - return sb.toString(); - } - - /** Extract leading whitespace (spaces and tabs) from a line. */ - private String getLeadingIndent(String line) { - int end = 0; - while (end < line.length() && (line.charAt(end) == ' ' || line.charAt(end) == '\t')) { - end++; - } - return line.substring(0, end); - } - - private Properties loadFormatterProperties() { - try (InputStream inputStream = - RoasterCodeGenerator.class - .getClassLoader() - .getResourceAsStream(FORMATTER_PROFILE_RESOURCE)) { - if (inputStream == null) { - logger.warning( - "simple-builders: Bundled Eclipse formatter profile '%s' was not found on the processor classpath.", - FORMATTER_PROFILE_RESOURCE); - return new Properties(); - } - FormatterProfileReader profileReader = FormatterProfileReader.fromEclipseXml(inputStream); - return profileReader.getDefaultProperties(); - } catch (IOException ex) { - logger.warning( - "simple-builders: Failed to load bundled Eclipse formatter profile '%s': %s", - FORMATTER_PROFILE_RESOURCE, - StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); - return new Properties(); - } + return sourceFormatter.format(rawSource); } private void writeClassToFile(String sourceCode, GenerationTargetClassDto classDef) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java new file mode 100644 index 00000000..5ef0e177 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java @@ -0,0 +1,231 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor.classgen.roaster; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; +import org.apache.commons.lang3.StringUtils; +import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; +import org.jboss.forge.roaster.Roaster; +import org.jboss.forge.roaster.model.util.FormatterProfileReader; + +/** + * Handles formatting of generated Java source code. + * + *

Supports two modes: + * + *

    + *
  • Eclipse JDT formatter — full formatting using a bundled Eclipse formatter profile. + * This is the default and produces the highest quality output. + *
  • Lightweight formatter — minimal post-processing of Roaster's {@code + * toUnformattedString()} output. Used when {@code skipFormatting} is enabled or when the + * Eclipse formatter profile cannot be loaded. Applies cosmetic fixes at a fraction of the + * cost: + *
      + *
    • Convert tab indentation to 2-space indentation + *
    • Remove duplicate blank lines (collapse 2+ consecutive blanks to 1) + *
    • Insert newline between a trailing import and an adjacent {@code /**} javadoc opening + *
    • Add missing {@code " * "} prefixes to javadoc body lines + *
    • Normalize javadoc body indentation to match the enclosing member + *
    + *
+ */ +public class SourceFormatter { + + private static final String FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; + + private final ProcessingLogger logger; + private final boolean skipFormatting; + private final Properties formatterProperties; + + /** + * Creates a formatter instance. + * + * @param logger logger for warnings (e.g. formatter profile load failures) + * @param skipFormatting if {@code true}, bypass the Eclipse formatter and use lightweight + * post-processing instead + */ + public SourceFormatter(ProcessingLogger logger, boolean skipFormatting) { + this.logger = logger; + this.skipFormatting = skipFormatting; + this.formatterProperties = loadFormatterProperties(); + } + + /** + * Formats the given raw source code. + * + *

If {@code skipFormatting} is enabled or the Eclipse formatter profile is unavailable, the + * lightweight formatter is used instead. Otherwise the full Eclipse JDT formatter is applied. + * + * @param rawSource the unformatted Java source code from Roaster's {@code toUnformattedString()} + * @return the formatted source code + */ + public String format(String rawSource) { + if (skipFormatting || formatterProperties.isEmpty()) { + return lightweightFormat(rawSource); + } + try { + return Roaster.format(formatterProperties, rawSource); + } catch (Exception ex) { + logger.warning( + "simple-builders: Failed to format generated source with bundled Eclipse formatter profile: %s", + StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); + return rawSource; + } + } + + /** + * Lightweight post-processing of Roaster's unformatted output. + * + *

Applies minimal cosmetic fixes that are much cheaper than the full Eclipse JDT formatter: + * + *

    + *
  • Convert tab indentation to 2-space indentation + *
  • Remove duplicate blank lines (collapse 2+ consecutive blanks to 1) + *
  • Insert newline between a trailing import and an adjacent {@code /**} javadoc opening + *
  • Add missing {@code " * "} prefixes to javadoc body lines + *
  • Normalize javadoc body indentation to match the enclosing member + *
+ * + * @param source the raw source from Roaster's {@code toUnformattedString()} + * @return the lightly post-processed source + */ + String lightweightFormat(String source) { + // Use a list so we can insert new lines when splitting concatenated code + List lines = new ArrayList<>(Arrays.asList(source.split("\n", -1))); + boolean inJavadoc = false; + int javadocIndent = 0; + + for (int i = 0; i < lines.size(); i++) { + // 1. Convert leading tabs to 2-space indentation + lines.set(i, convertTabsToSpaces(lines.get(i))); + String converted = lines.get(i); + String convertedStripped = converted.strip(); + + // 2. Handle import/code concatenated with /** (e.g. "import ...;/**") + if (!inJavadoc) { + int jdStart = converted.indexOf("/**"); + if (jdStart >= 0) { + String afterOpen = converted.substring(jdStart + 3); + if (!afterOpen.contains("*/")) { + String before = converted.substring(0, jdStart).stripTrailing(); + String indent = getLeadingIndent(converted); + if (!before.isEmpty()) { + // Split: code stays on this line, /** goes on next line + lines.set(i, before); + lines.add(i + 1, indent + "/**"); + converted = indent + "/**"; + convertedStripped = "/**"; + } + inJavadoc = true; + javadocIndent = getLeadingIndent(converted).length(); + continue; + } + } + } + + // 3. Javadoc asterisk and indentation fixup + if (inJavadoc && !convertedStripped.startsWith("/**")) { + if (convertedStripped.endsWith("*/")) { + // Closing line + if (!convertedStripped.equals("*/") && !convertedStripped.startsWith("*")) { + String content = converted.substring(0, converted.indexOf("*/")).strip(); + lines.set(i, " ".repeat(javadocIndent) + " * " + content + " */"); + } + inJavadoc = false; + } else if (!convertedStripped.startsWith("*") && !convertedStripped.isBlank()) { + // Body line missing asterisk — add " * " prefix with proper indentation + lines.set(i, " ".repeat(javadocIndent) + " * " + convertedStripped); + } + } + } + + // Second pass: collapse consecutive blank lines to one + List result = new ArrayList<>(); + boolean prevBlank = false; + for (String line : lines) { + boolean isBlank = line.isBlank(); + if (isBlank && prevBlank) { + continue; + } + result.add(line); + prevBlank = isBlank; + } + + return String.join("\n", result); + } + + /** Convert leading tab characters to 2 spaces per tab. */ + private String convertTabsToSpaces(String line) { + if (!line.contains("\t")) { + return line; + } + StringBuilder sb = new StringBuilder(line.length()); + for (int j = 0; j < line.length(); j++) { + char c = line.charAt(j); + if (c == '\t') { + sb.append(" "); + } else if (c == ' ') { + sb.append(' '); + } else { + sb.append(line, j, line.length()); + break; + } + } + return sb.toString(); + } + + /** Extract leading whitespace (spaces and tabs) from a line. */ + private String getLeadingIndent(String line) { + int end = 0; + while (end < line.length() && (line.charAt(end) == ' ' || line.charAt(end) == '\t')) { + end++; + } + return line.substring(0, end); + } + + private Properties loadFormatterProperties() { + try (InputStream inputStream = + SourceFormatter.class.getClassLoader().getResourceAsStream(FORMATTER_PROFILE_RESOURCE)) { + if (inputStream == null) { + logger.warning( + "simple-builders: Bundled Eclipse formatter profile '%s' was not found on the processor classpath.", + FORMATTER_PROFILE_RESOURCE); + return new Properties(); + } + FormatterProfileReader profileReader = FormatterProfileReader.fromEclipseXml(inputStream); + return profileReader.getDefaultProperties(); + } catch (IOException ex) { + logger.warning( + "simple-builders: Failed to load bundled Eclipse formatter profile '%s': %s", + FORMATTER_PROFILE_RESOURCE, + StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); + return new Properties(); + } + } +} From 39bfee455ff646a3d4d07c06cdf4b98db2f2f6ff Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 01:58:48 +0200 Subject: [PATCH 03/25] Extending tests for skipping formatting --- .../processor/SkipFormattingTest.java | 657 ++++++++++++++---- 1 file changed, 539 insertions(+), 118 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java index 9ed9c81f..1593a86a 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java @@ -92,117 +92,274 @@ public int getCount() { .map(String::stripTrailing) .collect(java.util.stream.Collectors.joining("\n")); - // Verify 2-space indentation (no tabs) - org.junit.jupiter.api.Assertions.assertFalse( - generatedCode.contains("\t"), - "Generated code should not contain tab characters when skipFormatting is enabled"); - - // Verify import is not concatenated with /** (should be on separate lines) - org.junit.jupiter.api.Assertions.assertFalse( - generatedCode.contains(";/**"), - "Import statements should not be concatenated with javadoc '/**' — expected newline between them"); - - // Verify no consecutive blank lines - org.junit.jupiter.api.Assertions.assertFalse( - generatedCode.contains("\n\n\n"), - "Generated code should not have consecutive blank lines (collapsed to one by lightweight formatter)"); - - // Verify javadoc body lines have " * " prefix - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains(" * Builder for {@code test.FormatTestDto}."), - "Class-level javadoc should have ' * ' prefix on body lines"); - - // Verify field-level javadoc has proper indentation and asterisk - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains(" /**\n * Tracked value for name"), - "Field-level javadoc should be indented with 2 spaces and have ' * ' prefix"); - - // Verify specific formatting properties using text block snippets - - // 1. Import and class-level javadoc: import should NOT be concatenated with /** - // (lightweight formatter splits "import ...;/**" into separate lines) - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - import org.javahelpers.simple.builders.core.util.TrackedValue; + // Full-file comparison using a text block. + // The 0-indented lines (package, imports, class declaration) anchor the common prefix + // so that all relative indentation (2-space, 4-space, etc.) is preserved correctly. + String expectedCode = + """ + package test; + + import static org.javahelpers.simple.builders.core.util.TrackedValue.changedValue; + import static org.javahelpers.simple.builders.core.util.TrackedValue.initialValue; + import static org.javahelpers.simple.builders.core.util.TrackedValue.unsetValue; + import java.util.function.BooleanSupplier; + import java.util.function.Consumer; + import java.util.function.Supplier; + import javax.annotation.processing.Generated; + import org.apache.commons.lang3.builder.ToStringBuilder; + import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; + import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; + import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; + import org.javahelpers.simple.builders.core.util.TrackedValue; + /** + * Builder for {@code test.FormatTestDto}. + *

+ * This builder provides a fluent API for creating instances of test.FormatTestDto with + * method chaining and validation. Use the static {@code create()} method + * to obtain a new builder instance, configure the desired properties using + * the setter methods, and then call {@code build()} to create the final DTO. + + *

Example:

{@code
+         * FormatTestDto result = FormatTestDtoBuilder.create()
+         *     .name("example value")
+         *     .name("Hello %s", "World")
+         *     .name(() -> "example value")
+         *     .name(sb -> sb.append("text"))
+         *     .count(42)
+         *     .count(() -> 42)
+         *     .build();
+         * }
+ */ + @Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") + @BuilderImplementation(forClass = FormatTestDto.class) + public class FormatTestDtoBuilder implements IBuilderBase { + + /** + * Tracked value for name: name. + */ + private TrackedValue name = unsetValue(); + /** + * Tracked value for count: count. + */ + private TrackedValue count = unsetValue(); + + /** + * Empty constructor of builder for {@code test.FormatTestDto}. + */ + public FormatTestDtoBuilder() { + } + + /** + * Initialisation of builder for {@code test.FormatTestDto} by a instance. + * @param instance object instance for initialisiation + */ + public FormatTestDtoBuilder(FormatTestDto instance) { + this.name = initialValue(instance.getName()); + this.count = initialValue(instance.getCount()); + } + + /** + * Creating a new builder for {@code test.FormatTestDto}. + + *

Example:

{@code
+           * FormatTestDtoBuilder builder = FormatTestDtoBuilder.create();
+           * }
+ * @return builder for {@code test.FormatTestDto} + */ + public static FormatTestDtoBuilder create() { + return new FormatTestDtoBuilder(); + } + + /** + * Sets the value for count. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.count(42);
+           * }
+ * @param count count + * @return current instance of builder + */ + public FormatTestDtoBuilder count(int count) { + this.count = changedValue(count); + return this; + } + + /** + * Sets the value for count by invoking the provided supplier. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.count(() -> 42);
+           * }
+ * @param countSupplier supplier for count + * @return current instance of builder + */ + public FormatTestDtoBuilder count(Supplier countSupplier) { + this.count = changedValue(countSupplier.get()); + return this; + } + + /** + * Sets the value for name. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.name("example value");
+           * }
+ * @param name name + * @return current instance of builder + */ + public FormatTestDtoBuilder name(String name) { + this.name = changedValue(name); + return this; + } + + /** + * Sets the value for name by executing the provided consumer. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.name(sb -> sb.append("text"));
+           * }
+ * @param nameStringBuilderConsumer consumer providing an instance of name + * @return current instance of builder + */ + public FormatTestDtoBuilder name(Consumer nameStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + nameStringBuilderConsumer.accept(builder); + this.name = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for name by invoking the provided supplier. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.name(() -> "example value");
+           * }
+ * @param nameSupplier supplier for name + * @return current instance of builder + */ + public FormatTestDtoBuilder name(Supplier nameSupplier) { + this.name = changedValue(nameSupplier.get()); + return this; + } + + /** + * Sets the String value for name by using String.format(format, args). + * See {@link String#format(String, Object...)} for details. + *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} + + *

Example:

{@code
+           * builder.name("Hello %s", "World");
+           * }
+ * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public FormatTestDtoBuilder name(String format, Object... args) { + this.name = changedValue(String.format(format, args)); + return this; + } + + /** + * Conditionally applies builder modifications if the condition is true. + * @param condition the condition to evaluate + * @param yesCondition the consumer to apply if condition is true + * @return this builder instance + */ + public FormatTestDtoBuilder conditional(BooleanSupplier condition, Consumer yesCondition) { + return conditional(condition, yesCondition, null); + } + + /** + * Conditionally applies builder modifications based on a condition evaluation. + * @param condition the condition to evaluate + * @param trueCase the consumer to apply if condition is true + * @param falseCase the consumer to apply if condition is false (can be null) + * @return this builder instance + */ + public FormatTestDtoBuilder conditional(BooleanSupplier condition, Consumer trueCase, + Consumer falseCase) { + if (condition.getAsBoolean()) { + trueCase.accept(this); + } else if (falseCase != null) { + falseCase.accept(this); + } + return this; + } + + /** + * Builds the configured DTO instance. + + *

Example:

{@code
+           * FormatTestDto result = builder.build();
+           * }
+ */ + @Override + public FormatTestDto build() { + if (!this.count.isSet()) { + throw new IllegalStateException("Required field 'count' must be set before calling build()"); + } + if (this.count.value() == null) { + throw new IllegalStateException("Field 'count' is marked as non-null but null value was provided"); + } + FormatTestDto result = new FormatTestDto(this.name.value(), this.count.value()); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("name", this.name) + .append("count", this.count).toString(); + } + + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { /** - * Builder for {@code test.FormatTestDto}. - """), - "Last import should be followed by '/**' on its own line (not concatenated)"); + * Initializes a builder from an instance of this class, using methods of this builder to change values and returns the new built object. + * @param b the consumer to apply modifications + * @return the modified instance + */ + default FormatTestDto with(Consumer b) { + FormatTestDtoBuilder builder; + try { + builder = new FormatTestDtoBuilder(FormatTestDto.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'FormatTestDtoBuilder.With' should only be implemented by classes, which could be casted to 'FormatTestDto'", + ex); + } + b.accept(builder); + return builder.build(); + } - // 2. Class-level javadoc body lines should have " * " prefix with correct indentation - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - * This builder provides a fluent API for creating instances of test.FormatTestDto with - * method chaining and validation. Use the static {@code create()} method - """), - "Class-level javadoc body lines should have ' * ' prefix"); - - // 3. Field-level javadoc should be indented with 2 spaces and have " * " prefix - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - /** - * Tracked value for name: name. - */ - private TrackedValue name = unsetValue(); - """), - "Field-level javadoc should be indented with 2 spaces and have ' * ' prefix"); - - // 4. Method-level javadoc should have proper indentation and " * " prefixes - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - /** - * Sets the value for name. - *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - - *

Example:

{@code
-               * builder.name("example value");
-               * }
- * @param name name - * @return current instance of builder - */ - public FormatTestDtoBuilder name(String name) { - """), - "Method-level javadoc should have proper 2-space indentation and ' * ' prefixes"); - - // 5. The create() method javadoc should have proper formatting - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - /** - * Creating a new builder for {@code test.FormatTestDto}. - - *

Example:

{@code
-               * FormatTestDtoBuilder builder = FormatTestDtoBuilder.create();
-               * }
- * @return builder for {@code test.FormatTestDto} - */ - public static FormatTestDtoBuilder create() { - """), - "create() method javadoc should have proper formatting with ' * ' prefixes"); - - // 6. Build method should be present with javadoc - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - """ - @Override - public FormatTestDto build() { - """), - "build() method should be present with @Override annotation"); - - // 7. With interface should be present at the end - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains("public interface With {"), "With interface should be generated"); - - // 8. Class annotations should be present - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains("@Generated("), "@Generated annotation should be present"); - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains("@BuilderImplementation("), - "@BuilderImplementation annotation should be present"); + /** + * Creates a builder initialized from this instance. + * @return a builder initialized with this instance's values + */ + default FormatTestDtoBuilder with() { + try { + return new FormatTestDtoBuilder(FormatTestDto.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'FormatTestDtoBuilder.With' should only be implemented by classes, which could be casted to 'FormatTestDto'", + ex); + } + } + } }"""; + org.junit.jupiter.api.Assertions.assertEquals( + expectedCode, + generatedCode, + "Generated code with skipFormatting should match the expected lightweight-formatted output"); } @Test @@ -234,15 +391,279 @@ public String getValue() { String generatedCode = loadGeneratedSource(compilation, "DefaultFormatDtoBuilder"); - // When formatting is NOT skipped, the Eclipse formatter adds a blank line between - // the last import and the class-level javadoc (Roaster's unformatted output concatenates them) - org.junit.jupiter.api.Assertions.assertTrue( - generatedCode.contains( - "import org.javahelpers.simple.builders.core.util.TrackedValue;\n\n/**"), - "Formatted output should have a blank line between last import and class javadoc"); + // Normalize trailing whitespace per line for text block comparison + generatedCode = + java.util.Arrays.stream(generatedCode.split("\n", -1)) + .map(String::stripTrailing) + .collect(java.util.stream.Collectors.joining("\n")); - // Verify no tabs in formatted output either (Eclipse formatter uses spaces) - org.junit.jupiter.api.Assertions.assertFalse( - generatedCode.contains("\t"), "Formatted output should not contain tab characters"); + String[] lines = generatedCode.split("\n", -1); + + // Section 1: package + imports + class-level javadoc + class declaration + String section1 = String.join("\n", java.util.Arrays.copyOfRange(lines, 0, 36)); + String expectedSection1 = + """ + package test; + + import static org.javahelpers.simple.builders.core.util.TrackedValue.changedValue; + import static org.javahelpers.simple.builders.core.util.TrackedValue.initialValue; + import static org.javahelpers.simple.builders.core.util.TrackedValue.unsetValue; + import java.util.function.BooleanSupplier; + import java.util.function.Consumer; + import java.util.function.Supplier; + import javax.annotation.processing.Generated; + import org.apache.commons.lang3.builder.ToStringBuilder; + import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; + import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; + import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; + import org.javahelpers.simple.builders.core.util.TrackedValue; + + /** + * Builder for {@code test.DefaultFormatDto}. + *

+ * This builder provides a fluent API for creating instances of test.DefaultFormatDto with method chaining and + * validation. Use the static {@code create()} method to obtain a new builder instance, configure the desired properties + * using the setter methods, and then call {@code build()} to create the final DTO. + * + *

Example:

+ * + *
{@code
+         * DefaultFormatDto result = DefaultFormatDtoBuilder.create()
+         *     .value("example value")
+         *     .value("Hello %s", "World")
+         *     .value(() -> "example value")
+         *     .value(sb -> sb.append("text"))
+         *     .build();
+         * }
+ */ + @Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") + @BuilderImplementation(forClass = DefaultFormatDto.class) + public class DefaultFormatDtoBuilder implements IBuilderBase {"""; + org.junit.jupiter.api.Assertions.assertEquals( + expectedSection1, + section1, + "Eclipse-formatted section 1 (package/imports/class javadoc/declaration) mismatch"); + + // Section 2: class body — the closing brace at 0 indentation anchors the common prefix + String section2 = String.join("\n", java.util.Arrays.copyOfRange(lines, 36, lines.length)); + String expectedSection2 = + """ + + /** + * Tracked value for value: value. + */ + private TrackedValue value = unsetValue(); + + /** + * Empty constructor of builder for {@code test.DefaultFormatDto}. + */ + public DefaultFormatDtoBuilder() { + } + + /** + * Initialisation of builder for {@code test.DefaultFormatDto} by a instance. + * + * @param instance object instance for initialisiation + */ + public DefaultFormatDtoBuilder(DefaultFormatDto instance) { + this.value = initialValue(instance.getValue()); + } + + /** + * Creating a new builder for {@code test.DefaultFormatDto}. + * + *

Example:

+ * + *
{@code
+           * DefaultFormatDtoBuilder builder = DefaultFormatDtoBuilder.create();
+           * }
+ * + * @return builder for {@code test.DefaultFormatDto} + */ + public static DefaultFormatDtoBuilder create() { + return new DefaultFormatDtoBuilder(); + } + + /** + * Sets the value for value. + *

+ * Generated from parameter in constructor {@link DefaultFormatDto#DefaultFormatDto(String) DefaultFormatDto(String + * value)} + * + *

Example:

+ * + *
{@code
+           * builder.value("example value");
+           * }
+ * + * @param value value + * @return current instance of builder + */ + public DefaultFormatDtoBuilder value(String value) { + this.value = changedValue(value); + return this; + } + + /** + * Sets the value for value by executing the provided consumer. + *

+ * Generated from parameter in constructor {@link DefaultFormatDto#DefaultFormatDto(String) DefaultFormatDto(String + * value)} + * + *

Example:

+ * + *
{@code
+           * builder.value(sb -> sb.append("text"));
+           * }
+ * + * @param valueStringBuilderConsumer consumer providing an instance of value + * @return current instance of builder + */ + public DefaultFormatDtoBuilder value(Consumer valueStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + valueStringBuilderConsumer.accept(builder); + this.value = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for value by invoking the provided supplier. + *

+ * Generated from parameter in constructor {@link DefaultFormatDto#DefaultFormatDto(String) DefaultFormatDto(String + * value)} + * + *

Example:

+ * + *
{@code
+           * builder.value(() -> "example value");
+           * }
+ * + * @param valueSupplier supplier for value + * @return current instance of builder + */ + public DefaultFormatDtoBuilder value(Supplier valueSupplier) { + this.value = changedValue(valueSupplier.get()); + return this; + } + + /** + * Sets the String value for value by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from parameter in constructor {@link DefaultFormatDto#DefaultFormatDto(String) DefaultFormatDto(String + * value)} + * + *

Example:

+ * + *
{@code
+           * builder.value("Hello %s", "World");
+           * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public DefaultFormatDtoBuilder value(String format, Object... args) { + this.value = changedValue(String.format(format, args)); + return this; + } + + /** + * Conditionally applies builder modifications if the condition is true. + * + * @param condition the condition to evaluate + * @param yesCondition the consumer to apply if condition is true + * @return this builder instance + */ + public DefaultFormatDtoBuilder conditional(BooleanSupplier condition, + Consumer yesCondition) { + return conditional(condition, yesCondition, null); + } + + /** + * Conditionally applies builder modifications based on a condition evaluation. + * + * @param condition the condition to evaluate + * @param trueCase the consumer to apply if condition is true + * @param falseCase the consumer to apply if condition is false (can be null) + * @return this builder instance + */ + public DefaultFormatDtoBuilder conditional(BooleanSupplier condition, Consumer trueCase, + Consumer falseCase) { + if (condition.getAsBoolean()) { + trueCase.accept(this); + } else if (falseCase != null) { + falseCase.accept(this); + } + return this; + } + + /** + * Builds the configured DTO instance. + * + *

Example:

+ * + *
{@code
+           * DefaultFormatDto result = builder.build();
+           * }
+ */ + @Override + public DefaultFormatDto build() { + DefaultFormatDto result = new DefaultFormatDto(this.value.value()); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("value", this.value).toString(); + } + + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Initializes a builder from an instance of this class, using methods of this builder to change values and returns + * the new built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default DefaultFormatDto with(Consumer b) { + DefaultFormatDtoBuilder builder; + try { + builder = new DefaultFormatDtoBuilder(DefaultFormatDto.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'DefaultFormatDtoBuilder.With' should only be implemented by classes, which could be casted to 'DefaultFormatDto'", + ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default DefaultFormatDtoBuilder with() { + try { + return new DefaultFormatDtoBuilder(DefaultFormatDto.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'DefaultFormatDtoBuilder.With' should only be implemented by classes, which could be casted to 'DefaultFormatDto'", + ex); + } + } + } + }"""; + org.junit.jupiter.api.Assertions.assertEquals( + expectedSection2, section2, "Eclipse-formatted section 2 (class body) mismatch"); } } From 077f524e872de74ea7c559b001f445886ad09238 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 02:02:12 +0200 Subject: [PATCH 04/25] Optimizing Formatter code for performance --- .../classgen/roaster/SourceFormatter.java | 92 ++++++++++--------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java index 5ef0e177..a2d752f9 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java @@ -25,9 +25,6 @@ import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import java.util.Properties; import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -116,16 +113,17 @@ public String format(String rawSource) { * @return the lightly post-processed source */ String lightweightFormat(String source) { - // Use a list so we can insert new lines when splitting concatenated code - List lines = new ArrayList<>(Arrays.asList(source.split("\n", -1))); + String[] rawLines = source.split("\n", -1); + StringBuilder output = new StringBuilder(source.length()); boolean inJavadoc = false; int javadocIndent = 0; + boolean prevBlank = false; + + for (int i = 0; i < rawLines.length; i++) { + String line = rawLines[i]; - for (int i = 0; i < lines.size(); i++) { // 1. Convert leading tabs to 2-space indentation - lines.set(i, convertTabsToSpaces(lines.get(i))); - String converted = lines.get(i); - String convertedStripped = converted.strip(); + String converted = convertTabsToSpaces(line); // 2. Handle import/code concatenated with /** (e.g. "import ...;/**") if (!inJavadoc) { @@ -136,67 +134,79 @@ String lightweightFormat(String source) { String before = converted.substring(0, jdStart).stripTrailing(); String indent = getLeadingIndent(converted); if (!before.isEmpty()) { - // Split: code stays on this line, /** goes on next line - lines.set(i, before); - lines.add(i + 1, indent + "/**"); + // Split: emit before-part as its own line, then process /** next + boolean beforeBlank = before.isBlank(); + if (!(beforeBlank && prevBlank)) { + if (output.length() > 0) output.append('\n'); + output.append(before); + prevBlank = beforeBlank; + } converted = indent + "/**"; - convertedStripped = "/**"; } inJavadoc = true; - javadocIndent = getLeadingIndent(converted).length(); - continue; + javadocIndent = indent.length(); } } } - // 3. Javadoc asterisk and indentation fixup - if (inJavadoc && !convertedStripped.startsWith("/**")) { - if (convertedStripped.endsWith("*/")) { - // Closing line - if (!convertedStripped.equals("*/") && !convertedStripped.startsWith("*")) { - String content = converted.substring(0, converted.indexOf("*/")).strip(); - lines.set(i, " ".repeat(javadocIndent) + " * " + content + " */"); + // 3. Javadoc asterisk and indentation fixup (only when in javadoc) + if (inJavadoc) { + String stripped = converted.strip(); + if (!stripped.startsWith("/**")) { + if (stripped.endsWith("*/")) { + if (!stripped.equals("*/") && !stripped.startsWith("*")) { + String content = converted.substring(0, converted.indexOf("*/")).strip(); + converted = " ".repeat(javadocIndent) + " * " + content + " */"; + } + inJavadoc = false; + } else if (!stripped.startsWith("*") && !stripped.isBlank()) { + converted = " ".repeat(javadocIndent) + " * " + stripped; } - inJavadoc = false; - } else if (!convertedStripped.startsWith("*") && !convertedStripped.isBlank()) { - // Body line missing asterisk — add " * " prefix with proper indentation - lines.set(i, " ".repeat(javadocIndent) + " * " + convertedStripped); } } - } - // Second pass: collapse consecutive blank lines to one - List result = new ArrayList<>(); - boolean prevBlank = false; - for (String line : lines) { - boolean isBlank = line.isBlank(); + // 4. Collapse consecutive blank lines + append (merged from second pass) + boolean isBlank = converted.isBlank(); if (isBlank && prevBlank) { continue; } - result.add(line); + if (output.length() > 0) output.append('\n'); + output.append(converted); prevBlank = isBlank; } - return String.join("\n", result); + return output.toString(); } /** Convert leading tab characters to 2 spaces per tab. */ private String convertTabsToSpaces(String line) { - if (!line.contains("\t")) { + int wsEnd = 0; + while (wsEnd < line.length() && (line.charAt(wsEnd) == ' ' || line.charAt(wsEnd) == '\t')) { + wsEnd++; + } + if (wsEnd == 0) { return line; } - StringBuilder sb = new StringBuilder(line.length()); - for (int j = 0; j < line.length(); j++) { + boolean hasTab = false; + for (int j = 0; j < wsEnd; j++) { + if (line.charAt(j) == '\t') { + hasTab = true; + break; + } + } + if (!hasTab) { + return line; + } + StringBuilder sb = new StringBuilder(line.length() + wsEnd); + for (int j = 0; j < wsEnd; j++) { char c = line.charAt(j); if (c == '\t') { sb.append(" "); - } else if (c == ' ') { - sb.append(' '); } else { - sb.append(line, j, line.length()); - break; + sb.append(c); } } + sb.append(line, wsEnd, line.length()); return sb.toString(); } From 6527fc5afdefd398e8a6c539eefc6127fde6b3aa Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 02:05:25 +0200 Subject: [PATCH 05/25] Replacing empty lines inside javadoc with *-lines --- .../classgen/roaster/SourceFormatter.java | 4 +++- .../builders/processor/SkipFormattingTest.java | 18 +++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java index a2d752f9..2721d0aa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java @@ -159,7 +159,9 @@ String lightweightFormat(String source) { converted = " ".repeat(javadocIndent) + " * " + content + " */"; } inJavadoc = false; - } else if (!stripped.startsWith("*") && !stripped.isBlank()) { + } else if (stripped.isBlank()) { + converted = " ".repeat(javadocIndent) + " *"; + } else if (!stripped.startsWith("*")) { converted = " ".repeat(javadocIndent) + " * " + stripped; } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java index 1593a86a..b1d8e001 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java @@ -118,7 +118,7 @@ public int getCount() { * method chaining and validation. Use the static {@code create()} method * to obtain a new builder instance, configure the desired properties using * the setter methods, and then call {@code build()} to create the final DTO. - + * *

Example:

{@code
          * FormatTestDto result = FormatTestDtoBuilder.create()
          *     .name("example value")
@@ -160,7 +160,7 @@ public FormatTestDtoBuilder(FormatTestDto instance) {
 
           /**
            * Creating a new builder for {@code test.FormatTestDto}.
-
+           *
            * 

Example:

{@code
            * FormatTestDtoBuilder builder = FormatTestDtoBuilder.create();
            * }
@@ -173,7 +173,7 @@ public static FormatTestDtoBuilder create() { /** * Sets the value for count. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.count(42);
            * }
@@ -188,7 +188,7 @@ public FormatTestDtoBuilder count(int count) { /** * Sets the value for count by invoking the provided supplier. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.count(() -> 42);
            * }
@@ -203,7 +203,7 @@ public FormatTestDtoBuilder count(Supplier countSupplier) { /** * Sets the value for name. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.name("example value");
            * }
@@ -218,7 +218,7 @@ public FormatTestDtoBuilder name(String name) { /** * Sets the value for name by executing the provided consumer. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.name(sb -> sb.append("text"));
            * }
@@ -235,7 +235,7 @@ public FormatTestDtoBuilder name(Consumer nameStringBuilderConsum /** * Sets the value for name by invoking the provided supplier. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.name(() -> "example value");
            * }
@@ -251,7 +251,7 @@ public FormatTestDtoBuilder name(Supplier nameSupplier) { * Sets the String value for name by using String.format(format, args). * See {@link String#format(String, Object...)} for details. *

Generated from parameter in constructor {@link FormatTestDto#FormatTestDto(String, int) FormatTestDto(String name, int count)} - + * *

Example:

{@code
            * builder.name("Hello %s", "World");
            * }
@@ -293,7 +293,7 @@ public FormatTestDtoBuilder conditional(BooleanSupplier condition, Consumer
Example:
{@code
            * FormatTestDto result = builder.build();
            * }
From fedb67046418bb86355bfda7e19d14801d21afbb Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sat, 29 Aug 2026 18:15:58 +0200 Subject: [PATCH 06/25] Switching to a FormattingMode (instead of boolean), supporting 3 ways (no, jdt, LIGHTWEIGHT) and updating documentation --- .../builders/core/enums/FormattingMode.java | 77 +++++++++++++++++++ docs/CONFIGURATION.md | 56 ++++++++++++++ performance-test/pom.xml | 3 + .../builders/processor/BuilderProcessor.java | 7 +- .../roaster/RoasterCodeGenerator.java | 7 +- .../classgen/roaster/SourceFormatter.java | 43 ++++------- .../processing/CompilerArgumentsEnum.java | 11 +-- .../processing/CompilerArgumentsReader.java | 14 ++++ .../processing/ProcessingContext.java | 15 ++++ ...ttingTest.java => FormattingModeTest.java} | 32 ++++---- .../RoasterCodeGeneratorResilienceTest.java | 3 +- 11 files changed, 211 insertions(+), 57 deletions(-) create mode 100644 core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java rename processor/src/test/java/org/javahelpers/simple/builders/processor/{SkipFormattingTest.java => FormattingModeTest.java} (96%) diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java b/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java new file mode 100644 index 00000000..71327dde --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java @@ -0,0 +1,77 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.core.enums; + +/** + * Enum representing the formatting mode for generated builder source files. + * + *

This enum controls how the raw source code produced by Roaster's {@code toUnformattedString()} + * is post-processed before being written to disk. + * + *

Compiler option: {@code -Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE} + */ +public enum FormattingMode { + + /** + * Full Eclipse JDT formatter (default). Produces the highest quality output but is the slowest. + */ + JDT("jdt"), + + /** + * Lightweight post-processing. Applies minimal cosmetic fixes (tab-to-space conversion, blank + * line collapsing, javadoc asterisk prefixes) at a fraction of the cost of full formatting. + */ + LIGHTWEIGHT("lightweight"), + + /** No formatting at all. Returns raw Roaster output without any post-processing. */ + NONE("none"); + + private final String optionValue; + + FormattingMode(String optionValue) { + this.optionValue = optionValue; + } + + /** + * Parses a string into a {@link FormattingMode}. + * + *

Accepts case-insensitive matching of either the enum name or the option value. Returns + * {@link #JDT} as the default for unrecognized or null input. + * + * @param value the string to parse (e.g., "jdt", "LIGHTWEIGHT", "none") + * @return the matching FormattingMode, or {@link #JDT} if not recognized + */ + public static FormattingMode fromString(String value) { + if (value == null || value.isBlank()) { + return JDT; + } + for (FormattingMode mode : values()) { + if (mode.optionValue.equalsIgnoreCase(value) || mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + return JDT; + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5f07e0a4..c98e4985 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -21,7 +21,9 @@ Simple-builders supports fine-grained configuration through the `@SimpleBuilder. - [Integration](#integration) - [Documentation](#documentation) - [Reliability](#reliability) + - [Debug Logging](#debug-logging) - [Performance Tracking](#performance-tracking) + - [Performance Optimization](#performance-optimization) - [Examples](#examples) - [Minimal Builder](#minimal-builder) - [Internal API Builder](#internal-api-builder) @@ -993,6 +995,50 @@ mvn compile \ **Note**: `performanceTracking` must be enabled for `performanceOutputFile` to have any effect. +--- + +### Debug Logging + +#### `verbose` + +**Default**: `false` | **Compiler Option**: `-Asimplebuilder.verbose=true|false` + +> **Note**: This is a **processor-level option** only. It cannot be set per-annotation via +> `@SimpleBuilder.Options`. + +Enables debug logging during annotation processing, providing detailed tracing of field discovery, +method analysis, and code generation steps. See [DEBUG_LOGGING.md](DEBUG_LOGGING.md) for detailed +information on log levels, output format, and configuration examples. + +--- + +### Performance Optimization + +#### `formattingMode` + +**Default**: `jdt` | **Compiler Option**: `-Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE` + +> **Note**: This is a **processor-level option** only. It cannot be set per-annotation via +> `@SimpleBuilder.Options` because formatting applies uniformly to all generated sources within a +> compilation. + +Controls how generated source code is post-processed before being written to disk: + +| Mode | Description | Trade-offs | +|------|-------------|------------| +| `JDT` (default) | Full Eclipse JDT formatter | Highest quality; slowest (~40% of generation time) | +| `LIGHTWEIGHT` | Minimal cosmetic fixes (tabs→spaces, blank line collapse, javadoc prefixes) | Fast; no line wrapping or import ordering | +| `NONE` | Raw Roaster output, no post-processing | Fastest; no indentation, not suitable for committed code | + +**Example**: +```bash +# Maven +mvn compile -Dsimplebuilder.formattingMode=lightweight + +# Or via compiler arg +-Asimplebuilder.formattingMode=lightweight +``` + ## Examples ### Minimal Builder @@ -1347,6 +1393,7 @@ methodAccess = AccessModifier.PRIVATE -Asimplebuilder.generateStringFormatHelpers=ENABLED|DISABLED -Asimplebuilder.generateAddToCollectionHelpers=ENABLED|DISABLED -Asimplebuilder.generateUnboxedOptional=ENABLED|DISABLED +-Asimplebuilder.copyTypeAnnotations=ENABLED|DISABLED # Collection Helpers -Asimplebuilder.usingArrayListBuilder=ENABLED|DISABLED @@ -1363,6 +1410,9 @@ methodAccess = AccessModifier.PRIVATE -Asimplebuilder.implementsBuilderBase=ENABLED|DISABLED -Asimplebuilder.usingGeneratedAnnotation=ENABLED|DISABLED -Asimplebuilder.usingBuilderImplementationAnnotation=ENABLED|DISABLED +-Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED +-Asimplebuilder.generateJacksonModule=ENABLED|DISABLED +-Asimplebuilder.jacksonModulePackage=com.your.package # Documentation -Asimplebuilder.generateJavaDoc=ENABLED|DISABLED @@ -1374,9 +1424,15 @@ methodAccess = AccessModifier.PRIVATE # Reliability -Asimplebuilder.strict=ENABLED|DISABLED +# Debug Logging +-Asimplebuilder.verbose=true|false + # Performance Tracking -Asimplebuilder.performanceTracking=true|false -Asimplebuilder.performanceOutputFile=path/to/report.json + +# Performance Optimization +-Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE ``` ### Complete Options Example diff --git a/performance-test/pom.xml b/performance-test/pom.xml index 86dcb154..8161d822 100644 --- a/performance-test/pom.xml +++ b/performance-test/pom.xml @@ -31,6 +31,7 @@ false + jdt 3.15.0 3.1.4 @@ -117,6 +118,7 @@ -Asimplebuilder.performanceTracking=${simplebuilder.performanceTracking} -Asimplebuilder.performanceOutputFile=${simplebuilder.performanceOutputFile} + -Asimplebuilder.formattingMode=${simplebuilder.formattingMode} @@ -156,6 +158,7 @@ -Asimplebuilder.performanceTracking=${simplebuilder.performanceTracking} -Asimplebuilder.performanceOutputFile=${simplebuilder.performanceOutputFile} + -Asimplebuilder.formattingMode=${simplebuilder.formattingMode} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 66201669..6142c017 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -92,12 +92,9 @@ public synchronized void init(ProcessingEnvironment processingEnv) { logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); this.context = new ProcessingContext(logger, globalConfig, processingEnv); - boolean skipFormatting = - new CompilerArgumentsReader(processingEnv) - .readBooleanValue(CompilerArgumentsEnum.SKIP_FORMATTING); this.codeGenerator = new RoasterCodeGenerator( - processingEnv, logger, context.getPerformanceTracker(), skipFormatting); + processingEnv, logger, context.getPerformanceTracker(), context.getFormattingMode()); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger); // Initialize GeneratorRegistry once during processor initialization @@ -273,7 +270,7 @@ private void process(Element annotatedElement, BuilderConfiguration config) // Track Code Generation (parent phase; sub-phases tracked inside RoasterCodeGenerator) tracker.startPhase(); - + codeGenerator.generateClass(renderingDto); tracker.endPhase(PHASE_CODE_GENERATION); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index bad8a25c..320e6b72 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -41,6 +41,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.annotation.InterfaceName; @@ -88,16 +89,18 @@ public class RoasterCodeGenerator { * * @param processingEnv Processing environment for accessing filer and element utilities * @param logger Logger for debug output + * @param tracker Performance tracker for sub-phase timing + * @param formattingMode Formatting mode for source code post-processing */ public RoasterCodeGenerator( ProcessingEnvironment processingEnv, ProcessingLogger logger, PerformanceTracker tracker, - boolean skipFormatting) { + FormattingMode formattingMode) { this.processingEnv = processingEnv; this.logger = logger; this.performanceTracker = tracker; - this.sourceFormatter = new SourceFormatter(logger, skipFormatting); + this.sourceFormatter = new SourceFormatter(logger, formattingMode); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java index 2721d0aa..105d6dc0 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java @@ -27,6 +27,7 @@ import java.io.InputStream; import java.util.Properties; import org.apache.commons.lang3.StringUtils; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; import org.jboss.forge.roaster.Roaster; import org.jboss.forge.roaster.model.util.FormatterProfileReader; @@ -34,56 +35,45 @@ /** * Handles formatting of generated Java source code. * - *

Supports two modes: - * - *

    - *
  • Eclipse JDT formatter — full formatting using a bundled Eclipse formatter profile. - * This is the default and produces the highest quality output. - *
  • Lightweight formatter — minimal post-processing of Roaster's {@code - * toUnformattedString()} output. Used when {@code skipFormatting} is enabled or when the - * Eclipse formatter profile cannot be loaded. Applies cosmetic fixes at a fraction of the - * cost: - *
      - *
    • Convert tab indentation to 2-space indentation - *
    • Remove duplicate blank lines (collapse 2+ consecutive blanks to 1) - *
    • Insert newline between a trailing import and an adjacent {@code /**} javadoc opening - *
    • Add missing {@code " * "} prefixes to javadoc body lines - *
    • Normalize javadoc body indentation to match the enclosing member - *
    - *
+ *

Supports three modes controlled by {@link FormattingMode}: full Eclipse JDT formatting, + * lightweight cosmetic post-processing, or no formatting at all. See the {@link FormattingMode} + * enum for details on each mode. */ public class SourceFormatter { private static final String FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; private final ProcessingLogger logger; - private final boolean skipFormatting; + private final FormattingMode formattingMode; private final Properties formatterProperties; /** * Creates a formatter instance. * * @param logger logger for warnings (e.g. formatter profile load failures) - * @param skipFormatting if {@code true}, bypass the Eclipse formatter and use lightweight - * post-processing instead + * @param formattingMode the formatting mode to use for source code post-processing */ - public SourceFormatter(ProcessingLogger logger, boolean skipFormatting) { + public SourceFormatter(ProcessingLogger logger, FormattingMode formattingMode) { this.logger = logger; - this.skipFormatting = skipFormatting; + this.formattingMode = formattingMode; this.formatterProperties = loadFormatterProperties(); } /** - * Formats the given raw source code. + * Formats the given raw source code according to the configured {@link FormattingMode}. * - *

If {@code skipFormatting} is enabled or the Eclipse formatter profile is unavailable, the - * lightweight formatter is used instead. Otherwise the full Eclipse JDT formatter is applied. + *

If {@link FormattingMode#NONE}, the raw source is returned as-is. If {@link + * FormattingMode#LIGHTWEIGHT} or if the Eclipse formatter profile is unavailable, the lightweight + * formatter is used. Otherwise the full Eclipse JDT formatter is applied. * * @param rawSource the unformatted Java source code from Roaster's {@code toUnformattedString()} * @return the formatted source code */ public String format(String rawSource) { - if (skipFormatting || formatterProperties.isEmpty()) { + if (formattingMode == FormattingMode.NONE) { + return rawSource; + } + if (formattingMode == FormattingMode.LIGHTWEIGHT || formatterProperties.isEmpty()) { return lightweightFormat(rawSource); } try { @@ -176,7 +166,6 @@ String lightweightFormat(String source) { output.append(converted); prevBlank = isBlank; } - return output.toString(); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java index 258eae56..49be4f39 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java @@ -132,16 +132,17 @@ public enum CompilerArgumentsEnum { */ DEACTIVATE_GENERATION_COMPONENTS("deactivateGenerationComponents"), - // === Logging === + // === Debug Logging === /** Option for verbose logging output. */ VERBOSE("verbose"), - // === Performance === + // === Performance Optimization === /** - * Option to skip Eclipse code formatting for faster generation. Output is still valid Java but - * not style-formatted. + * Option to control the formatting mode for generated source files. Accepts values {@code jdt}, + * {@code lightweight}, or {@code none}. See {@link + * org.javahelpers.simple.builders.core.enums.FormattingMode} for details. */ - SKIP_FORMATTING("skipFormatting"), + FORMATTING_MODE("formattingMode"), // === Performance Tracking === /** Option for performance tracking during annotation processing. */ diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java index 967265ad..1f9ad600 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java @@ -27,6 +27,7 @@ import javax.annotation.processing.ProcessingEnvironment; import org.apache.commons.lang3.Strings; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; @@ -123,6 +124,19 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) { } } + /** + * Reads the formatting mode from compiler arguments. + * + *

Returns the corresponding FormattingMode enum value, or {@link FormattingMode#JDT} as the + * default if not set or invalid. + * + * @return the FormattingMode value, defaults to {@link FormattingMode#JDT} + */ + public FormattingMode readFormattingMode() { + String value = readValue(CompilerArgumentsEnum.FORMATTING_MODE); + return FormattingMode.fromString(value); + } + /** * Reads a complete BuilderConfiguration from compiler arguments. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index 44e80152..aada81c7 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -32,6 +32,7 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.generators.registry.GeneratorRegistry; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.type.TypeName; @@ -53,6 +54,7 @@ public final class ProcessingContext { private final BuilderConfigurationReader configurationReader; private final ProcessingEnvironment processingEnv; private final PerformanceTracker performanceTracker; + private final FormattingMode formattingMode; private GeneratorRegistry generatorRegistry; private BuilderConfiguration configurationForProcessingTarget; @@ -82,6 +84,7 @@ public ProcessingContext( perfTrackingEnabled ? new ActivePerformanceTracker(perfOutputFile) : new NoOpPerformanceTracker(); + this.formattingMode = argReader.readFormattingMode(); // GeneratorRegistry will be lazily initialized on first access } @@ -140,6 +143,18 @@ public PerformanceTracker getPerformanceTracker() { return performanceTracker; } + /** + * Gets the formatting mode for generated source files. + * + *

Controlled via {@code -Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE}. Defaults to + * {@link FormattingMode#JDT} when not specified. + * + * @return the formatting mode to use for source code post-processing + */ + public FormattingMode getFormattingMode() { + return formattingMode; + } + /** * Get the TypeElement for a given qualified class name. * diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java similarity index 96% rename from processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java rename to processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java index b1d8e001..4e7d546c 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SkipFormattingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java @@ -25,6 +25,7 @@ import static com.google.testing.compile.CompilationSubject.assertThat; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.junit.jupiter.api.Assertions.assertEquals; import com.google.testing.compile.Compilation; import javax.tools.JavaFileObject; @@ -32,23 +33,21 @@ import org.junit.jupiter.api.Test; /** - * Tests for the {@code skipFormatting} compiler option. + * Tests for the {@code formattingMode} compiler option. * - *

Verifies that when {@code -Asimplebuilder.skipFormatting=true} is set: + *

Verifies: * *

    - *
  • The generated code compiles successfully - *
  • Tab indentation is converted to 2-space indentation - *
  • Javadoc body lines have proper {@code " * "} prefixes - *
  • Import statements are not concatenated with javadoc {@code /**} - *
  • Consecutive blank lines are collapsed to one - *
  • The generated code structure matches the expected lightweight-formatted output + *
  • {@code lightweight} mode: generated code compiles, tab indentation is converted to 2-space, + * javadoc body lines have proper {@code " * "} prefixes, imports are not concatenated with + * javadoc, consecutive blank lines are collapsed + *
  • Default (JDT) mode: generated code matches Eclipse-formatted output *
*/ -class SkipFormattingTest { +class FormattingModeTest { @Test - void skipFormatting_producesValidCodeWithLightweightFormatting() { + void lightweightFormatting_producesValidCodeWithLightweightFormatting() { JavaFileObject sourceFile = ProcessorTestUtils.forSource( """ @@ -78,7 +77,7 @@ public int getCount() { Compilation compilation = ProcessorTestUtils.createCompiler() - .withOptions("-Asimplebuilder.skipFormatting=true") + .withOptions("-Asimplebuilder.formattingMode=lightweight") .compile(sourceFile); assertThat(compilation).succeeded(); @@ -356,14 +355,14 @@ default FormatTestDtoBuilder with() { } } } }"""; - org.junit.jupiter.api.Assertions.assertEquals( + assertEquals( expectedCode, generatedCode, - "Generated code with skipFormatting should match the expected lightweight-formatted output"); + "Generated code with lightweight formatting should match the expected lightweight-formatted output"); } @Test - void skipFormatting_disabledByDefault_usesEclipseFormatter() { + void jdtFormattingByDefault_usesEclipseFormatter() { JavaFileObject sourceFile = ProcessorTestUtils.forSource( """ @@ -439,7 +438,7 @@ public String getValue() { @Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") @BuilderImplementation(forClass = DefaultFormatDto.class) public class DefaultFormatDtoBuilder implements IBuilderBase {"""; - org.junit.jupiter.api.Assertions.assertEquals( + assertEquals( expectedSection1, section1, "Eclipse-formatted section 1 (package/imports/class javadoc/declaration) mismatch"); @@ -663,7 +662,6 @@ default DefaultFormatDtoBuilder with() { } } }"""; - org.junit.jupiter.api.Assertions.assertEquals( - expectedSection2, section2, "Eclipse-formatted section 2 (class body) mismatch"); + assertEquals(expectedSection2, section2, "Eclipse-formatted section 2 (class body) mismatch"); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java index 0919f0b9..f8e9792f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java @@ -41,6 +41,7 @@ import javax.lang.model.util.Types; import javax.tools.Diagnostic; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.classgen.roaster.exceptions.RoasterMapperException; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -88,7 +89,7 @@ void shouldWrapRenderingRuntimeExceptionInBuilderException() { ProcessingEnvironment env = new NoopProcessingEnvironment(); RoasterCodeGenerator generator = new RoasterCodeGenerator( - env, new ProcessingLogger(env), new NoOpPerformanceTracker(), false); + env, new ProcessingLogger(env), new NoOpPerformanceTracker(), FormattingMode.JDT); BuilderException thrown = assertThrows(BuilderException.class, () -> generator.generateClass(classDef)); From 070b7b56d4cfe1d1d12412fd26bc0ca2b2095e6c Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 13:05:44 +0200 Subject: [PATCH 07/25] Improving codequality --- .../roaster/RoasterCodeGenerator.java | 4 +- ...atter.java => RoasterSourceFormatter.java} | 174 ++++++++++++------ 2 files changed, 119 insertions(+), 59 deletions(-) rename processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/{SourceFormatter.java => RoasterSourceFormatter.java} (56%) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index 320e6b72..07058457 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -82,7 +82,7 @@ public class RoasterCodeGenerator { /** Performance tracker for sub-phase timing (Source Construction, File Writing). */ private final PerformanceTracker performanceTracker; - private final SourceFormatter sourceFormatter; + private final RoasterSourceFormatter sourceFormatter; /** * Constructor for RoasterCodeGenerator. @@ -100,7 +100,7 @@ public RoasterCodeGenerator( this.processingEnv = processingEnv; this.logger = logger; this.performanceTracker = tracker; - this.sourceFormatter = new SourceFormatter(logger, formattingMode); + this.sourceFormatter = new RoasterSourceFormatter(logger, formattingMode); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java similarity index 56% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java index 105d6dc0..d6faf0f6 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/SourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.Objects; import java.util.Properties; import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.core.enums.FormattingMode; @@ -33,30 +34,42 @@ import org.jboss.forge.roaster.model.util.FormatterProfileReader; /** - * Handles formatting of generated Java source code. + * Handles formatting of Java source code produced by Roaster's {@code toUnformattedString()}. + * + *

This formatter is specifically designed for Roaster's output format and addresses its quirks + * (tab indentation, concatenated imports/javadoc, missing javadoc asterisk prefixes). It is not a + * general-purpose source code formatter. * *

Supports three modes controlled by {@link FormattingMode}: full Eclipse JDT formatting, * lightweight cosmetic post-processing, or no formatting at all. See the {@link FormattingMode} * enum for details on each mode. */ -public class SourceFormatter { +public final class RoasterSourceFormatter { private static final String FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; + private static final int SPACES_PER_TAB = 2; private final ProcessingLogger logger; private final FormattingMode formattingMode; private final Properties formatterProperties; + private final boolean formatterProfileAvailable; /** * Creates a formatter instance. * * @param logger logger for warnings (e.g. formatter profile load failures) * @param formattingMode the formatting mode to use for source code post-processing + * @throws NullPointerException if logger or formattingMode is null */ - public SourceFormatter(ProcessingLogger logger, FormattingMode formattingMode) { - this.logger = logger; - this.formattingMode = formattingMode; + public RoasterSourceFormatter(ProcessingLogger logger, FormattingMode formattingMode) { + this.logger = Objects.requireNonNull(logger, "logger must not be null"); + this.formattingMode = Objects.requireNonNull(formattingMode, "formattingMode must not be null"); this.formatterProperties = loadFormatterProperties(); + this.formatterProfileAvailable = !formatterProperties.isEmpty(); + if (formattingMode == FormattingMode.JDT && !formatterProfileAvailable) { + logger.warning( + "simple-builders: JDT formatting requested but Eclipse formatter profile is unavailable; falling back to lightweight formatting."); + } } /** @@ -73,7 +86,10 @@ public String format(String rawSource) { if (formattingMode == FormattingMode.NONE) { return rawSource; } - if (formattingMode == FormattingMode.LIGHTWEIGHT || formatterProperties.isEmpty()) { + if (formattingMode == FormattingMode.LIGHTWEIGHT) { + return lightweightFormat(rawSource); + } + if (!formatterProfileAvailable) { return lightweightFormat(rawSource); } try { @@ -105,71 +121,108 @@ public String format(String rawSource) { String lightweightFormat(String source) { String[] rawLines = source.split("\n", -1); StringBuilder output = new StringBuilder(source.length()); - boolean inJavadoc = false; - int javadocIndent = 0; + JavadocState javadocState = new JavadocState(); boolean prevBlank = false; - for (int i = 0; i < rawLines.length; i++) { - String line = rawLines[i]; - - // 1. Convert leading tabs to 2-space indentation + for (String line : rawLines) { + // 1. Convert leading tabs to spaces String converted = convertTabsToSpaces(line); - // 2. Handle import/code concatenated with /** (e.g. "import ...;/**") - if (!inJavadoc) { - int jdStart = converted.indexOf("/**"); - if (jdStart >= 0) { - String afterOpen = converted.substring(jdStart + 3); - if (!afterOpen.contains("*/")) { - String before = converted.substring(0, jdStart).stripTrailing(); - String indent = getLeadingIndent(converted); - if (!before.isEmpty()) { - // Split: emit before-part as its own line, then process /** next - boolean beforeBlank = before.isBlank(); - if (!(beforeBlank && prevBlank)) { - if (output.length() > 0) output.append('\n'); - output.append(before); - prevBlank = beforeBlank; - } - converted = indent + "/**"; - } - inJavadoc = true; - javadocIndent = indent.length(); - } - } + // 2. Split import/code concatenated with /** + if (!javadocState.inJavadoc) { + converted = splitConcatenatedJavadocOpen(converted, output, javadocState, prevBlank); } - // 3. Javadoc asterisk and indentation fixup (only when in javadoc) - if (inJavadoc) { - String stripped = converted.strip(); - if (!stripped.startsWith("/**")) { - if (stripped.endsWith("*/")) { - if (!stripped.equals("*/") && !stripped.startsWith("*")) { - String content = converted.substring(0, converted.indexOf("*/")).strip(); - converted = " ".repeat(javadocIndent) + " * " + content + " */"; - } - inJavadoc = false; - } else if (stripped.isBlank()) { - converted = " ".repeat(javadocIndent) + " *"; - } else if (!stripped.startsWith("*")) { - converted = " ".repeat(javadocIndent) + " * " + stripped; - } - } + // 3. Fix javadoc asterisk prefixes and indentation + if (javadocState.inJavadoc) { + converted = fixJavadocLine(converted, javadocState); } - // 4. Collapse consecutive blank lines + append (merged from second pass) + // 4. Collapse consecutive blank lines and append boolean isBlank = converted.isBlank(); if (isBlank && prevBlank) { continue; } - if (output.length() > 0) output.append('\n'); + if (output.length() > 0) { + output.append('\n'); + } output.append(converted); prevBlank = isBlank; } return output.toString(); } - /** Convert leading tab characters to 2 spaces per tab. */ + /** + * Handles a line that may contain a {@code /**} javadoc opening concatenated with preceding code + * (e.g. {@code "import ...;/**"}). If found, emits the preceding part as its own line and updates + * the javadoc state. Returns the remaining line to process (either the original or just the + * {@code /**} part). + */ + private String splitConcatenatedJavadocOpen( + String converted, StringBuilder output, JavadocState javadocState, boolean prevBlank) { + int jdStart = converted.indexOf("/**"); + if (jdStart < 0) { + return converted; + } + String afterOpen = converted.substring(jdStart + 3); + if (afterOpen.contains("*/")) { + return converted; + } + String before = converted.substring(0, jdStart).stripTrailing(); + String indent = getLeadingIndent(converted); + javadocState.inJavadoc = true; + javadocState.indent = indent.length(); + if (!before.isEmpty()) { + boolean beforeBlank = before.isBlank(); + if (!(beforeBlank && prevBlank)) { + if (output.length() > 0) { + output.append('\n'); + } + output.append(before); + } + return indent + "/**"; + } + return converted; + } + + /** + * Fixes javadoc body lines by adding missing {@code " * "} prefixes and normalizing indentation. + * Updates the javadoc state when the closing javadoc delimiter is encountered. + */ + private String fixJavadocLine(String converted, JavadocState javadocState) { + String stripped = converted.strip(); + if (stripped.startsWith("/**")) { + return converted; + } + if (stripped.endsWith("*/")) { + if (!stripped.equals("*/") && !stripped.startsWith("*")) { + String content = converted.substring(0, converted.indexOf("*/")).strip(); + converted = " ".repeat(javadocState.indent) + " * " + content + " */"; + } + javadocState.inJavadoc = false; + return converted; + } + if (stripped.isBlank()) { + return " ".repeat(javadocState.indent) + " *"; + } + if (!stripped.startsWith("*")) { + return " ".repeat(javadocState.indent) + " * " + stripped; + } + return converted; + } + + /** Mutable state for javadoc processing within {@link #lightweightFormat(String)}. */ + private static final class JavadocState { + boolean inJavadoc = false; + int indent = 0; + } + + /** + * Convert leading tab characters to spaces (2 spaces per tab). + * + * @param line the line to convert + * @return the line with leading tabs replaced by spaces, or the original line if no tabs + */ private String convertTabsToSpaces(String line) { int wsEnd = 0; while (wsEnd < line.length() && (line.charAt(wsEnd) == ' ' || line.charAt(wsEnd) == '\t')) { @@ -192,7 +245,7 @@ private String convertTabsToSpaces(String line) { for (int j = 0; j < wsEnd; j++) { char c = line.charAt(j); if (c == '\t') { - sb.append(" "); + sb.append(" ".repeat(SPACES_PER_TAB)); } else { sb.append(c); } @@ -201,8 +254,13 @@ private String convertTabsToSpaces(String line) { return sb.toString(); } - /** Extract leading whitespace (spaces and tabs) from a line. */ - private String getLeadingIndent(String line) { + /** + * Extract leading whitespace (spaces and tabs) from a line. + * + * @param line the line to extract indent from + * @return the leading whitespace string + */ + private static String getLeadingIndent(String line) { int end = 0; while (end < line.length() && (line.charAt(end) == ' ' || line.charAt(end) == '\t')) { end++; @@ -212,7 +270,9 @@ private String getLeadingIndent(String line) { private Properties loadFormatterProperties() { try (InputStream inputStream = - SourceFormatter.class.getClassLoader().getResourceAsStream(FORMATTER_PROFILE_RESOURCE)) { + RoasterSourceFormatter.class + .getClassLoader() + .getResourceAsStream(FORMATTER_PROFILE_RESOURCE)) { if (inputStream == null) { logger.warning( "simple-builders: Bundled Eclipse formatter profile '%s' was not found on the processor classpath.", From 0d59180e3d825aa835241788a5c57430307bd708 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 13:06:39 +0200 Subject: [PATCH 08/25] Adding extended tests for it! --- .../roaster/RoasterSourceFormatterTest.java | 484 ++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java new file mode 100644 index 00000000..995abce6 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -0,0 +1,484 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor.classgen.roaster; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic; +import org.javahelpers.simple.builders.core.enums.FormattingMode; +import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RoasterSourceFormatter}. + * + *

Tests cover: + * + *

    + *
  • {@code lightweightFormat()} edge cases: tab conversion, blank line collapsing, javadoc + * fixup, concatenated import/javadoc splitting + *
  • {@code format()} dispatch logic for NONE, LIGHTWEIGHT, and JDT modes + *
  • Fallback behavior when JDT formatter profile is unavailable + *
  • Constructor null checks + *
+ */ +class RoasterSourceFormatterTest { + + /** A minimal ProcessingEnvironment stub that provides a no-op Messager. */ + private static final class TestProcessingEnv implements ProcessingEnvironment { + final TestMessager messager = new TestMessager(); + + @Override + public Messager getMessager() { + return messager; + } + + @Override + public Map getOptions() { + return Collections.emptyMap(); + } + + @Override + public Elements getElementUtils() { + return null; + } + + @Override + public Types getTypeUtils() { + return null; + } + + @Override + public Filer getFiler() { + return null; + } + + @Override + public SourceVersion getSourceVersion() { + return SourceVersion.RELEASE_17; + } + + @Override + public Locale getLocale() { + return Locale.getDefault(); + } + } + + /** A minimal Messager that captures warnings. */ + private static final class TestMessager implements Messager { + final List warnings = new ArrayList<>(); + + @Override + public void printMessage(Diagnostic.Kind kind, CharSequence msg) { + if (kind == Diagnostic.Kind.WARNING) { + warnings.add(msg.toString()); + } + } + + @Override + public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e) { + if (kind == Diagnostic.Kind.WARNING) { + warnings.add(msg.toString()); + } + } + + @Override + public void printMessage( + Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a) { + if (kind == Diagnostic.Kind.WARNING) { + warnings.add(msg.toString()); + } + } + + @Override + public void printMessage( + Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a, AnnotationValue v) { + if (kind == Diagnostic.Kind.WARNING) { + warnings.add(msg.toString()); + } + } + } + + private TestProcessingEnv createProcessingEnv() { + return new TestProcessingEnv(); + } + + private RoasterSourceFormatter createFormatter(FormattingMode mode) { + return new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), mode); + } + + // === Constructor tests === + + @Test + void constructor_nullLogger_throwsNullPointerException() { + assertThrows( + NullPointerException.class, () -> new RoasterSourceFormatter(null, FormattingMode.JDT)); + } + + @Test + void constructor_nullFormattingMode_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), null)); + } + + // === NONE mode tests === + + @Test + void format_noneMode_returnsRawSourceUnchanged() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.NONE); + String raw = + """ + package test; + public class Foo { + } + """; + assertEquals(raw, formatter.format(raw)); + } + + // === LIGHTWEIGHT mode tests === + + @Test + void lightweightFormat_convertsTabsToSpaces() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + public class Foo { + \tpublic void bar() { + \t\treturn; + \t} + } + """; + String result = formatter.lightweightFormat(input); + assertTrue(result.contains(" public void bar()"), "Tabs should be converted to 2 spaces"); + assertTrue(result.contains(" return;"), "Nested tabs should be converted to 4 spaces"); + assertTrue(!result.contains("\t"), "No tabs should remain in output"); + } + + @Test + void lightweightFormat_collapsesConsecutiveBlankLines() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + + + + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + long blankCount = result.lines().filter(String::isBlank).count(); + assertEquals(1, blankCount, "Multiple consecutive blank lines should collapse to 1"); + } + + @Test + void lightweightFormat_preservesSingleBlankLine() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + long blankCount = result.lines().filter(String::isBlank).count(); + assertEquals(1, blankCount, "Single blank line should be preserved"); + } + + @Test + void lightweightFormat_splitsConcatenatedImportAndJavadoc() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test;import java.util.List;/** + * This is a javadoc. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains("import java.util.List;\n/**"), + "Import and javadoc opening should be split into separate lines"); + } + + @Test + void lightweightFormat_addsJavadocAsteriskPrefixes() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + This is a javadoc body line. + Another body line. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * This is a javadoc body line."), + "Javadoc body lines should get ' * ' prefix"); + assertTrue( + result.contains(" * Another body line."), + "Multiple javadoc body lines should get ' * ' prefix"); + } + + @Test + void lightweightFormat_normalizesBlankJavadocLines() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + First line. + + Second line. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * First line.\n *\n * Second line."), + "Blank javadoc lines should get ' *' prefix"); + } + + @Test + void lightweightFormat_handlesInlineJavadocClose() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** This is a one-line javadoc. */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains("/** This is a one-line javadoc. */"), + "Inline javadoc (/** ... */) should be preserved as-is"); + } + + @Test + void lightweightFormat_handlesEmptyInput() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String result = formatter.lightweightFormat(""); + assertEquals("", result, "Empty input should produce empty output"); + } + + @Test + void lightweightFormat_handlesJavadocWithIndentation() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + public class Foo { + /** + Body line. + */ + public void bar() { + } + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" /**\n * Body line."), + "Javadoc body lines should be indented to match the enclosing member"); + assertTrue( + result.contains(" */\n public void bar()"), + "Closing javadoc should align with the enclosing member"); + } + + @Test + void lightweightFormat_preservesAlreadyPrefixedJavadocLines() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + * Already has prefix. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * Already has prefix."), + "Lines that already have ' * ' prefix should be preserved"); + } + + @Test + void lightweightFormat_handlesMultipleJavadocBlocks() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + Class-level javadoc. + */ + public class Foo { + /** + Method javadoc. + */ + public void bar() { + } + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * Class-level javadoc."), + "First javadoc block body should get ' * ' prefix"); + assertTrue( + result.contains(" * Method javadoc."), + "Second javadoc block body should get ' * ' prefix with correct indentation"); + } + + @Test + void lightweightFormat_preservesJavadocTagLines() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + Description. + @param value the value + @return the result + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * @param value the value"), + "Javadoc @param tags should get ' * ' prefix"); + assertTrue( + result.contains(" * @return the result"), "Javadoc @return tags should get ' * ' prefix"); + } + + @Test + void lightweightFormat_handlesMixedTabsAndSpaces() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + public class Foo { + \t public void bar() { + \t \treturn; + \t } + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" public void bar()"), + "Mixed tab+space indentation should be converted (tab=2 spaces, then existing spaces)"); + assertTrue(!result.contains("\t"), "No tabs should remain in output"); + } + + @Test + void lightweightFormat_preservesStarPrefixWithoutSpace() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + *Body line without space. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains("*Body line without space."), + "Lines with '*' prefix but no space should be preserved as-is (already have asterisk)"); + } + + // === format() dispatch tests === + + @Test + void format_lightweightMode_usesLightweightFormat() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + \tpublic class Foo { + } + """; + String result = formatter.format(input); + assertTrue(!result.contains("\t"), "LIGHTWEIGHT mode should convert tabs"); + } + + @Test + void format_noneMode_doesNotConvertTabs() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.NONE); + String input = + """ + package test; + \tpublic class Foo { + } + """; + String result = formatter.format(input); + assertTrue(result.contains("\t"), "NONE mode should preserve tabs"); + } + + // === JDT mode tests === + + @Test + void format_jdtMode_withProfile_producesFormattedOutput() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + RoasterSourceFormatter formatter = new RoasterSourceFormatter(logger, FormattingMode.JDT); + String input = + """ + package test; + \tpublic class Foo { + } + """; + String result = formatter.format(input); + assertNotNull(result, "Format should always return a non-null string"); + assertTrue( + env.messager.warnings.stream().noneMatch(w -> w.contains("JDT formatting requested")), + "No fallback warning should be logged when formatter profile is available on classpath"); + } +} From d201eb396fe634eeee9507d1bc74dc2b739ab58e Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 13:50:55 +0200 Subject: [PATCH 09/25] Refactoring code to have the formatting in Annotation-Properties --- .../core/annotations/SimpleBuilder.java | 32 +++++ .../annotations/SimpleMinimalBuilder.java | 3 +- docs/CONFIGURATION.md | 13 +- .../builders/processor/BuilderProcessor.java | 7 +- .../roaster/RoasterCodeGenerator.java | 31 +++-- .../model/core/BuilderConfiguration.java | 33 +++++ .../core/BuilderToGenerationTypeMapper.java | 9 +- .../model/core/GenerationTargetClassDto.java | 22 +++ .../BuilderConfigurationReader.java | 1 + .../BuilderConfigurationReaderTest.java | 130 ++++++++++++++++++ .../ConfigurationProcessingTest.java | 52 +++++++ .../RoasterCodeGeneratorResilienceTest.java | 4 +- 12 files changed, 315 insertions(+), 22 deletions(-) diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index e6ca1145..3bce51bc 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -729,6 +729,38 @@ * @return the suffix for setter method names */ String setterSuffix() default ""; + + /** + * Formatting mode for the generated source code.
+ * Controls how the generated builder source is post-processed for readability. + * + *

Accepted values (case-insensitive): + * + *

    + *
  • {@code "jdt"} - Full Eclipse JDT formatting (default) + *
  • {@code "lightweight"} - Lightweight cosmetic formatting (tabs to spaces, javadoc fixup, + * blank line collapsing) + *
  • {@code "none"} - No formatting, raw Roaster output + *
+ * + *

An empty string (the default) means "inherit from compiler argument {@code + * -Asimplebuilder.formattingMode}", which itself defaults to {@code jdt}. + * + *

Example: + * + *

{@code
+     * @SimpleBuilder(options = @SimpleBuilder.Options(
+     *     formattingMode = "lightweight"
+     * ))
+     * public class PersonDto { ... }
+     * }
+ * + * Default: "" (empty - inherit from compiler argument)
+ * Compiler option: -Asimplebuilder.formattingMode + * + * @return the formatting mode for generated source code + */ + String formattingMode() default ""; } /** diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java index 63195002..5aec8ff5 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java @@ -94,7 +94,8 @@ copyTypeAnnotations = OptionState.DISABLED, implementsBuilderBase = OptionState.DISABLED, builderSuffix = "Builder", - setterSuffix = "")) + setterSuffix = "", + formattingMode = "lightweight")) @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @Inherited diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c98e4985..e6f11926 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1017,10 +1017,7 @@ information on log levels, output format, and configuration examples. #### `formattingMode` **Default**: `jdt` | **Compiler Option**: `-Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE` - -> **Note**: This is a **processor-level option** only. It cannot be set per-annotation via -> `@SimpleBuilder.Options` because formatting applies uniformly to all generated sources within a -> compilation. +| **Annotation Option**: `@SimpleBuilder.Options(formattingMode = "lightweight")` Controls how generated source code is post-processed before being written to disk: @@ -1039,6 +1036,14 @@ mvn compile -Dsimplebuilder.formattingMode=lightweight -Asimplebuilder.formattingMode=lightweight ``` +Per-annotation override: +```java +@SimpleBuilder(options = @SimpleBuilder.Options( + formattingMode = "lightweight" +)) +public class PersonDto { ... } +``` + ## Examples ### Minimal Builder diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 6142c017..095d492b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -93,8 +93,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { this.context = new ProcessingContext(logger, globalConfig, processingEnv); this.codeGenerator = - new RoasterCodeGenerator( - processingEnv, logger, context.getPerformanceTracker(), context.getFormattingMode()); + new RoasterCodeGenerator(processingEnv, logger, context.getPerformanceTracker()); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger); // Initialize GeneratorRegistry once during processor initialization @@ -131,6 +130,7 @@ public boolean process(Set annotations, RoundEnvironment jacksonModuleGenerator.getModuleDefinitions(); for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) { String packageName = moduleClassDef.getTypeName().getPackageName(); + moduleClassDef.setFormattingMode(context.getFormattingMode()); context.info("Generating Jackson Module in package '%s'", packageName); try { codeGenerator.generateClass(moduleClassDef); @@ -265,7 +265,8 @@ private void process(Element annotatedElement, BuilderConfiguration config) // Track DTO Mapping tracker.startPhase(); GenerationTargetClassDto renderingDto = - new BuilderToGenerationTypeMapper(config).toRenderingDto(builderDef); + new BuilderToGenerationTypeMapper(config, context.getFormattingMode()) + .toRenderingDto(builderDef); tracker.endPhase(PHASE_DTO_MAPPING); // Track Code Generation (parent phase; sub-phases tracked inside RoasterCodeGenerator) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index 07058457..5df3f01e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -31,6 +31,7 @@ import java.io.IOException; import java.io.Writer; import java.util.Arrays; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -82,7 +83,9 @@ public class RoasterCodeGenerator { /** Performance tracker for sub-phase timing (Source Construction, File Writing). */ private final PerformanceTracker performanceTracker; - private final RoasterSourceFormatter sourceFormatter; + /** Cached formatters per formatting mode (at most 3 instances, created lazily). */ + private final EnumMap formatterCache = + new EnumMap<>(FormattingMode.class); /** * Constructor for RoasterCodeGenerator. @@ -90,17 +93,25 @@ public class RoasterCodeGenerator { * @param processingEnv Processing environment for accessing filer and element utilities * @param logger Logger for debug output * @param tracker Performance tracker for sub-phase timing - * @param formattingMode Formatting mode for source code post-processing */ public RoasterCodeGenerator( - ProcessingEnvironment processingEnv, - ProcessingLogger logger, - PerformanceTracker tracker, - FormattingMode formattingMode) { + ProcessingEnvironment processingEnv, ProcessingLogger logger, PerformanceTracker tracker) { this.processingEnv = processingEnv; this.logger = logger; this.performanceTracker = tracker; - this.sourceFormatter = new RoasterSourceFormatter(logger, formattingMode); + } + + /** + * Returns a cached or newly created {@link RoasterSourceFormatter} for the given mode. + * + *

At most 3 formatter instances exist (one per {@link FormattingMode} enum value), created + * lazily on first use. + * + * @param mode the formatting mode + * @return a cached or new formatter instance + */ + private RoasterSourceFormatter getFormatter(FormattingMode mode) { + return formatterCache.computeIfAbsent(mode, m -> new RoasterSourceFormatter(logger, m)); } /** @@ -121,7 +132,7 @@ public void generateClass(GenerationTargetClassDto classDef) throws BuilderExcep String unformatted = source.toUnformattedString(); performanceTracker.endPhase(PHASE_STRING_GENERATION); performanceTracker.startPhase(); - sourceCode = formatSource(unformatted); + sourceCode = formatSource(unformatted, classDef.getFormattingMode()); // Roaster renders some java.lang annotations (e.g. @SuppressWarnings, @Deprecated with // members) with their FQN (@java.lang.SuppressWarnings) even though java.lang types don't // need qualification. Fix this by replacing @java.lang.Xxx with @Xxx for known annotations. @@ -556,8 +567,8 @@ private void addParameter( applyAnnotations(parameter, paramDto.getAnnotations()); } - private String formatSource(String rawSource) { - return sourceFormatter.format(rawSource); + private String formatSource(String rawSource, FormattingMode mode) { + return getFormatter(mode).format(rawSource); } private void writeClassToFile(String sourceCode, GenerationTargetClassDto classDef) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java index b007eadb..75e8723c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java @@ -32,6 +32,7 @@ import org.apache.commons.lang3.builder.ToStringStyle; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.core.enums.OptionState; /** @@ -65,6 +66,8 @@ * @param generateJavaDoc Generate Javadoc comments * @param builderSuffix Suffix for builder class name * @param setterSuffix Suffix for setter method names + * @param formattingMode Formatting mode for generated source code (null = inherit from compiler + * arg) * @param strict Strict/fail-fast generation mode */ public record BuilderConfiguration( @@ -95,6 +98,7 @@ public record BuilderConfiguration( String jacksonModulePackage, String builderSuffix, String setterSuffix, + String formattingMode, OptionState strict) { public static final BuilderConfiguration DEFAULT = @@ -126,6 +130,7 @@ public record BuilderConfiguration( .jacksonModulePackage(null) .builderSuffix("Builder") .setterSuffix("") + .formattingMode(null) .strict(DISABLED) .build(); @@ -243,6 +248,23 @@ public boolean isStrictModeEnabled() { return strict == ENABLED; } + /** + * Resolves the effective formatting mode for this configuration. + * + *

If this configuration's {@code formattingMode} is null or blank, the provided fallback (from + * compiler arguments) is used. Otherwise, this configuration's value takes priority. + * + * @param fallback the formatting mode from compiler arguments (used when annotation value is + * unset) + * @return the resolved formatting mode + */ + public FormattingMode resolveFormattingMode(FormattingMode fallback) { + if (formattingMode != null && !formattingMode.isBlank()) { + return FormattingMode.fromString(formattingMode); + } + return fallback; + } + /** * Merges this configuration with another configuration. * @@ -310,6 +332,7 @@ public BuilderConfiguration merge(BuilderConfiguration other) { .jacksonModulePackage(mergeString(other.jacksonModulePackage, this.jacksonModulePackage)) .builderSuffix(mergeString(other.builderSuffix, this.builderSuffix)) .setterSuffix(mergeString(other.setterSuffix, this.setterSuffix)) + .formattingMode(mergeString(other.formattingMode, this.formattingMode)) .strict(mergeOptionState(other.strict, this.strict)) .build(); } @@ -377,6 +400,7 @@ public String toString() { .appendIfNotEmpty("jacksonModulePackage", jacksonModulePackage) .appendIfNotEmpty("builderSuffix", builderSuffix) .appendIfNotEmpty("setterSuffix", setterSuffix) + .appendIfNotEmpty("formattingMode", formattingMode) .appendValueIfSet("strict", strict) .toString(); } @@ -459,6 +483,9 @@ public static class Builder { private String builderSuffix = null; private String setterSuffix = null; + // === Formatting === + private String formattingMode = null; + // === Error Handling === private OptionState strict = OptionState.UNSET; @@ -718,6 +745,11 @@ public Builder setterSuffix(String value) { return this; } + public Builder formattingMode(String value) { + this.formattingMode = StringUtils.trimToNull(value); + return this; + } + public Builder strict(OptionState value) { this.strict = value; return this; @@ -757,6 +789,7 @@ public BuilderConfiguration build() { jacksonModulePackage, builderSuffix, setterSuffix, + formattingMode, strict); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java index 530189f6..3e5950d2 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java @@ -25,6 +25,7 @@ package org.javahelpers.simple.builders.processor.model.core; import org.apache.commons.lang3.StringUtils; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.model.javadoc.JavadocDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; import org.javahelpers.simple.builders.processor.model.method.ConstructorDto; @@ -51,14 +52,19 @@ public class BuilderToGenerationTypeMapper { private final BuilderConfiguration configuration; + private final FormattingMode globalFormattingMode; /** * Creates a mapper for the given effective builder configuration. * * @param configuration the effective builder configuration + * @param globalFormattingMode the global formatting mode from compiler arguments (used as + * fallback when the annotation does not specify a formatting mode) */ - public BuilderToGenerationTypeMapper(BuilderConfiguration configuration) { + public BuilderToGenerationTypeMapper( + BuilderConfiguration configuration, FormattingMode globalFormattingMode) { this.configuration = configuration; + this.globalFormattingMode = globalFormattingMode; } /** @@ -78,6 +84,7 @@ public GenerationTargetClassDto toRenderingDto(BuilderDefinitionDto builderDto) renderingDto.setSuperType(builderDto.getSuperType()); renderingDto.setClassJavadoc( configuration.shouldGenerateJavaDoc() ? builderDto.getClassJavadoc() : null); + renderingDto.setFormattingMode(configuration.resolveFormattingMode(globalFormattingMode)); builderDto.getClassFields().stream() .map(this::toRenderingClassField) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GenerationTargetClassDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GenerationTargetClassDto.java index a61fc56c..71c19300 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GenerationTargetClassDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GenerationTargetClassDto.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Set; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.annotation.InterfaceName; import org.javahelpers.simple.builders.processor.model.imports.ImportStatement; @@ -94,6 +95,9 @@ public class GenerationTargetClassDto { /** Class-level JavaDoc for the generated class. */ private JavadocDto classJavadoc; + /** Formatting mode for this generated class (null = use global default). */ + private FormattingMode formattingMode; + public TypeName getTypeName() { return typeName; } @@ -288,4 +292,22 @@ public JavadocDto getClassJavadoc() { public void setClassJavadoc(JavadocDto classJavadoc) { this.classJavadoc = classJavadoc; } + + /** + * Returns the formatting mode for this generated class. + * + * @return the formatting mode, or null to use the global default + */ + public FormattingMode getFormattingMode() { + return formattingMode; + } + + /** + * Sets the formatting mode for this generated class. + * + * @param formattingMode the formatting mode, or null to use the global default + */ + public void setFormattingMode(FormattingMode formattingMode) { + this.formattingMode = formattingMode; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java index 607b968c..da146f06 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java @@ -336,6 +336,7 @@ private BuilderConfiguration parseOptionsFromMirror(AnnotationMirror optionsMirr case "jacksonModulePackage" -> builder.jacksonModulePackage(value.toString()); case "builderSuffix" -> builder.builderSuffix(value.toString()); case "setterSuffix" -> builder.setterSuffix(value.toString()); + case "formattingMode" -> builder.formattingMode(value.toString()); default -> logger.warning( "Unknown configuration option '%s' with value '%s' - ignoring", name, value); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java index 18993690..d2ce32f7 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationReaderTest.java @@ -25,6 +25,7 @@ package org.javahelpers.simple.builders.processor; import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; import com.google.testing.compile.Compilation; import javax.tools.JavaFileObject; @@ -715,4 +716,133 @@ public class PersonDto { // Then: Compilation succeeds without warnings about access modifiers assertThat(compilation).succeededWithoutWarnings(); } + + /** + * Test: {@code @SimpleBuilder.Options(formattingMode = "none")} produces unformatted output. + * + *

When {@code formattingMode} is set to {@code "none"} via annotation options, the generated + * source should be raw Roaster output without any post-processing (no indentation fixup). + */ + @Test + void readFromOptions_FormattingModeNone_GeneratesUnformattedCode() { + JavaFileObject source = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder(options = @SimpleBuilder.Options( + formattingMode = "none" + )) + public class PersonDto { + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(source); + + assertThat(compilation).succeeded(); + String generatedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "PersonDtoBuilder"); + assertNotNull(generatedCode, "PersonDtoBuilder should be generated"); + } + + /** + * Test: {@code @SimpleBuilder.Options(formattingMode = "lightweight")} produces formatted output. + * + *

When {@code formattingMode} is set to {@code "lightweight"} via annotation options, the + * generated source should have proper indentation (tabs converted to spaces). + */ + @Test + void readFromOptions_FormattingModeLightweight_GeneratesFormattedCode() { + JavaFileObject source = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder(options = @SimpleBuilder.Options( + formattingMode = "lightweight" + )) + public class PersonDto { + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(source); + + assertThat(compilation).succeeded(); + String generatedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "PersonDtoBuilder"); + assertNotNull(generatedCode, "PersonDtoBuilder should be generated"); + + // Lightweight formatting should produce 2-space indentation (not tabs) + ProcessorAsserts.assertContaining(generatedCode, " private"); + } + + /** + * Test: {@code @SimpleMinimalBuilder} uses lightweight formatting by default. + * + *

The {@code @SimpleMinimalBuilder} template sets {@code formattingMode = "lightweight"} in + * its template options. This verifies the template option flows through correctly. + */ + @Test + void readFromTemplate_SimpleMinimalBuilder_UsesLightweightFormatting() { + JavaFileObject source = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleMinimalBuilder; + + @SimpleMinimalBuilder + public class MinimalDto { + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(source); + + assertThat(compilation).succeeded(); + String generatedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "MinimalDtoBuilder"); + assertNotNull(generatedCode, "MinimalDtoBuilder should be generated"); + + // Lightweight formatting should produce 2-space indentation (not tabs) + ProcessorAsserts.assertContaining(generatedCode, " private"); + } + + /** + * Test: Default {@code @SimpleBuilder} (without formattingMode option) uses JDT formatting. + * + *

When no {@code formattingMode} is specified in annotation options, the global default (JDT) + * from compiler arguments should be used. + */ + @Test + void readFromOptions_NoFormattingMode_UsesJdtDefault() { + JavaFileObject source = + ProcessorTestUtils.simpleBuilderClass( + "test", + "PersonDto", + """ + private String name; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(source); + + assertThat(compilation).succeeded(); + String generatedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "PersonDtoBuilder"); + assertNotNull(generatedCode, "PersonDtoBuilder should be generated"); + + // JDT formatting should produce 2-space indentation + ProcessorAsserts.assertContaining(generatedCode, " private"); + } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java index ea9f5470..1790304d 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java @@ -8,6 +8,7 @@ import com.google.testing.compile.Compilation; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; @@ -86,6 +87,8 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { // Naming .builderSuffix("Builder") .setterSuffix("") + // Formatting + .formattingMode("lightweight") .build(); // Verify all options are accessible (this will fail to compile if accessors are missing) @@ -115,6 +118,7 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { assertEquals(OptionState.ENABLED, config.generateJavaDoc()); assertEquals("Builder", config.getBuilderSuffix()); assertEquals("", config.getSetterSuffix()); + assertEquals("lightweight", config.formattingMode()); } /** @@ -538,6 +542,54 @@ void configurationMerge_MustRespectPriority() { assertEquals("with", merged.getSetterSuffix(), "Override should win for setterSuffix"); } + /** Merge logic test for formattingMode: Annotation value must override compiler arg default. */ + @Test + void configurationMerge_FormattingMode_MustRespectPriority() { + // Given: Base config with no formattingMode (inherit from compiler arg) + BuilderConfiguration base = BuilderConfiguration.builder().build(); + + // When: Merge with override that sets formattingMode + BuilderConfiguration override = BuilderConfiguration.builder().formattingMode("none").build(); + + BuilderConfiguration merged = base.merge(override); + + // Then: Override should win + assertEquals("none", merged.formattingMode(), "Override should win for formattingMode"); + + // And: resolveFormattingMode should return the override value + assertEquals( + FormattingMode.NONE, + merged.resolveFormattingMode(FormattingMode.JDT), + "Annotation formattingMode should override compiler arg fallback"); + } + + /** resolveFormattingMode test: Null/blank formattingMode should fall back to compiler arg. */ + @Test + void resolveFormattingMode_WhenUnset_ShouldUseFallback() { + BuilderConfiguration config = BuilderConfiguration.builder().build(); + + assertEquals( + FormattingMode.JDT, + config.resolveFormattingMode(FormattingMode.JDT), + "Null formattingMode should fall back to JDT"); + assertEquals( + FormattingMode.LIGHTWEIGHT, + config.resolveFormattingMode(FormattingMode.LIGHTWEIGHT), + "Null formattingMode should fall back to LIGHTWEIGHT when provided as fallback"); + } + + /** resolveFormattingMode test: Set formattingMode should override fallback. */ + @Test + void resolveFormattingMode_WhenSet_ShouldOverrideFallback() { + BuilderConfiguration config = + BuilderConfiguration.builder().formattingMode("lightweight").build(); + + assertEquals( + FormattingMode.LIGHTWEIGHT, + config.resolveFormattingMode(FormattingMode.JDT), + "Set formattingMode should override compiler arg fallback"); + } + /** * toString test: Configuration must produce human-readable output. * diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java index f8e9792f..cd9caa34 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java @@ -41,7 +41,6 @@ import javax.lang.model.util.Types; import javax.tools.Diagnostic; import org.javahelpers.simple.builders.core.enums.AccessModifier; -import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.classgen.roaster.exceptions.RoasterMapperException; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -88,8 +87,7 @@ void shouldWrapRenderingRuntimeExceptionInBuilderException() { ProcessingEnvironment env = new NoopProcessingEnvironment(); RoasterCodeGenerator generator = - new RoasterCodeGenerator( - env, new ProcessingLogger(env), new NoOpPerformanceTracker(), FormattingMode.JDT); + new RoasterCodeGenerator(env, new ProcessingLogger(env), new NoOpPerformanceTracker()); BuilderException thrown = assertThrows(BuilderException.class, () -> generator.generateClass(classDef)); From 2f2229da9fc27dcaf99701edae29f1e1730d1bd4 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 14:26:52 +0200 Subject: [PATCH 10/25] Improving test coverage --- .../roaster/RoasterSourceFormatterTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index 995abce6..f9738acc 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -434,6 +434,23 @@ public class Foo { "Lines with '*' prefix but no space should be preserved as-is (already have asterisk)"); } + @Test + void lightweightFormat_fixesJavadocLineEndingWithStarSlash() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + /** + Some description text */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + assertTrue( + result.contains(" * Some description text */"), + "Javadoc line ending with */ but not starting with * should get ' * ' prefix"); + } + // === format() dispatch tests === @Test From 3c6b03e33e3baa71c7330ced2b2d2582b05b6b81 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 16:51:34 +0200 Subject: [PATCH 11/25] Calculating coverage of core by tests of processor module --- .github/workflows/fork-coverage.yml | 2 +- .github/workflows/fork-sonar.yml | 4 ++-- .github/workflows/maven.yml | 9 ++++++--- pom.xml | 2 +- processor/pom.xml | 10 ++++++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/fork-coverage.yml b/.github/workflows/fork-coverage.yml index 9c4b0e8e..f2c5c1cc 100644 --- a/.github/workflows/fork-coverage.yml +++ b/.github/workflows/fork-coverage.yml @@ -72,7 +72,7 @@ jobs: uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - files: codecov-payload/jacoco.xml + files: codecov-payload/jacoco.xml,codecov-payload/jacoco-aggregate.xml flags: processor name: codecov-upload-fork override_commit: ${{ steps.meta.outputs.pr_head_sha }} diff --git a/.github/workflows/fork-sonar.yml b/.github/workflows/fork-sonar.yml index ef09b41f..587698b7 100644 --- a/.github/workflows/fork-sonar.yml +++ b/.github/workflows/fork-sonar.yml @@ -92,9 +92,9 @@ jobs: mkdir -p core/target/classes processor/target/classes cp -a "$src/core-classes/." core/target/classes/ 2>/dev/null || true cp -a "$src/processor-classes/." processor/target/classes/ 2>/dev/null || true - mkdir -p core/target/site/jacoco processor/target/site/jacoco - cp -a "$src/core-jacoco.xml" core/target/site/jacoco/jacoco.xml 2>/dev/null || true + mkdir -p processor/target/site/jacoco processor/target/site/jacoco-aggregate cp -a "$src/processor-jacoco.xml" processor/target/site/jacoco/jacoco.xml 2>/dev/null || true + cp -a "$src/jacoco-aggregate.xml" processor/target/site/jacoco-aggregate/jacoco.xml 2>/dev/null || true - name: Publish fork PR analysis to SonarCloud if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index a5ae9d74..8099d63b 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -106,7 +106,7 @@ jobs: mkdir -p sonar-analysis-data cp -a core/target/classes sonar-analysis-data/core-classes 2>/dev/null || true cp -a processor/target/classes sonar-analysis-data/processor-classes 2>/dev/null || true - cp -a core/target/site/jacoco/jacoco.xml sonar-analysis-data/core-jacoco.xml 2>/dev/null || true + cp -a processor/target/site/jacoco-aggregate/jacoco.xml sonar-analysis-data/jacoco-aggregate.xml 2>/dev/null || true cp -a processor/target/site/jacoco/jacoco.xml sonar-analysis-data/processor-jacoco.xml 2>/dev/null || true { printf 'pr_number=%s\n' "$PR_NUMBER" @@ -163,6 +163,7 @@ jobs: run: | mkdir -p codecov-payload/surefire codecov-payload/failsafe cp -f processor/target/site/jacoco/jacoco.xml codecov-payload/ 2>/dev/null || true + cp -f processor/target/site/jacoco-aggregate/jacoco.xml codecov-payload/jacoco-aggregate.xml 2>/dev/null || true cp -f processor/target/surefire-reports/*.xml codecov-payload/surefire/ 2>/dev/null || true cp -f processor/target/failsafe-reports/*.xml codecov-payload/failsafe/ 2>/dev/null || true { @@ -191,13 +192,15 @@ jobs: fail_ci_if_error: true verbose: false - - name: Upload coverage to Codecov (processor) + - name: Upload coverage to Codecov (processor + core via aggregate) # Skip when CODECOV_TOKEN is unavailable (fork PRs) and for any bot-authored PR. if: always() && env.CODECOV_TOKEN != '' && github.event.pull_request.user.type != 'Bot' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true - files: processor/target/site/jacoco/jacoco.xml + files: | + processor/target/site/jacoco/jacoco.xml + processor/target/site/jacoco-aggregate/jacoco.xml flags: processor name: codecov-upload verbose: false diff --git a/pom.xml b/pom.xml index fa324c44..a0c628c9 100644 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ Simple Builders https://sonarcloud.io ${project.basedir}/core/target/classes,${project.basedir}/processor/target/classes - ${project.basedir}/core/target/site/jacoco/jacoco.xml,${project.basedir}/processor/target/site/jacoco/jacoco.xml + ${project.basedir}/processor/target/site/jacoco-aggregate/jacoco.xml,${project.basedir}/processor/target/site/jacoco/jacoco.xml **/example/** From 4bddb60f521cf34d47cb9d6c1add381aa4bf16da Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 16:52:31 +0200 Subject: [PATCH 12/25] Improving coverage by extending tests and refactoring of RoasterSourceFormatter for external configuration --- .../roaster/RoasterSourceFormatter.java | 26 ++-- .../roaster/RoasterSourceFormatterTest.java | 144 +++++++++++++++--- .../eclipse-java-format-malformed.xml | 1 + 3 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 processor/src/test/resources/eclipse-java-format-malformed.xml diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java index d6faf0f6..276b6ff7 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java @@ -46,13 +46,14 @@ */ public final class RoasterSourceFormatter { - private static final String FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; + static final String DEFAULT_FORMATTER_PROFILE_RESOURCE = "eclipse-java-format.xml"; private static final int SPACES_PER_TAB = 2; private final ProcessingLogger logger; private final FormattingMode formattingMode; private final Properties formatterProperties; private final boolean formatterProfileAvailable; + private final String formatterProfileResource; /** * Creates a formatter instance. @@ -62,8 +63,16 @@ public final class RoasterSourceFormatter { * @throws NullPointerException if logger or formattingMode is null */ public RoasterSourceFormatter(ProcessingLogger logger, FormattingMode formattingMode) { + this(logger, formattingMode, DEFAULT_FORMATTER_PROFILE_RESOURCE); + } + + RoasterSourceFormatter( + ProcessingLogger logger, FormattingMode formattingMode, String formatterProfileResource) { this.logger = Objects.requireNonNull(logger, "logger must not be null"); this.formattingMode = Objects.requireNonNull(formattingMode, "formattingMode must not be null"); + this.formatterProfileResource = + Objects.requireNonNull( + formatterProfileResource, "formatterProfileResource must not be null"); this.formatterProperties = loadFormatterProperties(); this.formatterProfileAvailable = !formatterProperties.isEmpty(); if (formattingMode == FormattingMode.JDT && !formatterProfileAvailable) { @@ -92,14 +101,7 @@ public String format(String rawSource) { if (!formatterProfileAvailable) { return lightweightFormat(rawSource); } - try { - return Roaster.format(formatterProperties, rawSource); - } catch (Exception ex) { - logger.warning( - "simple-builders: Failed to format generated source with bundled Eclipse formatter profile: %s", - StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); - return rawSource; - } + return Roaster.format(formatterProperties, rawSource); } /** @@ -272,11 +274,11 @@ private Properties loadFormatterProperties() { try (InputStream inputStream = RoasterSourceFormatter.class .getClassLoader() - .getResourceAsStream(FORMATTER_PROFILE_RESOURCE)) { + .getResourceAsStream(formatterProfileResource)) { if (inputStream == null) { logger.warning( "simple-builders: Bundled Eclipse formatter profile '%s' was not found on the processor classpath.", - FORMATTER_PROFILE_RESOURCE); + formatterProfileResource); return new Properties(); } FormatterProfileReader profileReader = FormatterProfileReader.fromEclipseXml(inputStream); @@ -284,7 +286,7 @@ private Properties loadFormatterProperties() { } catch (IOException ex) { logger.warning( "simple-builders: Failed to load bundled Eclipse formatter profile '%s': %s", - FORMATTER_PROFILE_RESOURCE, + formatterProfileResource, StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName())); return new Properties(); } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index f9738acc..b5dc9048 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -238,8 +238,12 @@ public class Foo { } """; String result = formatter.lightweightFormat(input); + String expected = + """ + import java.util.List; + /**"""; assertTrue( - result.contains("import java.util.List;\n/**"), + result.contains(expected), "Import and javadoc opening should be split into separate lines"); } @@ -257,12 +261,12 @@ public class Foo { } """; String result = formatter.lightweightFormat(input); - assertTrue( - result.contains(" * This is a javadoc body line."), - "Javadoc body lines should get ' * ' prefix"); - assertTrue( - result.contains(" * Another body line."), - "Multiple javadoc body lines should get ' * ' prefix"); + String expected = + """ + * This is a javadoc body line. + * Another body line.""" + .indent(1); + assertTrue(result.contains(expected), "Javadoc body lines should get ' * ' prefix"); } @Test @@ -280,9 +284,13 @@ public class Foo { } """; String result = formatter.lightweightFormat(input); - assertTrue( - result.contains(" * First line.\n *\n * Second line."), - "Blank javadoc lines should get ' *' prefix"); + String expected = + """ + * First line. + * + * Second line.""" + .indent(1); + assertTrue(result.contains(expected), "Blank javadoc lines should get ' *' prefix"); } @Test @@ -323,12 +331,23 @@ public void bar() { } """; String result = formatter.lightweightFormat(input); + String expectedJavadoc = + """ + /** + * Body line.""" + .indent(2) + .stripTrailing(); + String expectedClose = + """ + */ + public void bar()""" + .indent(2) + .stripTrailing(); assertTrue( - result.contains(" /**\n * Body line."), + result.contains(expectedJavadoc), "Javadoc body lines should be indented to match the enclosing member"); assertTrue( - result.contains(" */\n public void bar()"), - "Closing javadoc should align with the enclosing member"); + result.contains(expectedClose), "Closing javadoc should align with the enclosing member"); } @Test @@ -390,11 +409,13 @@ public class Foo { } """; String result = formatter.lightweightFormat(input); + String expected = + """ + * @param value the value + * @return the result""" + .indent(1); assertTrue( - result.contains(" * @param value the value"), - "Javadoc @param tags should get ' * ' prefix"); - assertTrue( - result.contains(" * @return the result"), "Javadoc @return tags should get ' * ' prefix"); + result.contains(expected), "Javadoc @param and @return tags should get ' * ' prefix"); } @Test @@ -453,6 +474,28 @@ public class Foo { // === format() dispatch tests === + @Test + void lightweightFormat_splitsConcatenatedImportAndJavadocWithPrecedingLine() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + import java.util.List;/** + * Test javadoc. + */ + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + String expected = + """ + import java.util.List; + /**"""; + assertTrue( + result.contains(expected), + "Concatenated import and javadoc should be split into separate lines with newline between"); + } + @Test void format_lightweightMode_usesLightweightFormat() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); @@ -498,4 +541,71 @@ void format_jdtMode_withProfile_producesFormattedOutput() { env.messager.warnings.stream().noneMatch(w -> w.contains("JDT formatting requested")), "No fallback warning should be logged when formatter profile is available on classpath"); } + + // === Error path tests (missing/malformed formatter profile) === + + @Test + void constructor_jdtMode_missingProfile_logsFallbackWarning() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml"); + assertTrue( + env.messager.warnings.stream() + .anyMatch(w -> w.contains("JDT formatting requested") && w.contains("unavailable")), + "JDT mode with missing profile should log fallback warning"); + } + + @Test + void constructor_lightweightMode_missingProfile_noFallbackWarning() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + new RoasterSourceFormatter(logger, FormattingMode.LIGHTWEIGHT, "nonexistent-profile.xml"); + assertTrue( + env.messager.warnings.stream().noneMatch(w -> w.contains("JDT formatting requested")), + "LIGHTWEIGHT mode should not log JDT fallback warning even if profile is missing"); + } + + @Test + void constructor_missingProfile_logsProfileNotFoundWarning() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml"); + assertTrue( + env.messager.warnings.stream() + .anyMatch(w -> w.contains("not found") && w.contains("nonexistent-profile.xml")), + "Missing formatter profile should log 'not found' warning with resource name"); + } + + @Test + void format_jdtMode_missingProfile_fallsBackToLightweight() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + RoasterSourceFormatter formatter = + new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml"); + String input = + """ + package test; + \tpublic class Foo { + } + """; + String result = formatter.format(input); + assertNotNull(result, "Format should always return a non-null string"); + assertTrue( + !result.contains("\t"), + "JDT mode with missing profile should fall back to lightweight (tabs converted)"); + } + + @Test + void constructor_malformedProfile_logsLoadFailureWarning() { + TestProcessingEnv env = createProcessingEnv(); + ProcessingLogger logger = new ProcessingLogger(env); + new RoasterSourceFormatter(logger, FormattingMode.JDT, "eclipse-java-format-malformed.xml"); + assertTrue( + env.messager.warnings.stream() + .anyMatch( + w -> + w.contains("Failed to load") + && w.contains("eclipse-java-format-malformed.xml")), + "Malformed formatter profile should log 'Failed to load' warning"); + } } diff --git a/processor/src/test/resources/eclipse-java-format-malformed.xml b/processor/src/test/resources/eclipse-java-format-malformed.xml new file mode 100644 index 00000000..73b5364c --- /dev/null +++ b/processor/src/test/resources/eclipse-java-format-malformed.xml @@ -0,0 +1 @@ + Date: Sun, 30 Aug 2026 17:15:30 +0200 Subject: [PATCH 13/25] Minimal Change to customerDtoBuilder for expectation of processor run --- .../simple/builders/example/CustomerDtoBuilder.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java index b8c2c25f..cd86f936 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java @@ -7,7 +7,6 @@ import org.apache.commons.lang3.builder.ToStringBuilder; import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; import org.javahelpers.simple.builders.core.util.TrackedValue; - public class CustomerDtoBuilder { private TrackedValue email = unsetValue(); @@ -75,9 +74,6 @@ public CustomerDto build() { @Override public String toString() { return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("email", this.email) - .append("id", this.id) - .append("name", this.name) - .append("tags", this.tags) - .toString(); - } + .append("id", this.id).append("name", this.name).append("tags", this.tags).toString(); + } } \ No newline at end of file From 6835c96fd11b84ac6532335ab3fef5c91a18306f Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 17:16:42 +0200 Subject: [PATCH 14/25] Improving runner of performance-measurement --- performance-test/scripts/generate_classes.py | 4 -- .../scripts/run_full_comparison.py | 39 +++++++++++--- .../scripts/run_performance_measurement.py | 53 +++++++++++++++---- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/performance-test/scripts/generate_classes.py b/performance-test/scripts/generate_classes.py index c49fa3f2..9fd2812d 100755 --- a/performance-test/scripts/generate_classes.py +++ b/performance-test/scripts/generate_classes.py @@ -944,7 +944,6 @@ def main(argv: list[str] | None = None) -> int: errors.append(msg) print(f" ERROR: {msg}", file=sys.stderr) continue - print(f" wrote: {out_file}") written += 1 # --- Generate base classes --- @@ -975,7 +974,6 @@ def main(argv: list[str] | None = None) -> int: else: try: atomic_write(out_file, source) - print(f" wrote: {out_file}") written += 1 except OSError as e: msg = f"failed to write {out_file}: {e}" @@ -1015,7 +1013,6 @@ def main(argv: list[str] | None = None) -> int: errors.append(msg) print(f" ERROR: {msg}", file=sys.stderr) continue - print(f" wrote: {out_file}") written += 1 # --- Generate DTO classes/records --- @@ -1103,7 +1100,6 @@ def main(argv: list[str] | None = None) -> int: print(f" ERROR: {msg}", file=sys.stderr) continue - print(f" wrote: {out_file}") written += 1 print() diff --git a/performance-test/scripts/run_full_comparison.py b/performance-test/scripts/run_full_comparison.py index 0e31e45e..daaea2fb 100755 --- a/performance-test/scripts/run_full_comparison.py +++ b/performance-test/scripts/run_full_comparison.py @@ -87,11 +87,20 @@ def main() -> None: help="Copy generated builders to generated-builders// " "so they survive Maven clean", ) + parser.add_argument( + "--no-tracking", + action="store_true", + help="Disable JSON performance tracking for simple-builders types. " + "All frameworks are measured with wall-time/compiler-time only, " + "avoiding the overhead of the processor's internal performance tracker. " + "This ensures a fair comparison without measurement overhead.", + ) args = parser.parse_args() num_runs = args.runs keep_builders = args.keep_builders summaries: list[str] = [] + failures: list[str] = [] for bt in BUILDER_TYPES: label = f"{LABEL_PREFIX[bt]}-{num_runs}runs" @@ -103,19 +112,24 @@ def main() -> None: "--builder-type", bt, "--force", ]) if rc != 0: - print(f"ERROR: generate_classes.py failed for {bt}") - sys.exit(1) + print(f"ERROR: generate_classes.py failed for {bt} - skipping") + failures.append(f"{bt}: generate_classes.py failed") + continue # 2. Run measurements - rc = run([ + measure_cmd = [ sys.executable, str(SCRIPT_DIR / "run_performance_measurement.py"), "--runs", str(num_runs), "--label", label, "--builder-type", bt, - ]) + ] + if args.no_tracking: + measure_cmd.append("--no-tracking") + rc = run(measure_cmd) if rc != 0: - print(f"ERROR: run_performance_measurement.py failed for {bt}") - sys.exit(1) + print(f"ERROR: run_performance_measurement.py failed for {bt} - skipping") + failures.append(f"{bt}: run_performance_measurement.py failed") + continue # 3. Copy generated builders to safe location if keep_builders: @@ -134,7 +148,13 @@ def main() -> None: summaries.append(f"{label}/summary.json") - # 4. Compare all + # 4. Compare all successful results + if not summaries: + print("ERROR: No successful measurements to compare.") + for f in failures: + print(f" - {f}") + sys.exit(1) + print_section("Comparison") compare_cmd = [sys.executable, str(SCRIPT_DIR / "compare_performance.py")] + summaries @@ -146,6 +166,11 @@ def main() -> None: print() print("Full comparison complete.") print(f"Reports: {BASE_DIR / 'performance-reports'}") + if failures: + print() + print(f"WARNING: {len(failures)} builder type(s) were skipped due to errors:") + for f in failures: + print(f" - {f}") if __name__ == "__main__": diff --git a/performance-test/scripts/run_performance_measurement.py b/performance-test/scripts/run_performance_measurement.py index d5ee33bf..a691302b 100644 --- a/performance-test/scripts/run_performance_measurement.py +++ b/performance-test/scripts/run_performance_measurement.py @@ -135,7 +135,11 @@ def parse_compiler_time(output: str) -> Optional[float]: def run_one(run_index: int, profile: str, is_simple_builders: bool, report_dir: Path, builder_type: str = "") -> Optional[dict]: - """Run a single clean compile and return the parsed JSON report (or wall-time-only dict).""" + """Run a single clean compile and return the parsed JSON report (or wall-time-only dict). + + The report file uses the run_index in its name so that retries overwrite the failed + attempt's file rather than accumulating stale files. + """ report_file = report_dir / f"run-{run_index:02d}.json" source_count = count_source_files() @@ -412,6 +416,22 @@ def main() -> None: "Options: " + ", ".join(BUILDER_TYPE_TO_PROFILE.keys()) + " (default: simple-builder).", ) + parser.add_argument( + "--max-retries", + type=int, + default=3, + help="Maximum retries per run before giving up (default: 3). " + "A run that fails (non-zero exit, missing/invalid JSON) is retried " + "up to this many times before being skipped.", + ) + parser.add_argument( + "--no-tracking", + action="store_true", + help="Disable JSON performance tracking for simple-builders types. " + "Runs are measured with wall-time and compiler-time only (like " + "lombok/record-builder), avoiding the overhead of the processor's " + "internal performance tracker.", + ) args = parser.parse_args() num_runs = args.runs @@ -419,6 +439,9 @@ def main() -> None: builder_type = args.builder_type profile = BUILDER_TYPE_TO_PROFILE[builder_type] is_simple_builders = builder_type in SIMPLE_BUILDERS_TYPES + # When --no-tracking is set, disable JSON performance tracking for simple-builders + # types so they are measured with wall-time/compiler-time only (like lombok/record-builder). + use_tracking = is_simple_builders and not args.no_tracking report_dir = BASE_DIR / "performance-reports" / run_label if report_dir.exists(): @@ -427,21 +450,33 @@ def main() -> None: print(f"Running {num_runs} performance measurement runs...") print(f"Builder type: {builder_type}") - print(f"JSON reports: {'yes' if is_simple_builders else 'no (wall-time only)'}") + print(f"JSON reports: {'yes' if use_tracking else 'no (wall-time only)'}") print(f"Report directory: {report_dir}") print() + max_retries = args.max_retries runs: list[dict] = [] + total_attempts = 0 for i in range(1, num_runs + 1): - run_start = time.time() - data = run_one(i, profile, is_simple_builders, report_dir, builder_type) - if data is not None: - if "_wallTimeSeconds" not in data: - data["_wallTimeSeconds"] = time.time() - run_start - runs.append(data) + data = None + attempt = 0 + while data is None and attempt <= max_retries: + attempt += 1 + total_attempts += 1 + if attempt > 1: + print(f" Run {i}: retry {attempt - 1}/{max_retries}...", flush=True) + run_start = time.time() + data = run_one(i, profile, use_tracking, report_dir, builder_type) + if data is not None: + if "_wallTimeSeconds" not in data: + data["_wallTimeSeconds"] = time.time() - run_start + runs.append(data) + elif attempt > max_retries: + print(f" Run {i}: giving up after {max_retries} retries", flush=True) print() - print(f"Successful runs: {len(runs)}/{num_runs}") + print(f"Successful runs: {len(runs)}/{num_runs}" + f" ({total_attempts - num_runs} retries used)") if not runs: print("No successful runs to aggregate.") From 1f06ce300eaa37593c8954de28d94fa73fa00fcd Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 17:25:45 +0200 Subject: [PATCH 15/25] Further Changes on Example data --- .../javahelpers/simple/builders/example/CustomerDtoBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java index cd86f936..307a9585 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/CustomerDtoBuilder.java @@ -75,5 +75,5 @@ public CustomerDto build() { public String toString() { return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("email", this.email) .append("id", this.id).append("name", this.name).append("tags", this.tags).toString(); - } + } } \ No newline at end of file From 1aee672abc79a6c256261c57ac1d62772a8f017a Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 17:32:32 +0200 Subject: [PATCH 16/25] Improving formatter --- .../roaster/RoasterSourceFormatter.java | 153 ++++++++++++++++-- .../roaster/RoasterSourceFormatterTest.java | 112 +++++++++++++ 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java index 276b6ff7..705822ab 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java @@ -130,28 +130,151 @@ String lightweightFormat(String source) { // 1. Convert leading tabs to spaces String converted = convertTabsToSpaces(line); - // 2. Split import/code concatenated with /** - if (!javadocState.inJavadoc) { - converted = splitConcatenatedJavadocOpen(converted, output, javadocState, prevBlank); + // 2. Split concatenated lines (import;class, }}) + for (String splitLine : splitConcatenatedLines(converted)) { + processLine(splitLine, output, javadocState, prevBlank); + prevBlank = splitLine.isBlank(); } + } + return output.toString(); + } + + private void processLine( + String converted, StringBuilder output, JavadocState javadocState, boolean prevBlank) { + // Split import/code concatenated with /** + if (!javadocState.inJavadoc) { + converted = splitConcatenatedJavadocOpen(converted, output, javadocState, prevBlank); + } + + // Fix javadoc asterisk prefixes and indentation + if (javadocState.inJavadoc) { + converted = fixJavadocLine(converted, javadocState); + } + + // Collapse consecutive blank lines and append + boolean isBlank = converted.isBlank(); + if (isBlank && prevBlank) { + return; + } + if (output.length() > 0) { + output.append('\n'); + } + output.append(converted); + } + + /** + * Splits a single line that Roaster concatenated without newlines. + * + *

Roaster's {@code toUnformattedString()} sometimes glues together: + * + *

    + *
  • The last import and the class declaration: {@code "import x.Y;public class Foo {"} + *
  • Closing braces at end of file: {@code " } }"} + *
+ * + *

This method splits such lines at: + * + *

    + *
  1. Semicolon followed by a Java declaration keyword ({@code public}, {@code private}, {@code + * protected}, {@code class}, {@code interface}, {@code enum}, {@code record}, {@code + * abstract}, {@code final}, {@code import}, {@code package}) + *
  2. A closing brace ({@code }}) followed by another closing brace (with optional whitespace) + *
+ * + * @param line the potentially concatenated line + * @return a list of split lines (or a singleton list if no splitting was needed) + */ + private java.util.List splitConcatenatedLines(String line) { + // Fast path: no semicolons or closing braces, nothing to split + if (!line.contains(";") && !line.contains("}")) { + return java.util.List.of(line); + } - // 3. Fix javadoc asterisk prefixes and indentation - if (javadocState.inJavadoc) { - converted = fixJavadocLine(converted, javadocState); + java.util.List result = new java.util.ArrayList<>(); + String remaining = line; + + while (true) { + int splitPos = findSplitPosition(remaining); + if (splitPos < 0) { + result.add(remaining); + break; } + result.add(remaining.substring(0, splitPos).stripTrailing()); + remaining = remaining.substring(splitPos).strip(); + } + return result; + } - // 4. Collapse consecutive blank lines and append - boolean isBlank = converted.isBlank(); - if (isBlank && prevBlank) { - continue; + /** + * Finds the position at which a line should be split for the next concatenated segment. + * + * @return the start index of the next segment, or -1 if no split is needed + */ + private int findSplitPosition(String line) { + int bestPos = -1; + + // Pattern 1: semicolon followed by a declaration keyword + int semiIdx = 0; + while ((semiIdx = line.indexOf(';', semiIdx)) >= 0) { + int afterSemi = semiIdx + 1; + // Skip whitespace after semicolon + while (afterSemi < line.length() && Character.isWhitespace(line.charAt(afterSemi))) { + afterSemi++; } - if (output.length() > 0) { - output.append('\n'); + if (matchesDeclarationKeyword(line, afterSemi)) { + bestPos = afterSemi; + break; } - output.append(converted); - prevBlank = isBlank; + semiIdx++; } - return output.toString(); + + // Pattern 2: closing brace followed by closing brace (with optional whitespace) + if (bestPos < 0) { + int braceIdx = 0; + while ((braceIdx = line.indexOf('}', braceIdx)) >= 0) { + int afterBrace = braceIdx + 1; + while (afterBrace < line.length() && Character.isWhitespace(line.charAt(afterBrace))) { + afterBrace++; + } + if (afterBrace < line.length() && line.charAt(afterBrace) == '}') { + bestPos = afterBrace; + break; + } + braceIdx++; + } + } + + return bestPos; + } + + /** Checks whether the text at the given position starts with a Java declaration keyword. */ + private boolean matchesDeclarationKeyword(String text, int pos) { + if (pos >= text.length()) { + return false; + } + String[] keywords = { + "public", + "private", + "protected", + "class", + "interface", + "enum", + "record", + "abstract", + "final", + "import", + "package" + }; + for (String kw : keywords) { + if (text.startsWith(kw, pos)) { + int endPos = pos + kw.length(); + // Ensure the keyword is a complete word (followed by whitespace or other non-word char) + if (endPos >= text.length() || !Character.isJavaIdentifierPart(text.charAt(endPos))) { + return true; + } + } + } + return false; } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index b5dc9048..e1ebd7f4 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -474,6 +474,118 @@ public class Foo { // === format() dispatch tests === + @Test + void lightweightFormat_splitsConcatenatedImportAndClassDeclaration() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + import java.util.List;public class Foo { + private List items; + } + """; + String result = formatter.lightweightFormat(input); + String[] lines = result.split("\n", -1); + int importIdx = -1; + int classIdx = -1; + for (int i = 0; i < lines.length; i++) { + if (lines[i].equals("import java.util.List;")) { + importIdx = i; + } + if (lines[i].equals("public class Foo {")) { + classIdx = i; + } + } + assertTrue(importIdx >= 0, "Import should be on its own line"); + assertTrue(classIdx >= 0, "Class declaration should be on its own line"); + assertEquals( + importIdx + 1, + classIdx, + "Class declaration should be on the line immediately after the import"); + } + + @Test + void lightweightFormat_splitsConcatenatedClosingBraces() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + public class Foo { + public void bar() { + return; + } }"""; + String result = formatter.lightweightFormat(input); + String[] lines = result.split("\n", -1); + int methodCloseIdx = -1; + int classCloseIdx = -1; + for (int i = 0; i < lines.length; i++) { + if (lines[i].equals(" }")) { + methodCloseIdx = i; + } + if (lines[i].equals("}")) { + classCloseIdx = i; + } + } + assertTrue(methodCloseIdx >= 0, "Method closing brace should be on its own line"); + assertTrue(classCloseIdx >= 0, "Class closing brace should be on its own line"); + assertEquals( + methodCloseIdx + 1, + classCloseIdx, + "Class closing brace should be on the line immediately after method closing brace"); + } + + @Test + void lightweightFormat_splitsConcatenatedPackageAndImport() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test;import java.util.List; + public class Foo { + } + """; + String result = formatter.lightweightFormat(input); + String[] lines = result.split("\n", -1); + int pkgIdx = -1; + int importIdx = -1; + for (int i = 0; i < lines.length; i++) { + if (lines[i].equals("package test;")) { + pkgIdx = i; + } + if (lines[i].equals("import java.util.List;")) { + importIdx = i; + } + } + assertTrue(pkgIdx >= 0, "Package declaration should be on its own line"); + assertTrue(importIdx >= 0, "Import should be on its own line"); + assertEquals( + pkgIdx + 1, + importIdx, + "Import should be on the line immediately after package declaration"); + } + + @Test + void lightweightFormat_splitsTripleConcatenatedClosingBraces() { + RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + String input = + """ + package test; + public class Foo { + public void bar() { + if (true) { + return; + } } }"""; + String result = formatter.lightweightFormat(input); + // After splitting, each brace ends up on its own line. The first brace + // retains its original indentation; subsequent braces are stripped to bare "}". + String[] lines = result.split("\n", -1); + assertTrue(lines.length >= 3, "Should have at least 3 lines for 3 closing braces"); + assertTrue( + lines[lines.length - 3].endsWith("}"), "Third-to-last line should end with closing brace"); + assertEquals( + "}", lines[lines.length - 2], "Second-to-last line should be a bare closing brace"); + assertEquals("}", lines[lines.length - 1], "Last line should be a bare closing brace"); + } + @Test void lightweightFormat_splitsConcatenatedImportAndJavadocWithPrecedingLine() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); From 47f489ca61542a1f51659dac783d3e89b805f716 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 17:47:36 +0200 Subject: [PATCH 17/25] Fixing test expectation --- .../simple/builders/processor/FormattingModeTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java index 4e7d546c..a90b0ad1 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java @@ -354,7 +354,8 @@ default FormatTestDtoBuilder with() { ex); } } - } }"""; + } + }"""; assertEquals( expectedCode, generatedCode, From 41878567053c562d3393ebbf5cf6fc40b62cd68f Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 18:13:25 +0200 Subject: [PATCH 18/25] Fixing sonarqube findings --- .../roaster/RoasterSourceFormatter.java | 62 +++++++++++-------- .../roaster/RoasterSourceFormatterTest.java | 54 +++++++++------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java index 705822ab..084b22e8 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java @@ -156,7 +156,7 @@ private void processLine( if (isBlank && prevBlank) { return; } - if (output.length() > 0) { + if (!output.isEmpty()) { output.append('\n'); } output.append(converted); @@ -211,40 +211,52 @@ private java.util.List splitConcatenatedLines(String line) { * @return the start index of the next segment, or -1 if no split is needed */ private int findSplitPosition(String line) { - int bestPos = -1; + int pos = findSemicolonKeywordSplit(line); + if (pos >= 0) { + return pos; + } + return findClosingBraceSplit(line); + } - // Pattern 1: semicolon followed by a declaration keyword + /** + * Finds a semicolon followed by a Java declaration keyword and returns the position of the + * keyword. + */ + private int findSemicolonKeywordSplit(String line) { int semiIdx = 0; while ((semiIdx = line.indexOf(';', semiIdx)) >= 0) { - int afterSemi = semiIdx + 1; - // Skip whitespace after semicolon - while (afterSemi < line.length() && Character.isWhitespace(line.charAt(afterSemi))) { - afterSemi++; - } + int afterSemi = skipWhitespace(line, semiIdx + 1); if (matchesDeclarationKeyword(line, afterSemi)) { - bestPos = afterSemi; - break; + return afterSemi; } semiIdx++; } + return -1; + } - // Pattern 2: closing brace followed by closing brace (with optional whitespace) - if (bestPos < 0) { - int braceIdx = 0; - while ((braceIdx = line.indexOf('}', braceIdx)) >= 0) { - int afterBrace = braceIdx + 1; - while (afterBrace < line.length() && Character.isWhitespace(line.charAt(afterBrace))) { - afterBrace++; - } - if (afterBrace < line.length() && line.charAt(afterBrace) == '}') { - bestPos = afterBrace; - break; - } - braceIdx++; + /** + * Finds a closing brace followed by another closing brace (with optional whitespace between) and + * returns the position of the second brace. + */ + private int findClosingBraceSplit(String line) { + int braceIdx = 0; + while ((braceIdx = line.indexOf('}', braceIdx)) >= 0) { + int afterBrace = skipWhitespace(line, braceIdx + 1); + if (afterBrace < line.length() && line.charAt(afterBrace) == '}') { + return afterBrace; } + braceIdx++; } + return -1; + } - return bestPos; + /** Skips whitespace starting at the given index and returns the first non-whitespace position. */ + private int skipWhitespace(String line, int start) { + int pos = start; + while (pos < line.length() && Character.isWhitespace(line.charAt(pos))) { + pos++; + } + return pos; } /** Checks whether the text at the given position starts with a Java declaration keyword. */ @@ -300,7 +312,7 @@ private String splitConcatenatedJavadocOpen( if (!before.isEmpty()) { boolean beforeBlank = before.isBlank(); if (!(beforeBlank && prevBlank)) { - if (output.length() > 0) { + if (!output.isEmpty()) { output.append('\n'); } output.append(before); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index e1ebd7f4..c980bf3d 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -46,6 +46,8 @@ import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; /** * Unit tests for {@link RoasterSourceFormatter}. @@ -155,9 +157,8 @@ void constructor_nullLogger_throwsNullPointerException() { @Test void constructor_nullFormattingMode_throwsNullPointerException() { - assertThrows( - NullPointerException.class, - () -> new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), null)); + ProcessingLogger logger = new ProcessingLogger(createProcessingEnv()); + assertThrows(NullPointerException.class, () -> new RoasterSourceFormatter(logger, null)); } // === NONE mode tests === @@ -247,26 +248,33 @@ public class Foo { "Import and javadoc opening should be split into separate lines"); } - @Test - void lightweightFormat_addsJavadocAsteriskPrefixes() { + @ParameterizedTest + @CsvSource({ + "This is a javadoc body line.,Another body line.", + "Single line.,", + }) + void lightweightFormat_addsJavadocAsteriskPrefixes(String bodyLine1, String bodyLine2) { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); + boolean hasSecondLine = bodyLine2 != null && !bodyLine2.isEmpty(); + String javadocBody = hasSecondLine ? bodyLine1 + "\n" + bodyLine2 : bodyLine1; String input = """ package test; /** - This is a javadoc body line. - Another body line. + %s */ public class Foo { } - """; - String result = formatter.lightweightFormat(input); - String expected = """ - * This is a javadoc body line. - * Another body line.""" - .indent(1); - assertTrue(result.contains(expected), "Javadoc body lines should get ' * ' prefix"); + .formatted(javadocBody); + String result = formatter.lightweightFormat(input); + String expectedLine1 = " * " + bodyLine1; + assertTrue(result.contains(expectedLine1), "First javadoc body line should get ' * ' prefix"); + if (hasSecondLine) { + String expectedLine2 = " * " + bodyLine2; + assertTrue( + result.contains(expectedLine2), "Second javadoc body line should get ' * ' prefix"); + } } @Test @@ -293,20 +301,24 @@ public class Foo { assertTrue(result.contains(expected), "Blank javadoc lines should get ' *' prefix"); } - @Test - void lightweightFormat_handlesInlineJavadocClose() { + @ParameterizedTest + @CsvSource({ + "/** This is a one-line javadoc. */", + "/** Short. */", + "/** Multi word inline javadoc with several words. */", + }) + void lightweightFormat_handlesInlineJavadocClose(String inlineJavadoc) { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); String input = """ package test; - /** This is a one-line javadoc. */ + %s public class Foo { } - """; + """ + .formatted(inlineJavadoc); String result = formatter.lightweightFormat(input); - assertTrue( - result.contains("/** This is a one-line javadoc. */"), - "Inline javadoc (/** ... */) should be preserved as-is"); + assertTrue(result.contains(inlineJavadoc), "Inline javadoc should be preserved as-is"); } @Test From e3cbbce83e38d849518feba15880f71898d99ac5 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Sun, 30 Aug 2026 19:24:47 +0200 Subject: [PATCH 19/25] Improving tests by replacing 3 individual javadoc tests by a paramerized test --- .../roaster/RoasterSourceFormatterTest.java | 93 +++++++++---------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index c980bf3d..9bdf7368 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -33,6 +33,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Stream; import javax.annotation.processing.Filer; import javax.annotation.processing.Messager; import javax.annotation.processing.ProcessingEnvironment; @@ -47,7 +48,9 @@ import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; /** * Unit tests for {@link RoasterSourceFormatter}. @@ -362,22 +365,49 @@ public void bar()""" result.contains(expectedClose), "Closing javadoc should align with the enclosing member"); } - @Test - void lightweightFormat_preservesAlreadyPrefixedJavadocLines() { + @ParameterizedTest(name = "{2}") + @MethodSource("javadocPrefixCases") + void lightweightFormat_preservesOrAddsJavadocPrefixes( + String input, String expected, String description) { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - * Already has prefix. - */ - public class Foo { - } - """; String result = formatter.lightweightFormat(input); - assertTrue( - result.contains(" * Already has prefix."), - "Lines that already have ' * ' prefix should be preserved"); + assertTrue(result.contains(expected), "Case: " + description); + } + + static Stream javadocPrefixCases() { + return Stream.of( + Arguments.of( + """ + package test; + /** + * Already has prefix. + */ + public class Foo { + } + """, + " * Already has prefix.", + "already-prefixed lines keep ' * ' prefix"), + Arguments.of( + """ + package test; + /** + *Body line without space. + */ + public class Foo { + } + """, + "*Body line without space.", + "star-only prefix without space is preserved"), + Arguments.of( + """ + package test; + /** + Some description text */ + public class Foo { + } + """, + " * Some description text */", + "line ending with */ gets ' * ' prefix")); } @Test @@ -449,41 +479,6 @@ public class Foo { assertTrue(!result.contains("\t"), "No tabs should remain in output"); } - @Test - void lightweightFormat_preservesStarPrefixWithoutSpace() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - *Body line without space. - */ - public class Foo { - } - """; - String result = formatter.lightweightFormat(input); - assertTrue( - result.contains("*Body line without space."), - "Lines with '*' prefix but no space should be preserved as-is (already have asterisk)"); - } - - @Test - void lightweightFormat_fixesJavadocLineEndingWithStarSlash() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - Some description text */ - public class Foo { - } - """; - String result = formatter.lightweightFormat(input); - assertTrue( - result.contains(" * Some description text */"), - "Javadoc line ending with */ but not starting with * should get ' * ' prefix"); - } - // === format() dispatch tests === @Test From a4b14f73ee158fd0197e5f0569ecec123ffa9dc0 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 21:19:53 +0200 Subject: [PATCH 20/25] Removing marker inside test-classes --- .../simple/builders/processor/DefaultValueTest.java | 6 ------ .../classgen/roaster/RoasterSourceFormatterTest.java | 4 ---- 2 files changed, 10 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java index b5094798..658a4dd4 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/DefaultValueTest.java @@ -406,8 +406,6 @@ public OrderDto build() { """); } - // === Default + non-null interaction === - /** * Verifies the interaction between {@code @NotNull} and {@code @Default}: * @@ -470,8 +468,6 @@ public RequiredRecord build() { """); } - // === Framework-agnostic detection === - /** * Verifies that the processor detects third-party annotations named {@code @DefaultValue} (e.g., * Jakarta REST {@code jakarta.ws.rs.DefaultValue}) by simple name matching, not just our own @@ -520,8 +516,6 @@ public JakartaRecord build() { """); } - // === formatDefaultExpression unit tests === - private static Stream formatDefaultExpressionCases() { TypeName enumType = new TypeName("test", "Status"); enumType.setEnumType(true); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index 9bdf7368..32c46009 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -150,8 +150,6 @@ private RoasterSourceFormatter createFormatter(FormattingMode mode) { return new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), mode); } - // === Constructor tests === - @Test void constructor_nullLogger_throwsNullPointerException() { assertThrows( @@ -164,8 +162,6 @@ void constructor_nullFormattingMode_throwsNullPointerException() { assertThrows(NullPointerException.class, () -> new RoasterSourceFormatter(logger, null)); } - // === NONE mode tests === - @Test void format_noneMode_returnsRawSourceUnchanged() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.NONE); From 8dc2bbb8badd55c0f703996c7e1e3659c2f1de0b Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 21:29:32 +0200 Subject: [PATCH 21/25] Improving RoasterSourceFormatterTest --- .../roaster/RoasterSourceFormatterTest.java | 420 +++++++++--------- 1 file changed, 200 insertions(+), 220 deletions(-) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java index 32c46009..c129ebfc 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java @@ -24,8 +24,8 @@ package org.javahelpers.simple.builders.processor.classgen.roaster; 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; @@ -49,7 +49,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; /** @@ -62,7 +61,6 @@ * fixup, concatenated import/javadoc splitting *
  • {@code format()} dispatch logic for NONE, LIGHTWEIGHT, and JDT modes *
  • Fallback behavior when JDT formatter profile is unavailable - *
  • Constructor null checks * */ class RoasterSourceFormatterTest { @@ -150,32 +148,19 @@ private RoasterSourceFormatter createFormatter(FormattingMode mode) { return new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), mode); } - @Test - void constructor_nullLogger_throwsNullPointerException() { - assertThrows( - NullPointerException.class, () -> new RoasterSourceFormatter(null, FormattingMode.JDT)); - } - - @Test - void constructor_nullFormattingMode_throwsNullPointerException() { - ProcessingLogger logger = new ProcessingLogger(createProcessingEnv()); - assertThrows(NullPointerException.class, () -> new RoasterSourceFormatter(logger, null)); - } - @Test void format_noneMode_returnsRawSourceUnchanged() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.NONE); + // Input is intentionally misformatted so that any active formatter would change it; + // NONE mode must return it verbatim. String raw = """ - package test; - public class Foo { - } - """; + package test; + public class Foo { + }"""; assertEquals(raw, formatter.format(raw)); } - // === LIGHTWEIGHT mode tests === - @Test void lightweightFormat_convertsTabsToSpaces() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); @@ -189,9 +174,17 @@ public class Foo { } """; String result = formatter.lightweightFormat(input); - assertTrue(result.contains(" public void bar()"), "Tabs should be converted to 2 spaces"); - assertTrue(result.contains(" return;"), "Nested tabs should be converted to 4 spaces"); - assertTrue(!result.contains("\t"), "No tabs should remain in output"); + assertFalse(result.contains("\t"), "No tabs should remain in output"); + String expected = + """ + package test; + public class Foo { + public void bar() { + return; + } + } + """; + assertEquals(expected, result); } @Test @@ -226,152 +219,39 @@ public class Foo { assertEquals(1, blankCount, "Single blank line should be preserved"); } - @Test - void lightweightFormat_splitsConcatenatedImportAndJavadoc() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test;import java.util.List;/** - * This is a javadoc. - */ - public class Foo { - } - """; - String result = formatter.lightweightFormat(input); - String expected = - """ - import java.util.List; - /**"""; - assertTrue( - result.contains(expected), - "Import and javadoc opening should be split into separate lines"); - } - - @ParameterizedTest - @CsvSource({ - "This is a javadoc body line.,Another body line.", - "Single line.,", - }) - void lightweightFormat_addsJavadocAsteriskPrefixes(String bodyLine1, String bodyLine2) { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - boolean hasSecondLine = bodyLine2 != null && !bodyLine2.isEmpty(); - String javadocBody = hasSecondLine ? bodyLine1 + "\n" + bodyLine2 : bodyLine1; - String input = - """ - package test; - /** - %s - */ - public class Foo { - } - """ - .formatted(javadocBody); - String result = formatter.lightweightFormat(input); - String expectedLine1 = " * " + bodyLine1; - assertTrue(result.contains(expectedLine1), "First javadoc body line should get ' * ' prefix"); - if (hasSecondLine) { - String expectedLine2 = " * " + bodyLine2; - assertTrue( - result.contains(expectedLine2), "Second javadoc body line should get ' * ' prefix"); - } - } - - @Test - void lightweightFormat_normalizesBlankJavadocLines() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - First line. - - Second line. - */ - public class Foo { - } - """; - String result = formatter.lightweightFormat(input); - String expected = - """ - * First line. - * - * Second line.""" - .indent(1); - assertTrue(result.contains(expected), "Blank javadoc lines should get ' *' prefix"); - } - - @ParameterizedTest - @CsvSource({ - "/** This is a one-line javadoc. */", - "/** Short. */", - "/** Multi word inline javadoc with several words. */", - }) - void lightweightFormat_handlesInlineJavadocClose(String inlineJavadoc) { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - %s - public class Foo { - } - """ - .formatted(inlineJavadoc); - String result = formatter.lightweightFormat(input); - assertTrue(result.contains(inlineJavadoc), "Inline javadoc should be preserved as-is"); - } - - @Test - void lightweightFormat_handlesEmptyInput() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String result = formatter.lightweightFormat(""); - assertEquals("", result, "Empty input should produce empty output"); - } - - @Test - void lightweightFormat_handlesJavadocWithIndentation() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - public class Foo { - /** - Body line. - */ - public void bar() { - } - } - """; - String result = formatter.lightweightFormat(input); - String expectedJavadoc = - """ - /** - * Body line.""" - .indent(2) - .stripTrailing(); - String expectedClose = - """ - */ - public void bar()""" - .indent(2) - .stripTrailing(); - assertTrue( - result.contains(expectedJavadoc), - "Javadoc body lines should be indented to match the enclosing member"); - assertTrue( - result.contains(expectedClose), "Closing javadoc should align with the enclosing member"); - } - @ParameterizedTest(name = "{2}") - @MethodSource("javadocPrefixCases") - void lightweightFormat_preservesOrAddsJavadocPrefixes( - String input, String expected, String description) { + @MethodSource("javadocFormattingCases") + void lightweightFormat_javadocFormatting(String input, String expected, String description) { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); String result = formatter.lightweightFormat(input); - assertTrue(result.contains(expected), "Case: " + description); + assertEquals(expected, result.strip(), "Case: " + description); } - static Stream javadocPrefixCases() { + static Stream javadocFormattingCases() { return Stream.of( + // Multi-line javadoc: body lines get ' * ' prefix, blank lines get ' *' + Arguments.of( + """ + package test; + /** + First line. + + Second line. + */ + public class Foo { + } + """, + """ + package test; + /** + * First line. + * + * Second line. + */ + public class Foo { + }""", + "multi-line javadoc with blank line gets ' * ' prefixes"), + // Already-prefixed lines are preserved Arguments.of( """ package test; @@ -381,8 +261,15 @@ static Stream javadocPrefixCases() { public class Foo { } """, - " * Already has prefix.", + """ + package test; + /** + * Already has prefix. + */ + public class Foo { + }""", "already-prefixed lines keep ' * ' prefix"), + // Star-only prefix (no space) is preserved as-is Arguments.of( """ package test; @@ -392,8 +279,15 @@ public class Foo { public class Foo { } """, - "*Body line without space.", + """ + package test; + /** + *Body line without space. + */ + public class Foo { + }""", "star-only prefix without space is preserved"), + // Line ending with */ gets ' * ' prefix Arguments.of( """ package test; @@ -402,58 +296,150 @@ public class Foo { public class Foo { } """, - " * Some description text */", - "line ending with */ gets ' * ' prefix")); - } - - @Test - void lightweightFormat_handlesMultipleJavadocBlocks() { - RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - Class-level javadoc. - */ - public class Foo { - /** - Method javadoc. - */ - public void bar() { - } - } - """; - String result = formatter.lightweightFormat(input); - assertTrue( - result.contains(" * Class-level javadoc."), - "First javadoc block body should get ' * ' prefix"); - assertTrue( - result.contains(" * Method javadoc."), - "Second javadoc block body should get ' * ' prefix with correct indentation"); + """ + package test; + /** + * Some description text */ + public class Foo { + }""", + "line ending with */ gets ' * ' prefix"), + // Inline one-line javadoc is preserved as-is + Arguments.of( + """ + package test; + /** This is a one-line javadoc. */ + public class Foo { + } + """, + """ + package test; + /** This is a one-line javadoc. */ + public class Foo { + }""", + "inline one-line javadoc is preserved"), + Arguments.of( + """ + package test; + /** Short. */ + public class Foo { + } + """, + """ + package test; + /** Short. */ + public class Foo { + }""", + "short inline javadoc is preserved"), + Arguments.of( + """ + package test; + /** Multi word inline javadoc with several words. */ + public class Foo { + } + """, + """ + package test; + /** Multi word inline javadoc with several words. */ + public class Foo { + }""", + "multi-word inline javadoc is preserved"), + // Javadoc tag lines get ' * ' prefix + Arguments.of( + """ + package test; + /** + Description. + @param value the value + @return the result + */ + public class Foo { + } + """, + """ + package test; + /** + * Description. + * @param value the value + * @return the result + */ + public class Foo { + }""", + "javadoc tag lines get ' * ' prefix"), + // Multiple javadoc blocks at different indentation levels + Arguments.of( + """ + package test; + /** + Class-level javadoc. + */ + public class Foo { + /** + Method javadoc. + */ + public void bar() { + } + } + """, + """ + package test; + /** + * Class-level javadoc. + */ + public class Foo { + /** + * Method javadoc. + */ + public void bar() { + } + }""", + "multiple javadoc blocks at different indentation levels"), + // Javadoc inside a method gets indented to match enclosing member + Arguments.of( + """ + package test; + public class Foo { + /** + Body line. + */ + public void bar() { + } + } + """, + """ + package test; + public class Foo { + /** + * Body line. + */ + public void bar() { + } + }""", + "javadoc body lines indented to match enclosing member"), + // Concatenated import and javadoc are split into separate lines + Arguments.of( + """ + package test;import java.util.List;/** + * This is a javadoc. + */ + public class Foo { + } + """, + """ + package test; + import java.util.List; + /** + * This is a javadoc. + */ + public class Foo { + }""", + "concatenated import and javadoc are split")); } @Test - void lightweightFormat_preservesJavadocTagLines() { + void lightweightFormat_handlesEmptyInput() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); - String input = - """ - package test; - /** - Description. - @param value the value - @return the result - */ - public class Foo { - } - """; - String result = formatter.lightweightFormat(input); - String expected = - """ - * @param value the value - * @return the result""" - .indent(1); - assertTrue( - result.contains(expected), "Javadoc @param and @return tags should get ' * ' prefix"); + String result = formatter.lightweightFormat(""); + assertEquals("", result, "Empty input should produce empty output"); } @Test @@ -475,8 +461,6 @@ public class Foo { assertTrue(!result.contains("\t"), "No tabs should remain in output"); } - // === format() dispatch tests === - @Test void lightweightFormat_splitsConcatenatedImportAndClassDeclaration() { RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT); @@ -637,8 +621,6 @@ void format_noneMode_doesNotConvertTabs() { assertTrue(result.contains("\t"), "NONE mode should preserve tabs"); } - // === JDT mode tests === - @Test void format_jdtMode_withProfile_producesFormattedOutput() { TestProcessingEnv env = createProcessingEnv(); @@ -657,8 +639,6 @@ void format_jdtMode_withProfile_producesFormattedOutput() { "No fallback warning should be logged when formatter profile is available on classpath"); } - // === Error path tests (missing/malformed formatter profile) === - @Test void constructor_jdtMode_missingProfile_logsFallbackWarning() { TestProcessingEnv env = createProcessingEnv(); From 825449385a6bb9bf6e931ffe79d47b0b305195f0 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 21:29:59 +0200 Subject: [PATCH 22/25] Fixing processing of formattingMode configuration to be handled like other configurations --- .../builders/processor/BuilderProcessor.java | 10 +++++++--- .../model/core/BuilderToGenerationTypeMapper.java | 13 +++++-------- .../processing/CompilerArgumentsReader.java | 15 +-------------- .../processor/processing/ProcessingContext.java | 15 --------------- 4 files changed, 13 insertions(+), 40 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 095d492b..c7d8e0e5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -48,6 +48,7 @@ import javax.lang.model.element.TypeElement; import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -130,7 +131,11 @@ public boolean process(Set annotations, RoundEnvironment jacksonModuleGenerator.getModuleDefinitions(); for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) { String packageName = moduleClassDef.getTypeName().getPackageName(); - moduleClassDef.setFormattingMode(context.getFormattingMode()); + moduleClassDef.setFormattingMode( + context + .getConfigurationReader() + .getGlobalConfiguration() + .resolveFormattingMode(FormattingMode.JDT)); context.info("Generating Jackson Module in package '%s'", packageName); try { codeGenerator.generateClass(moduleClassDef); @@ -265,8 +270,7 @@ private void process(Element annotatedElement, BuilderConfiguration config) // Track DTO Mapping tracker.startPhase(); GenerationTargetClassDto renderingDto = - new BuilderToGenerationTypeMapper(config, context.getFormattingMode()) - .toRenderingDto(builderDef); + new BuilderToGenerationTypeMapper(config).toRenderingDto(builderDef); tracker.endPhase(PHASE_DTO_MAPPING); // Track Code Generation (parent phase; sub-phases tracked inside RoasterCodeGenerator) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java index 3e5950d2..df79484d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java @@ -52,19 +52,16 @@ public class BuilderToGenerationTypeMapper { private final BuilderConfiguration configuration; - private final FormattingMode globalFormattingMode; /** * Creates a mapper for the given effective builder configuration. * - * @param configuration the effective builder configuration - * @param globalFormattingMode the global formatting mode from compiler arguments (used as - * fallback when the annotation does not specify a formatting mode) + * @param configuration the effective builder configuration (already merged with global compiler + * arguments, so the formatting mode is available via {@link + * BuilderConfiguration#resolveFormattingMode}) */ - public BuilderToGenerationTypeMapper( - BuilderConfiguration configuration, FormattingMode globalFormattingMode) { + public BuilderToGenerationTypeMapper(BuilderConfiguration configuration) { this.configuration = configuration; - this.globalFormattingMode = globalFormattingMode; } /** @@ -84,7 +81,7 @@ public GenerationTargetClassDto toRenderingDto(BuilderDefinitionDto builderDto) renderingDto.setSuperType(builderDto.getSuperType()); renderingDto.setClassJavadoc( configuration.shouldGenerateJavaDoc() ? builderDto.getClassJavadoc() : null); - renderingDto.setFormattingMode(configuration.resolveFormattingMode(globalFormattingMode)); + renderingDto.setFormattingMode(configuration.resolveFormattingMode(FormattingMode.JDT)); builderDto.getClassFields().stream() .map(this::toRenderingClassField) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java index 1f9ad600..a47a211c 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java @@ -27,7 +27,6 @@ import javax.annotation.processing.ProcessingEnvironment; import org.apache.commons.lang3.Strings; import org.javahelpers.simple.builders.core.enums.AccessModifier; -import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; @@ -124,19 +123,6 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) { } } - /** - * Reads the formatting mode from compiler arguments. - * - *

    Returns the corresponding FormattingMode enum value, or {@link FormattingMode#JDT} as the - * default if not set or invalid. - * - * @return the FormattingMode value, defaults to {@link FormattingMode#JDT} - */ - public FormattingMode readFormattingMode() { - String value = readValue(CompilerArgumentsEnum.FORMATTING_MODE); - return FormattingMode.fromString(value); - } - /** * Reads a complete BuilderConfiguration from compiler arguments. * @@ -189,6 +175,7 @@ public BuilderConfiguration readBuilderConfiguration() { .jacksonModulePackage(readValue(CompilerArgumentsEnum.JACKSON_MODULE_PACKAGE)) .builderSuffix(readValue(CompilerArgumentsEnum.BUILDER_SUFFIX)) .setterSuffix(readValue(CompilerArgumentsEnum.SETTER_SUFFIX)) + .formattingMode(readValue(CompilerArgumentsEnum.FORMATTING_MODE)) .strict(readOptionState(CompilerArgumentsEnum.STRICT)) .build(); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index aada81c7..44e80152 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -32,7 +32,6 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; -import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.generators.registry.GeneratorRegistry; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.type.TypeName; @@ -54,7 +53,6 @@ public final class ProcessingContext { private final BuilderConfigurationReader configurationReader; private final ProcessingEnvironment processingEnv; private final PerformanceTracker performanceTracker; - private final FormattingMode formattingMode; private GeneratorRegistry generatorRegistry; private BuilderConfiguration configurationForProcessingTarget; @@ -84,7 +82,6 @@ public ProcessingContext( perfTrackingEnabled ? new ActivePerformanceTracker(perfOutputFile) : new NoOpPerformanceTracker(); - this.formattingMode = argReader.readFormattingMode(); // GeneratorRegistry will be lazily initialized on first access } @@ -143,18 +140,6 @@ public PerformanceTracker getPerformanceTracker() { return performanceTracker; } - /** - * Gets the formatting mode for generated source files. - * - *

    Controlled via {@code -Asimplebuilder.formattingMode=JDT|LIGHTWEIGHT|NONE}. Defaults to - * {@link FormattingMode#JDT} when not specified. - * - * @return the formatting mode to use for source code post-processing - */ - public FormattingMode getFormattingMode() { - return formattingMode; - } - /** * Get the TypeElement for a given qualified class name. * From cf2571846a89bfb2c2258f93f13174a41bf41ff9 Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 22:07:36 +0200 Subject: [PATCH 23/25] Further improvements in configuration processing for formattingMode --- .../builders/core/enums/FormattingMode.java | 5 ++++ .../builders/processor/BuilderProcessor.java | 8 +------ .../integration/JacksonModuleGenerator.java | 15 +++++++++--- .../model/core/BuilderConfiguration.java | 19 +++------------ .../core/BuilderToGenerationTypeMapper.java | 5 ++-- .../ConfigurationProcessingTest.java | 24 ++++++++----------- 6 files changed, 33 insertions(+), 43 deletions(-) diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java b/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java index 71327dde..6d8a0e0b 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java @@ -54,6 +54,11 @@ public enum FormattingMode { this.optionValue = optionValue; } + /** Returns the option string used in compiler arguments and annotations. */ + public String getOptionValue() { + return optionValue; + } + /** * Parses a string into a {@link FormattingMode}. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index c7d8e0e5..9b47ea66 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -48,7 +48,6 @@ import javax.lang.model.element.TypeElement; import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; -import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -95,7 +94,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { this.context = new ProcessingContext(logger, globalConfig, processingEnv); this.codeGenerator = new RoasterCodeGenerator(processingEnv, logger, context.getPerformanceTracker()); - this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger); + this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig); // Initialize GeneratorRegistry once during processor initialization context.debugStartOperation("Initializing generator registry"); @@ -131,11 +130,6 @@ public boolean process(Set annotations, RoundEnvironment jacksonModuleGenerator.getModuleDefinitions(); for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) { String packageName = moduleClassDef.getTypeName().getPackageName(); - moduleClassDef.setFormattingMode( - context - .getConfigurationReader() - .getGlobalConfiguration() - .resolveFormattingMode(FormattingMode.JDT)); context.info("Generating Jackson Module in package '%s'", packageName); try { codeGenerator.generateClass(moduleClassDef); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java index 7c4faf17..dd52fd1d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java @@ -32,6 +32,7 @@ import org.apache.commons.collections4.SetValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; @@ -61,13 +62,18 @@ public class JacksonModuleGenerator { private final ProcessingEnvironment processingEnv; private final ProcessingLogger logger; + private final BuilderConfiguration globalConfiguration; private final SetValuedMap entriesByPackage = new HashSetValuedHashMap<>(); private final boolean jacksonAvailable; - public JacksonModuleGenerator(ProcessingEnvironment processingEnv, ProcessingLogger logger) { + public JacksonModuleGenerator( + ProcessingEnvironment processingEnv, + ProcessingLogger logger, + BuilderConfiguration globalConfiguration) { this.processingEnv = processingEnv; this.logger = logger; + this.globalConfiguration = globalConfiguration; this.jacksonAvailable = this.processingEnv .getElementUtils() @@ -133,6 +139,7 @@ private boolean validateForModuleGeneration(BuilderConfiguration config, Element * @return list of target class definitions, one per package */ public List getModuleDefinitions() { + FormattingMode formattingMode = globalConfiguration.formattingModeEnum(); List definitions = new ArrayList<>(); if (!entriesByPackage.isEmpty()) { logger.info( @@ -141,7 +148,7 @@ public List getModuleDefinitions() { for (String packageName : entriesByPackage.keySet()) { Set moduleEntries = entriesByPackage.get(packageName); - definitions.add(buildTargetClass(packageName, moduleEntries)); + definitions.add(buildTargetClass(packageName, moduleEntries, formattingMode)); } } clear(); @@ -153,14 +160,16 @@ public List getModuleDefinitions() { * * @param packageName target package of the generated module class * @param entries DTO/builder type pairs to register as mixins + * @param formattingMode the formatting mode to use for the generated module class * @return a fully populated class definition ready for code generation */ private GenerationTargetClassDto buildTargetClass( - String packageName, Set entries) { + String packageName, Set entries, FormattingMode formattingMode) { GenerationTargetClassDto classDef = new GenerationTargetClassDto(); classDef.setTypeName(new TypeName(packageName, MODULE_CLASS_NAME)); classDef.setClassAccessModifier(AccessModifier.PUBLIC); classDef.setSuperType(SIMPLE_MODULE_TYPE); + classDef.setFormattingMode(formattingMode); // Each entry becomes a private nested mixin interface annotated with @JsonDeserialize. for (JacksonModuleEntryDto entry : entries) { diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java index 75e8723c..7d3fad82 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java @@ -130,7 +130,7 @@ public record BuilderConfiguration( .jacksonModulePackage(null) .builderSuffix("Builder") .setterSuffix("") - .formattingMode(null) + .formattingMode(FormattingMode.JDT.getOptionValue()) .strict(DISABLED) .build(); @@ -248,21 +248,8 @@ public boolean isStrictModeEnabled() { return strict == ENABLED; } - /** - * Resolves the effective formatting mode for this configuration. - * - *

    If this configuration's {@code formattingMode} is null or blank, the provided fallback (from - * compiler arguments) is used. Otherwise, this configuration's value takes priority. - * - * @param fallback the formatting mode from compiler arguments (used when annotation value is - * unset) - * @return the resolved formatting mode - */ - public FormattingMode resolveFormattingMode(FormattingMode fallback) { - if (formattingMode != null && !formattingMode.isBlank()) { - return FormattingMode.fromString(formattingMode); - } - return fallback; + public FormattingMode formattingModeEnum() { + return FormattingMode.fromString(formattingMode); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java index df79484d..bf8d5eb1 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java @@ -25,7 +25,6 @@ package org.javahelpers.simple.builders.processor.model.core; import org.apache.commons.lang3.StringUtils; -import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.model.javadoc.JavadocDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; import org.javahelpers.simple.builders.processor.model.method.ConstructorDto; @@ -58,7 +57,7 @@ public class BuilderToGenerationTypeMapper { * * @param configuration the effective builder configuration (already merged with global compiler * arguments, so the formatting mode is available via {@link - * BuilderConfiguration#resolveFormattingMode}) + * BuilderConfiguration#formattingModeEnum}) */ public BuilderToGenerationTypeMapper(BuilderConfiguration configuration) { this.configuration = configuration; @@ -81,7 +80,7 @@ public GenerationTargetClassDto toRenderingDto(BuilderDefinitionDto builderDto) renderingDto.setSuperType(builderDto.getSuperType()); renderingDto.setClassJavadoc( configuration.shouldGenerateJavaDoc() ? builderDto.getClassJavadoc() : null); - renderingDto.setFormattingMode(configuration.resolveFormattingMode(FormattingMode.JDT)); + renderingDto.setFormattingMode(configuration.formattingModeEnum()); builderDto.getClassFields().stream() .map(this::toRenderingClassField) diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java index 1790304d..cbebb097 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java @@ -556,38 +556,34 @@ void configurationMerge_FormattingMode_MustRespectPriority() { // Then: Override should win assertEquals("none", merged.formattingMode(), "Override should win for formattingMode"); - // And: resolveFormattingMode should return the override value + // And: formattingModeEnum should return the override value assertEquals( FormattingMode.NONE, - merged.resolveFormattingMode(FormattingMode.JDT), + merged.formattingModeEnum(), "Annotation formattingMode should override compiler arg fallback"); } - /** resolveFormattingMode test: Null/blank formattingMode should fall back to compiler arg. */ + /** formattingModeEnum test: Null/blank formattingMode should default to JDT. */ @Test - void resolveFormattingMode_WhenUnset_ShouldUseFallback() { + void formattingModeEnum_WhenUnset_ShouldDefaultToJdt() { BuilderConfiguration config = BuilderConfiguration.builder().build(); assertEquals( FormattingMode.JDT, - config.resolveFormattingMode(FormattingMode.JDT), - "Null formattingMode should fall back to JDT"); - assertEquals( - FormattingMode.LIGHTWEIGHT, - config.resolveFormattingMode(FormattingMode.LIGHTWEIGHT), - "Null formattingMode should fall back to LIGHTWEIGHT when provided as fallback"); + config.formattingModeEnum(), + "Null formattingMode should default to JDT"); } - /** resolveFormattingMode test: Set formattingMode should override fallback. */ + /** formattingModeEnum test: Set formattingMode should be resolved. */ @Test - void resolveFormattingMode_WhenSet_ShouldOverrideFallback() { + void formattingModeEnum_WhenSet_ShouldReturnSetValue() { BuilderConfiguration config = BuilderConfiguration.builder().formattingMode("lightweight").build(); assertEquals( FormattingMode.LIGHTWEIGHT, - config.resolveFormattingMode(FormattingMode.JDT), - "Set formattingMode should override compiler arg fallback"); + config.formattingModeEnum(), + "Set formattingMode should be resolved correctly"); } /** From 94b5d3380bf74a31e24922544fd652bb718233ad Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 22:08:33 +0200 Subject: [PATCH 24/25] Adding documentation for configuration processing --- docs/CONTRIBUTING.md | 9 +++++++++ .../processing/CompilerArgumentsReader.java | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 4ddddad4..4a0335ed 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,6 +65,15 @@ After making code changes: - Changes affecting generation: `mvn test -pl processor,example -am` 3. **Full validation before committing**: `mvn clean test` +### Adding or Modifying Configuration Options + +Configuration options flow through a three-layer merge chain: built-in defaults +(`BuilderConfiguration.DEFAULT`), compiler arguments +(`CompilerArgumentsReader.readBuilderConfiguration()`), and annotation values +(`@SimpleBuilder.Options(...)`). Every option must be wired through all three layers. +See the comment in `CompilerArgumentsReader.readBuilderConfiguration()` for the detailed +checklist of files to touch. + ### Test Assertions Best Practices - **Use explicit string literals** for expected values, not variables diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java index a47a211c..45e147a3 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java @@ -136,6 +136,26 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) { * *

    All values default to UNSET or DEFAULT if not specified in compiler arguments. * + *

    Adding a new option: every option in {@link CompilerArgumentsEnum} that + * represents a configuration value must be read and set here. Omitting it causes the + * compiler argument to be silently ignored. The full checklist when adding a new + * option: + * + *

      + *
    1. {@code CompilerArgumentsEnum} — add the enum constant. + *
    2. This method — read the value and set it on the builder. + *
    3. {@code BuilderConfiguration} — add the field, builder method, merge logic, and + * a typed accessor (e.g. {@code formattingModeEnum}) if enum conversion is + * needed. Set the default in {@code BuilderConfiguration.DEFAULT}. + *
    4. {@code BuilderConfigurationReader} — handle annotation-side extraction in + * {@code extractOptionsFromAnnotationMirror}. + *
    5. {@code ProcessingContext} — should NOT need a dedicated field or getter. The + * resolved per-target config ({@code context.getConfiguration()}) and global + * config ({@code context.getConfigurationReader().getGlobalConfiguration()}) + * carry all option values. Special-casing outside {@code BuilderConfiguration} + * breaks the merge chain and bypasses annotation overrides. + *
    + * * @return a BuilderConfiguration with values read from compiler arguments */ public BuilderConfiguration readBuilderConfiguration() { From 8ebe55717de5a6800f83e719a7e5b3f01a2f5fdc Mon Sep 17 00:00:00 2001 From: AndreasIgel Date: Tue, 1 Sep 2026 22:19:31 +0200 Subject: [PATCH 25/25] Improvements in Javadoc --- .../core/BuilderToGenerationTypeMapper.java | 6 ++--- .../processing/CompilerArgumentsReader.java | 27 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java index bf8d5eb1..a56be04f 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderToGenerationTypeMapper.java @@ -53,11 +53,9 @@ public class BuilderToGenerationTypeMapper { private final BuilderConfiguration configuration; /** - * Creates a mapper for the given effective builder configuration. + * Creates a mapper for the given builder configuration. * - * @param configuration the effective builder configuration (already merged with global compiler - * arguments, so the formatting mode is available via {@link - * BuilderConfiguration#formattingModeEnum}) + * @param configuration the builder configuration (already merged with global compiler arguments) */ public BuilderToGenerationTypeMapper(BuilderConfiguration configuration) { this.configuration = configuration; diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java index 45e147a3..5edf99fa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java @@ -136,24 +136,23 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) { * *

    All values default to UNSET or DEFAULT if not specified in compiler arguments. * - *

    Adding a new option: every option in {@link CompilerArgumentsEnum} that - * represents a configuration value must be read and set here. Omitting it causes the - * compiler argument to be silently ignored. The full checklist when adding a new - * option: + *

    Adding a new option: every option in {@link CompilerArgumentsEnum} that represents a + * configuration value must be read and set here. Omitting it causes the compiler argument to be + * silently ignored. The full checklist when adding a new option: * *

      *
    1. {@code CompilerArgumentsEnum} — add the enum constant. *
    2. This method — read the value and set it on the builder. - *
    3. {@code BuilderConfiguration} — add the field, builder method, merge logic, and - * a typed accessor (e.g. {@code formattingModeEnum}) if enum conversion is - * needed. Set the default in {@code BuilderConfiguration.DEFAULT}. - *
    4. {@code BuilderConfigurationReader} — handle annotation-side extraction in - * {@code extractOptionsFromAnnotationMirror}. - *
    5. {@code ProcessingContext} — should NOT need a dedicated field or getter. The - * resolved per-target config ({@code context.getConfiguration()}) and global - * config ({@code context.getConfigurationReader().getGlobalConfiguration()}) - * carry all option values. Special-casing outside {@code BuilderConfiguration} - * breaks the merge chain and bypasses annotation overrides. + *
    6. {@code BuilderConfiguration} — add the field, builder method, merge logic, and a typed + * accessor (e.g. {@code formattingModeEnum}) if enum conversion is needed. Set the default + * in {@code BuilderConfiguration.DEFAULT}. + *
    7. {@code BuilderConfigurationReader} — handle annotation-side extraction in {@code + * extractOptionsFromAnnotationMirror}. + *
    8. {@code ProcessingContext} — should NOT need a dedicated field or getter. The resolved + * per-target config ({@code context.getConfiguration()}) and global config ({@code + * context.getConfigurationReader().getGlobalConfiguration()}) carry all option values. + * Special-casing outside {@code BuilderConfiguration} breaks the merge chain and bypasses + * annotation overrides. *
    * * @return a BuilderConfiguration with values read from compiler arguments