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/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/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..6d8a0e0b
--- /dev/null
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/enums/FormattingMode.java
@@ -0,0 +1,82 @@
+/*
+ * 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;
+ }
+
+ /** Returns the option string used in compiler arguments and annotations. */
+ public String getOptionValue() {
+ return 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..e6f11926 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,55 @@ 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`
+| **Annotation Option**: `@SimpleBuilder.Options(formattingMode = "lightweight")`
+
+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
+```
+
+Per-annotation override:
+```java
+@SimpleBuilder(options = @SimpleBuilder.Options(
+ formattingMode = "lightweight"
+))
+public class PersonDto { ... }
+```
+
## Examples
### Minimal Builder
@@ -1347,6 +1398,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 +1415,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 +1429,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/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/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..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
@@ -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
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/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.")
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/**
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..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
@@ -94,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");
@@ -269,7 +269,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 95807c23..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
@@ -29,12 +29,11 @@
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.EnumMap;
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;
@@ -43,6 +42,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;
@@ -71,12 +71,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,20 +83,35 @@ public class RoasterCodeGenerator {
/** Performance tracker for sub-phase timing (Source Construction, File Writing). */
private final PerformanceTracker performanceTracker;
- private final Properties formatterProperties;
+ /** Cached formatters per formatting mode (at most 3 instances, created lazily). */
+ private final EnumMap formatterCache =
+ new EnumMap<>(FormattingMode.class);
/**
* Constructor for 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
*/
public RoasterCodeGenerator(
ProcessingEnvironment processingEnv, ProcessingLogger logger, PerformanceTracker tracker) {
this.processingEnv = processingEnv;
this.logger = logger;
this.performanceTracker = tracker;
- this.formatterProperties = loadFormatterProperties();
+ }
+
+ /**
+ * 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));
}
/**
@@ -120,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.
@@ -555,40 +567,8 @@ private void addParameter(
applyAnnotations(parameter, paramDto.getAnnotations());
}
- private String formatSource(String rawSource) {
- if (formatterProperties.isEmpty()) {
- return 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;
- }
- }
-
- 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();
- }
+ 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/classgen/roaster/RoasterSourceFormatter.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java
new file mode 100644
index 00000000..084b22e8
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatter.java
@@ -0,0 +1,429 @@
+/*
+ * 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.Objects;
+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;
+
+/**
+ * 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 final class RoasterSourceFormatter {
+
+ 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.
+ *
+ * @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 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) {
+ logger.warning(
+ "simple-builders: JDT formatting requested but Eclipse formatter profile is unavailable; falling back to lightweight formatting.");
+ }
+ }
+
+ /**
+ * Formats the given raw source code according to the configured {@link FormattingMode}.
+ *
+ *
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 (formattingMode == FormattingMode.NONE) {
+ return rawSource;
+ }
+ if (formattingMode == FormattingMode.LIGHTWEIGHT) {
+ return lightweightFormat(rawSource);
+ }
+ if (!formatterProfileAvailable) {
+ return lightweightFormat(rawSource);
+ }
+ return Roaster.format(formatterProperties, 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) {
+ String[] rawLines = source.split("\n", -1);
+ StringBuilder output = new StringBuilder(source.length());
+ JavadocState javadocState = new JavadocState();
+ boolean prevBlank = false;
+
+ for (String line : rawLines) {
+ // 1. Convert leading tabs to spaces
+ String converted = convertTabsToSpaces(line);
+
+ // 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.isEmpty()) {
+ 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:
+ *
+ *
+ * - 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})
+ *
- 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);
+ }
+
+ 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;
+ }
+
+ /**
+ * 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 pos = findSemicolonKeywordSplit(line);
+ if (pos >= 0) {
+ return pos;
+ }
+ return findClosingBraceSplit(line);
+ }
+
+ /**
+ * 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 = skipWhitespace(line, semiIdx + 1);
+ if (matchesDeclarationKeyword(line, afterSemi)) {
+ return afterSemi;
+ }
+ semiIdx++;
+ }
+ return -1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /** 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. */
+ 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;
+ }
+
+ /**
+ * 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.isEmpty()) {
+ 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')) {
+ wsEnd++;
+ }
+ if (wsEnd == 0) {
+ return line;
+ }
+ 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(" ".repeat(SPACES_PER_TAB));
+ } else {
+ sb.append(c);
+ }
+ }
+ sb.append(line, wsEnd, line.length());
+ return sb.toString();
+ }
+
+ /**
+ * 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++;
+ }
+ return line.substring(0, end);
+ }
+
+ private Properties loadFormatterProperties() {
+ try (InputStream inputStream =
+ RoasterSourceFormatter.class
+ .getClassLoader()
+ .getResourceAsStream(formatterProfileResource)) {
+ if (inputStream == null) {
+ logger.warning(
+ "simple-builders: Bundled Eclipse formatter profile '%s' was not found on the processor classpath.",
+ formatterProfileResource);
+ 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",
+ formatterProfileResource,
+ StringUtils.defaultIfBlank(ex.getMessage(), ex.getClass().getSimpleName()));
+ return new Properties();
+ }
+ }
+}
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 b007eadb..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
@@ -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(FormattingMode.JDT.getOptionValue())
.strict(DISABLED)
.build();
@@ -243,6 +248,10 @@ public boolean isStrictModeEnabled() {
return strict == ENABLED;
}
+ public FormattingMode formattingModeEnum() {
+ return FormattingMode.fromString(formattingMode);
+ }
+
/**
* Merges this configuration with another configuration.
*
@@ -310,6 +319,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 +387,7 @@ public String toString() {
.appendIfNotEmpty("jacksonModulePackage", jacksonModulePackage)
.appendIfNotEmpty("builderSuffix", builderSuffix)
.appendIfNotEmpty("setterSuffix", setterSuffix)
+ .appendIfNotEmpty("formattingMode", formattingMode)
.appendValueIfSet("strict", strict)
.toString();
}
@@ -459,6 +470,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 +732,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 +776,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..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,9 +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
+ * @param configuration the builder configuration (already merged with global compiler arguments)
*/
public BuilderToGenerationTypeMapper(BuilderConfiguration configuration) {
this.configuration = configuration;
@@ -78,6 +78,7 @@ public GenerationTargetClassDto toRenderingDto(BuilderDefinitionDto builderDto)
renderingDto.setSuperType(builderDto.getSuperType());
renderingDto.setClassJavadoc(
configuration.shouldGenerateJavaDoc() ? builderDto.getClassJavadoc() : null);
+ renderingDto.setFormattingMode(configuration.formattingModeEnum());
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/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..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,10 +132,18 @@ public enum CompilerArgumentsEnum {
*/
DEACTIVATE_GENERATION_COMPONENTS("deactivateGenerationComponents"),
- // === Logging ===
+ // === Debug Logging ===
/** Option for verbose logging output. */
VERBOSE("verbose"),
+ // === Performance Optimization ===
+ /**
+ * 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.
+ */
+ FORMATTING_MODE("formattingMode"),
+
// === Performance Tracking ===
/** Option for performance tracking during annotation processing. */
PERFORMANCE_TRACKING("performanceTracking"),
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..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,6 +136,25 @@ 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:
+ *
+ *
+ * - {@code CompilerArgumentsEnum} — add the enum constant.
+ *
- This method — read the value and set it on the builder.
+ *
- {@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}.
+ *
- {@code BuilderConfigurationReader} — handle annotation-side extraction in {@code
+ * extractOptionsFromAnnotationMirror}.
+ *
- {@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() {
@@ -175,6 +194,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/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..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
@@ -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,50 @@ 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: formattingModeEnum should return the override value
+ assertEquals(
+ FormattingMode.NONE,
+ merged.formattingModeEnum(),
+ "Annotation formattingMode should override compiler arg fallback");
+ }
+
+ /** formattingModeEnum test: Null/blank formattingMode should default to JDT. */
+ @Test
+ void formattingModeEnum_WhenUnset_ShouldDefaultToJdt() {
+ BuilderConfiguration config = BuilderConfiguration.builder().build();
+
+ assertEquals(
+ FormattingMode.JDT,
+ config.formattingModeEnum(),
+ "Null formattingMode should default to JDT");
+ }
+
+ /** formattingModeEnum test: Set formattingMode should be resolved. */
+ @Test
+ void formattingModeEnum_WhenSet_ShouldReturnSetValue() {
+ BuilderConfiguration config =
+ BuilderConfiguration.builder().formattingMode("lightweight").build();
+
+ assertEquals(
+ FormattingMode.LIGHTWEIGHT,
+ config.formattingModeEnum(),
+ "Set formattingMode should be resolved correctly");
+ }
+
/**
* toString test: Configuration must produce human-readable output.
*
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/FormattingModeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java
new file mode 100644
index 00000000..a90b0ad1
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/FormattingModeTest.java
@@ -0,0 +1,668 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+
+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 formattingMode} compiler option.
+ *
+ * Verifies:
+ *
+ *
+ * - {@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 FormattingModeTest {
+
+ @Test
+ void lightweightFormatting_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.formattingMode=lightweight")
+ .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"));
+
+ // 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 {
+ /**
+ * 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();
+ }
+
+ /**
+ * 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);
+ }
+ }
+ }
+ }""";
+ assertEquals(
+ expectedCode,
+ generatedCode,
+ "Generated code with lightweight formatting should match the expected lightweight-formatted output");
+ }
+
+ @Test
+ void jdtFormattingByDefault_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");
+
+ // 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"));
+
+ 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 {""";
+ 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);
+ }
+ }
+ }
+ }""";
+ assertEquals(expectedSection2, section2, "Eclipse-formatted section 2 (class body) mismatch");
+ }
+}
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..c129ebfc
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java
@@ -0,0 +1,706 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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 java.util.stream.Stream;
+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;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * 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
+ *
+ */
+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);
+ }
+
+ @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 {
+ }""";
+ assertEquals(raw, formatter.format(raw));
+ }
+
+ @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);
+ 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
+ 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");
+ }
+
+ @ParameterizedTest(name = "{2}")
+ @MethodSource("javadocFormattingCases")
+ void lightweightFormat_javadocFormatting(String input, String expected, String description) {
+ RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT);
+ String result = formatter.lightweightFormat(input);
+ assertEquals(expected, result.strip(), "Case: " + description);
+ }
+
+ 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;
+ /**
+ * Already has prefix.
+ */
+ public class Foo {
+ }
+ """,
+ """
+ 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;
+ /**
+ *Body line without space.
+ */
+ public class Foo {
+ }
+ """,
+ """
+ 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;
+ /**
+ Some description text */
+ public class Foo {
+ }
+ """,
+ """
+ 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_handlesEmptyInput() {
+ RoasterSourceFormatter formatter = createFormatter(FormattingMode.LIGHTWEIGHT);
+ String result = formatter.lightweightFormat("");
+ assertEquals("", result, "Empty input should produce empty output");
+ }
+
+ @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_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);
+ 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);
+ 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");
+ }
+
+ @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");
+ }
+
+ @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 @@
+