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): + * + *

+ * + *

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: + * + *

+ * + * @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: + * + *

+ * + *

This method splits such lines at: + * + *

    + *
  1. Semicolon followed by a Java declaration keyword ({@code public}, {@code private}, {@code + * protected}, {@code class}, {@code interface}, {@code enum}, {@code record}, {@code + * abstract}, {@code final}, {@code import}, {@code package}) + *
  2. A closing brace ({@code }}) followed by another closing brace (with optional whitespace) + *
+ * + * @param line the potentially concatenated line + * @return a list of split lines (or a singleton list if no splitting was needed) + */ + private java.util.List splitConcatenatedLines(String line) { + // Fast path: no semicolons or closing braces, nothing to split + if (!line.contains(";") && !line.contains("}")) { + return java.util.List.of(line); + } + + 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: + * + *

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