diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..45cc9122 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,347 @@ +# Contributing to Simple Builders + +Thank you for your interest in contributing to Simple Builders! This document provides guidelines and instructions for developing and testing the project. + +## Table of Contents +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Development Guidelines](#development-guidelines) +- [Building and Testing](#building-and-testing) +- [Debugging](#debugging) +- [Code Style](#code-style) +- [Submitting Changes](#submitting-changes) + +## Development Setup + +### Prerequisites + +- **Java 17+**: Required for development +- **Maven 3.8+**: Build tool +- **Git**: Version control + +### Clone and Build + +```bash +git clone https://github.com/java-helpers/simple-builders.git +cd simple-builders +mvn clean install +``` + +## Project Structure + +The project is organized as a multi-module Maven project: + +``` +simple-builders/ +├── core/ # Core annotations and runtime utilities +├── processor/ # Annotation processor (compile-time code generation) +├── example/ # Example usage and integration tests +└── pom.xml # Parent POM +``` + +### Module Dependencies + +**Important**: The `example` module depends on the `processor` module being installed in your local Maven repository. This is because: +1. The annotation processor must be available at compile-time +2. The example uses `@SimpleBuilder` annotations that trigger code generation +3. Tests in example validate the generated builders + +## Development Guidelines + +### Working with Annotation Processors + +This project uses annotation processing for code generation. Understanding this architecture is crucial: + +- The `processor` module generates code at **compile-time** +- The `example` module depends on the processor being installed in your local Maven repository +- Tests use Google's compile-testing library, which makes compilation happen inside test code + +### Code Changes Workflow + +After making code changes: +1. **Always run tests** +2. **Use appropriate scope**: + - Processor changes: `mvn test -pl processor` + - Changes affecting generation: `mvn test -pl processor,example -am` +3. **Full validation before committing**: `mvn clean test` + +### Test Assertions Best Practices + +- **Use explicit string literals** for expected values, not variables +- This improves readability and makes failures easier to diagnose +- ✅ Good: Use complete method bodies in assertions + ```java + assertContains(code, """ + public PersonBuilder name(String name) { + this.name = name; + return this; + } + """); + ``` +- ❌ Avoid: Building assertion strings dynamically from variables + +## Building and Testing + +### Test Strategies + +#### 1. When Modifying Processor Code + +If you're changing code in the `processor` module: + +```bash +# Test only the processor +mvn test -pl processor +``` + +#### 2. When Modifying Example Code + +If you're changing code in the `example` module, you **must** install the processor first: + +```bash +# Install processor (skip its tests for speed) +mvn install -pl processor -DskipTests + +# Then test example +mvn test -pl example +``` + +#### 3. When Modifying Both Modules + +For changes affecting both processor and example: + +```bash +# Option A: Use reactor with -am (also-make) flag +mvn test -pl processor,example -am + +# Option B: Full clean install (safest) +mvn clean install +``` + +#### 4. Full Validation Before Committing + +Always run a full build with all tests before committing: + +```bash +# Clean build with all tests +mvn clean test + +# Or full install +mvn clean install +``` + +### Common Maven Commands + +```bash +# Clean everything +mvn clean + +# Compile without tests +mvn compile -DskipTests + +# Install to local repository without tests +mvn install -DskipTests + +# Run tests for specific modules +mvn test -pl processor,example + +# Run a specific test class +mvn test -Dtest=BuilderProcessorTest -pl processor + +# Run a single test method +mvn test -Dtest=BuilderProcessorTest#shouldGenerateBasicBuilder -pl processor + +# Run tests matching a pattern +mvn test -Dtest=*ProcessorTest -pl processor +``` + +### Troubleshooting Build Issues + +#### Maven Compilation Cache Issues + +If you encounter strange compilation errors in the `example` module: + +1. **Clean the affected module**: + ```bash + mvn clean -pl example + ``` + +2. **Reinstall dependencies**: + ```bash + mvn clean install -pl processor -DskipTests + mvn compile -pl example + ``` + +3. **Full clean (nuclear option)**: + ```bash + mvn clean + mvn install + ``` + +#### Test Compilation Failures + +If test classes can't find generated builders: + +```bash +# Ensure processor is installed +mvn install -pl processor -DskipTests + +# Clean and rebuild example +mvn clean compile -pl example +mvn test -pl example +``` + +## Debugging + +### When Tests Fail + +**Always run failing tests with verbose mode first** to see what the annotation processor is doing: + +```bash +# Debug a specific failing test +mvn test -pl processor -Dtest=YourFailingTest -Dsimplebuilder.verbose=true +``` + +### Verbose Output + +The annotation processor has its own verbose logging (different from Maven's `-X` flag): + +```bash +# Enable for processor tests +mvn test -pl processor -Dsimplebuilder.verbose=true + +# Enable for example compilation +mvn compile -pl example -Dsimplebuilder.verbose=true + +# Enable for all tests +mvn test -Dsimplebuilder.verbose=true +``` + +**What verbose output shows:** +- Field discovery and type analysis +- Method parameter extraction +- Annotation processing steps +- Code generation details +- Exact error locations +- Complete generated source code (printed before assertions run) + +For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). + +**Example output:** +``` +========== Compilation Diagnostics ========== +--- NOTES --- +[DEBUG] simple-builders: Processing element: Project +[DEBUG] Extracting builder definition from: test.Project +[DEBUG] Analyzing method: setName with 1 parameter(s) +[DEBUG] -> Adding field: name (type: java.lang.String) +[DEBUG] Generated 4 methods for field: name +============================================= + +========== Generated Source Files ========== + +--- ProjectBuilder.java --- +package test; + +public class ProjectBuilder { + private String name; + + public ProjectBuilder name(String name) { + this.name = name; + return this; + } + ... +} +--- End of ProjectBuilder.java --- +============================================= +``` + +This makes it easy to compare expected vs actual generated code without needing a debugger. + +**Why is this important?** + +This is **critical** for debugging because annotation processing happens inside Google's compile-testing framework, making it otherwise invisible. + +## Code Style + +### Formatting + +The project uses [google-java-format](https://github.com/google/google-java-format) for consistent code formatting. + +**Automatic formatting** is applied during build via the `fmt-maven-plugin`: + +```bash +# Format code automatically +mvn fmt:format + +# Check formatting without modifying files +mvn fmt:check +``` + +### Code Quality + +- **SonarLint**: We use SonarQube rules. Install the SonarLint IDE plugin for real-time feedback. +- **Test Coverage**: Aim for high test coverage for new features. +- **JavaDoc**: Public APIs should have comprehensive JavaDoc comments. + +### Naming Conventions + +- **Classes**: `PascalCase` (e.g., `BuilderProcessor`) +- **Methods**: `camelCase` (e.g., `generateBuilder`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_TIMEOUT`) +- **Packages**: lowercase (e.g., `org.javahelpers.simple.builders`) + +## Submitting Changes + +### Before Submitting + +1. **Run all tests**: + ```bash + mvn clean test + ``` + +2. **Check code formatting**: + ```bash + mvn fmt:check + ``` + +3. **Update documentation** if needed + +4. **Write tests** for new features + +### Pull Request Process + +1. **Fork** the repository +2. **Create a feature branch** from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` + +3. **Make your changes** following the code style guidelines + +4. **Commit** with clear, descriptive messages: + ```bash + git commit -m "Add feature: description of feature" + ``` + +5. **Push** to your fork: + ```bash + git push origin feature/your-feature-name + ``` + +6. **Open a Pull Request** against the `main` branch + +### Pull Request Guidelines + +- **Clear description**: Explain what your PR does and why +- **Reference issues**: Link related issues (e.g., "Fixes #123") +- **Keep it focused**: One feature or fix per PR +- **Include tests**: Add tests for new functionality +- **Update docs**: Update README.md or other docs if needed + +## Questions? + +If you have questions or need help: +- Open an [issue](https://github.com/java-helpers/simple-builders/issues) +- Check existing [discussions](https://github.com/java-helpers/simple-builders/discussions) + +Thank you for contributing to Simple Builders! 🎉 diff --git a/README.md b/README.md index f8d954ea..89897e93 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ - [Validation Annotations](#validation-annotations) - [Conditional Builder Logic](#conditional-builder-logic) - [Collections and Nested Objects](#collections-and-nested-objects) - - [Debug Logging](#debug-logging) -- [Building from Source](#building-from-source) + - [With Interface Pattern](#with-interface-pattern) - [Contributing](#contributing) - [License](#license) +- [Acknowledgements](#acknowledgements) - [Links](#links) ## What is Simple Builders? @@ -34,8 +34,8 @@ Simple Builders is a Java [annotation processor](https://docs.oracle.com/en/java - **Type-Safe Builders**: Compile-time type checking for all builder methods - **Fluent API**: Clean, chainable API for object construction - **Collections Support**: Built-in support for collections and maps -- **Nested Builders**: Automatic generation of nested object builders - **Annotation Preservation**: Validation annotations are automatically copied to builder methods +- **With Interface Pattern**: Type-safe object modifications using generated With interfaces ## Requirements @@ -234,51 +234,68 @@ Project project = ProjectBuilder.create() .build(); ``` -### Debug Logging -Simple Builders supports detailed debug logging to trace the builder generation process. Enable it with the `-Averbose=true` compiler argument for detailed insights into field discovery, method analysis, and code generation. +### With Interface Pattern -For complete documentation on enabling and using debug logging, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). +Simple Builders generates a nested `With` interface for each builder field, enabling a clean, type-safe way to create modified copies of objects. This pattern is particularly useful for creating variations of an object: -## Building from Source +```java +Person person = PersonBuilder.create() + .name("John Doe") + .age(30) + .build(); -1. Clone the repository: - ```bash - git clone https://github.com/java-helpers/simple-builders.git - cd simple-builders - ``` +// Create a modified copy using the With interface +Person olderPerson = PersonBuilder.create() + .with(person) + .age(31) // Only change the age + .build(); -2. Build the project: - ```bash - mvn clean install - ``` +// By implementing the With interface, you can create modified copies of objects in a type-safe way +Person youngerPerson = person.with(p -> p.age(29)); +``` -3. Run tests: - ```bash - mvn test - ``` +The `With` interface provides type-safe setter methods that mirror the builder's API, making it easy to create object variations without manually copying all fields. ## Contributing -Contributions are welcome! Please follow these steps: - -1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Create a new Pull Request - -Please ensure your code follows the project's code style and includes appropriate tests. +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: -### Releasing +- Development setup and project structure +- Building and testing strategies (important for annotation processor modules) +- Debugging with verbose output +- Code style and formatting +- Pull request process -For maintainers: -- **Release process:** See [RELEASE.md](RELEASE.md) for releasing new versions +For maintainers, see [RELEASE.md](RELEASE.md) for the release process. ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +## Acknowledgements + +This project was made possible thanks to the following: + +### Inspiration and Patterns + +- **[Benji Weber](https://benjiweber.co.uk/blog/2020/09/19/fun-with-java-records/)** - The With interface pattern is inspired by Benji's innovative work on functional builders and extending Java Records. +- **[RecordBuilder](https://github.com/Randgalt/record-builder)** by Randall Hauch - A state-of-the-art builder solution for Java records. If your project uses records exclusively, RecordBuilder is an excellent choice. Simple Builders extends these concepts to traditional Java classes. + +### Tools and Libraries + +- **[JavaPoet](https://github.com/palantir/javapoet)** - An excellent library for generating Java source code. Originally created by Square, now maintained by Palantir. JavaPoet made it straightforward to generate clean, readable builder code. +- **[Google Compile Testing](https://github.com/google/compile-testing)** - Essential for testing annotation processors with comprehensive compilation diagnostics. + +### Learning Resources + +The following resources were invaluable for understanding annotation processing: + +- **[Baeldung: Java Annotation Processing and Creating a Builder](https://www.baeldung.com/java-annotation-processing-builder)** - Comprehensive guide to annotation processing fundamentals +- **[SkyRo Tech: Code Generation with JavaPoet in Practice](https://medium.com/skyro-tech/code-generation-with-javapoet-on-practice-bfbe8ca56a61)** - Practical examples of using JavaPoet +- **[Annotation Processing Demo](https://github.com/ledungcobra/annotation-processing-demo)** by Le Dung - Hands-on examples of annotation processor implementation + +Thank you to all contributors and the Java community for making this project possible! ## Links diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java b/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java new file mode 100644 index 00000000..8698a4e3 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/ProductRecord.java @@ -0,0 +1,68 @@ +/* + * MIT License + * + * Copyright (c) 2025 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.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + +/** + * Example showing how Records work with Simple Builders and the With interface. + * + *

Records are immutable and final. The builder pattern works great with Records + * for creating new instances. The With interface is also generated, allowing fluent + * modification methods. + * + *

Since annotation processors cannot modify Records to add 'implements' clauses, + * you need to manually add {@code implements ProductRecordBuilder.With} to your Record + * declaration to use the fluent with() methods. + * + *

Example usage with the With interface: + *

{@code
+ * ProductRecord product = ProductRecordBuilder.create()
+ *     .name("Laptop")
+ *     .price(1500.0)
+ *     .category("Electronics")
+ *     .build();
+ * 
+ * // Create modified copy using with()
+ * ProductRecord discounted = product.with(b -> b.price(1200.0));
+ * }
+ */ +@SimpleBuilder +public record ProductRecord( + String name, + double price, + String category +) implements ProductRecordBuilder.With { + + /** + * Custom method showing fluent modification using the With interface. + * Since Records are immutable, the with() methods create new instances. + */ + public ProductRecord withDiscountedPrice(double discountPercentage) { + double discountedPrice = price * (1 - discountPercentage / 100); + // Use the with() method from the With interface for fluent modification + return with(builder -> builder.price(discountedPrice)); + } +} diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java new file mode 100644 index 00000000..1cf054e2 --- /dev/null +++ b/example/src/test/java/org/javahelpers/simple/builders/example/ProductRecordTest.java @@ -0,0 +1,201 @@ +/* + * MIT License + * + * Copyright (c) 2025 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.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +/** + * Tests demonstrating how Records work with Simple Builders and the With interface. + * + *

This showcases: + *

+ */ +class ProductRecordTest { + + @Test + void testBuilder_createsRecordSuccessfully() { + // Create a product using the builder + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + assertNotNull(laptop); + assertEquals("Gaming Laptop", laptop.name()); + assertEquals(1500.00, laptop.price()); + assertEquals("Electronics", laptop.category()); + } + + @Test + void testBuilder_copiesFromRecordInstance() { + // Given: an existing Record instance + ProductRecord original = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: creating a builder from the Record instance using copy constructor + ProductRecordBuilder builder = new ProductRecordBuilder(original); + ProductRecord copy = builder.build(); + + // Then: all fields should be copied from the original + assertNotNull(copy); + assertEquals("Gaming Laptop", copy.name(), "Name should be copied from original Record"); + assertEquals(1500.00, copy.price(), "Price should be copied from original Record"); + assertEquals("Electronics", copy.category(), "Category should be copied from original Record"); + } + + @Test + void testWithInterface_createsModifiedCopy() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: using the With interface to create a modified copy + ProductRecord discountedLaptop = laptop.with(builder -> + builder.price(1200.00) + ); + + // Then: a new instance is created with modified values + assertNotNull(discountedLaptop); + assertEquals("Gaming Laptop", discountedLaptop.name()); + assertEquals(1200.00, discountedLaptop.price()); + assertEquals("Electronics", discountedLaptop.category()); + + // And: the original is unchanged (immutability) + assertEquals(1500.00, laptop.price()); + } + + @Test + void testWithInterface_multipleModifications() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: applying multiple modifications using with() + ProductRecord rebranded = laptop.with(builder -> + builder + .name("Professional Laptop") + .category("Business") + .price(1800.00) + ); + + // Then: all modifications are applied + assertNotNull(rebranded); + assertEquals("Professional Laptop", rebranded.name()); + assertEquals(1800.00, rebranded.price()); + assertEquals("Business", rebranded.category()); + + // And: the original is unchanged + assertEquals("Gaming Laptop", laptop.name()); + assertEquals(1500.00, laptop.price()); + assertEquals("Electronics", laptop.category()); + } + + @Test + void testWithInterface_returnsBuilder() { + // Given: an original product + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: getting a builder from the existing instance + ProductRecordBuilder builder = laptop.with(); + ProductRecord modified = builder + .price(1400.00) + .build(); + + // Then: the builder is initialized from the original and modifications applied + assertNotNull(modified); + assertEquals("Gaming Laptop", modified.name()); + assertEquals(1400.00, modified.price()); + assertEquals("Electronics", modified.category()); + } + + @Test + void testCustomWithMethod_calculatesDiscount() { + // Given: a product with a price + ProductRecord laptop = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: applying a 20% discount using the custom method + ProductRecord saleProduct = laptop.withDiscountedPrice(20); + + // Then: the price is correctly discounted + assertNotNull(saleProduct); + assertEquals("Gaming Laptop", saleProduct.name()); + assertEquals(1200.00, saleProduct.price(), 0.01); // 1500 * 0.8 = 1200 + assertEquals("Electronics", saleProduct.category()); + + // And: the original is unchanged + assertEquals(1500.00, laptop.price()); + } + + @Test + void testImmutability_originalUnchanged() { + // Given: an original product + ProductRecord original = ProductRecordBuilder.create() + .name("Gaming Laptop") + .price(1500.00) + .category("Electronics") + .build(); + + // When: creating multiple modified versions + ProductRecord modified1 = original.with(builder -> builder.price(1200.00)); + ProductRecord modified2 = original.with(builder -> builder.name("Business Laptop")); + ProductRecord modified3 = original.withDiscountedPrice(10); + + // Then: all modified versions are different instances + assertNotNull(modified1); + assertNotNull(modified2); + assertNotNull(modified3); + + // And: the original remains completely unchanged + assertEquals("Gaming Laptop", original.name()); + assertEquals(1500.00, original.price()); + assertEquals("Electronics", original.category()); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java index 0a7a4e1d..4b375cc0 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java @@ -51,6 +51,12 @@ public class BuilderDefinitionDto { */ private final List fields = new LinkedList<>(); + /** + * Nested types (interfaces or classes) to be generated inside the builder, such as the "With" + * interface. + */ + private final List nestedTypes = new LinkedList<>(); + /** * Getting type of builder. * @@ -173,4 +179,22 @@ public void addGeneric(GenericParameterDto generic) { public List getGenerics() { return generics; } + + /** + * Returns the list of nested types (interfaces or classes) to be generated inside the builder. + * + * @return the list of nested types + */ + public List getNestedTypes() { + return nestedTypes; + } + + /** + * Adds a nested type definition to the builder. + * + * @param nestedType the nested type to add + */ + public void addNestedType(NestedTypeDto nestedType) { + this.nestedTypes.add(nestedType); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java index 0d352648..787f108b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java @@ -46,6 +46,12 @@ public class MethodDto { /** Name of method. */ private String methodName; + /** Return type of method. */ + private TypeName returnType; + + /** Javadoc comment for the method. */ + private String javadoc; + /** List of parameters of Method. */ private final LinkedList parameters = new LinkedList<>(); @@ -178,4 +184,40 @@ public Optional getModifier() { public void setModifier(Modifier modifier) { this.modifier = Optional.ofNullable(modifier); } + + /** + * Gets the return type of the method. + * + * @return the return type as TypeName + */ + public TypeName getReturnType() { + return returnType; + } + + /** + * Sets the return type of the method. + * + * @param returnType the return type as TypeName + */ + public void setReturnType(TypeName returnType) { + this.returnType = returnType; + } + + /** + * Gets the Javadoc comment for the method. + * + * @return the Javadoc comment + */ + public String getJavadoc() { + return javadoc; + } + + /** + * Sets the Javadoc comment for the method. + * + * @param javadoc the Javadoc comment + */ + public void setJavadoc(String javadoc) { + this.javadoc = javadoc; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java new file mode 100644 index 00000000..857049f6 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java @@ -0,0 +1,96 @@ +/* + * MIT License + * + * Copyright (c) 2025 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.dtos; + +import java.util.LinkedList; +import java.util.List; + +/** + * Represents a nested type (interface or class) to be generated inside the builder. + * + *

For example, the "With" interface that allows DTOs to implement fluent modification methods. + */ +public class NestedTypeDto { + + /** The simple name of the nested type (e.g., "With"). */ + private String typeName; + + /** The kind of nested type (INTERFACE or CLASS). */ + private NestedTypeKind kind; + + /** Whether this nested type should be public. */ + private boolean isPublic = true; + + /** Methods to be generated in this nested type. */ + private final List methods = new LinkedList<>(); + + /** Javadoc comment for this nested type. */ + private String javadoc; + + public enum NestedTypeKind { + INTERFACE, + CLASS + } + + public String getTypeName() { + return typeName; + } + + public void setTypeName(String typeName) { + this.typeName = typeName; + } + + public NestedTypeKind getKind() { + return kind; + } + + public void setKind(NestedTypeKind kind) { + this.kind = kind; + } + + public boolean isPublic() { + return isPublic; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + + public List getMethods() { + return methods; + } + + public void addMethod(MethodDto method) { + this.methods.add(method); + } + + public String getJavadoc() { + return javadoc; + } + + public void setJavadoc(String javadoc) { + this.javadoc = javadoc; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java index 51d26f8a..a7514d66 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java @@ -93,12 +93,16 @@ public static BuilderDefinitionDto extractFromElement( BuilderDefinitionDto result = initializeBuilderDefinition(annotatedType, context); - List constructorFields = extractConstructorFields(annotatedType, context); + List constructorFields = extractConstructorFields(annotatedType, result, context); result.addAllFieldsInConstructor(constructorFields); List setterFields = extractSetterFields(annotatedType, result, context); result.addAllFields(setterFields); + // Create the With interface + NestedTypeDto withInterface = createWithInterface(result, context); + result.addNestedType(withInterface); + return result; } @@ -126,7 +130,7 @@ private static BuilderDefinitionDto initializeBuilderDefinition( * @return list of fields extracted from constructor parameters */ private static List extractConstructorFields( - TypeElement annotatedType, ProcessingContext context) { + TypeElement annotatedType, BuilderDefinitionDto builderDef, ProcessingContext context) { List constructorFields = new LinkedList<>(); Optional constructorOpt = findConstructorForBuilder(annotatedType, context); if (constructorOpt.isPresent()) { @@ -136,7 +140,8 @@ private static List extractConstructorFields( ctor.getSimpleName(), ctor.getParameters().size()); for (VariableElement param : ctor.getParameters()) { Optional fieldFromCtor = - createFieldFromConstructor(annotatedType, param, context); + createFieldFromConstructor( + annotatedType, param, builderDef.getBuilderTypeName(), context); if (fieldFromCtor.isPresent()) { FieldDto field = fieldFromCtor.get(); logFieldAddition(field, context); @@ -174,7 +179,8 @@ private static List extractSetterFields( mth.getSimpleName(), mth.getParameters().size()); if (isMethodRelevantForBuilder(mth, context)) { - Optional maybeField = createFieldFromSetter(mth, context); + Optional maybeField = + createFieldFromSetter(mth, result.getBuilderTypeName(), context); if (maybeField.isPresent()) { processedCount++; FieldDto field = maybeField.get(); @@ -234,12 +240,16 @@ private static boolean isMethodRelevantForBuilder( } private static void addAdditionalHelperMethodsForField( - FieldDto result, String fieldName, TypeName fieldType, List annotations) { + FieldDto result, + String fieldName, + TypeName fieldType, + List annotations, + TypeName builderType) { // Check for String type (not array) and add format method if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "String.format(format, args)", annotations)); + fieldName, "String.format(format, args)", annotations, builderType)); } // Only process generic types (List, Set, Map, Optional, etc.) @@ -253,29 +263,31 @@ private static void addAdditionalHelperMethodsForField( if (isList(fieldType) && innerTypesCnt == 1) { result.addMethod( createFieldSetterWithTransform( - fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false))); + fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false), builderType)); } else if (isSet(fieldType) && innerTypesCnt == 1) { result.addMethod( createFieldSetterWithTransform( - fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true))); + fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true), builderType)); } else if (isMap(fieldType) && innerTypesCnt == 2) { TypeName mapEntryType = new TypeNameArray( new TypeNameGeneric("java.util", "Map.Entry", innerTypes.get(0), innerTypes.get(1)), false); result.addMethod( - createFieldSetterWithTransform(fieldName, "Map.ofEntries(%s)", mapEntryType)); + createFieldSetterWithTransform( + fieldName, "Map.ofEntries(%s)", mapEntryType, builderType)); } else if (isOptional(fieldType) && innerTypesCnt == 1) { // Add setter that accepts the inner type T and wraps it in Optional.ofNullable() result.addMethod( - createFieldSetterWithTransform(fieldName, "Optional.ofNullable(%s)", innerTypes.get(0))); + createFieldSetterWithTransform( + fieldName, "Optional.ofNullable(%s)", innerTypes.get(0), builderType)); // If Optional, add format method TypeName innerType = innerTypes.get(0); if (isString(innerType)) { result.addMethod( createStringFormatMethodWithTransform( - fieldName, "Optional.of(String.format(format, args))", List.of())); + fieldName, "Optional.of(String.format(format, args))", List.of(), builderType)); } } } @@ -286,6 +298,7 @@ private static void addConsumerMethodsForField( TypeName fieldType, VariableElement fieldParameter, TypeElement fieldTypeElement, + TypeName builderType, ProcessingContext context) { // Do not generate supplier methods for generic type variables (e.g., T) if (fieldType instanceof TypeNameVariable) { @@ -296,12 +309,13 @@ private static void addConsumerMethodsForField( return; } - if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, context) - && !tryAddFieldConsumer(result, fieldName, fieldType, fieldTypeElement, context) - && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, context) - && !tryAddMapConsumer(result, fieldName, fieldType) - && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, context)) { - tryAddStringBuilderConsumer(result, fieldName, fieldType); + if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, builderType, context) + && !tryAddFieldConsumer( + result, fieldName, fieldType, fieldTypeElement, builderType, context) + && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, builderType, context) + && !tryAddMapConsumer(result, fieldName, fieldType, builderType) + && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, builderType, context)) { + tryAddStringBuilderConsumer(result, fieldName, fieldType, builderType); } } @@ -310,12 +324,14 @@ private static boolean tryAddBuilderConsumer( FieldDto result, String fieldName, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { - Optional builderTypeOpt = resolveBuilderType(fieldParameter, context); - if (builderTypeOpt.isPresent()) { - TypeName builderType = builderTypeOpt.get(); + Optional fieldBuilderOpt = resolveBuilderType(fieldParameter, context); + if (fieldBuilderOpt.isPresent()) { + TypeName fieldBuilderType = fieldBuilderOpt.get(); result.addMethod( - BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderType)); + BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, fieldBuilderType, builderType)); return true; } return false; @@ -327,6 +343,7 @@ private static boolean tryAddFieldConsumer( String fieldName, TypeName fieldType, TypeElement fieldTypeElement, + TypeName builderType, ProcessingContext context) { if (!isJavaClass(fieldType) && fieldTypeElement != null @@ -334,7 +351,7 @@ private static boolean tryAddFieldConsumer( && !fieldTypeElement.getModifiers().contains(Modifier.ABSTRACT) && hasEmptyConstructor(fieldTypeElement, context)) { // Only generate a Consumer for concrete classes with an accessible empty constructor - result.addMethod(createFieldConsumer(fieldName, fieldType)); + result.addMethod(createFieldConsumer(fieldName, fieldType, builderType)); return true; } return false; @@ -342,11 +359,11 @@ && hasEmptyConstructor(fieldTypeElement, context)) { /** Tries to add StringBuilder-based consumer for String and Optional. */ private static boolean tryAddStringBuilderConsumer( - FieldDto result, String fieldName, TypeName fieldType) { + FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) { if (shouldGenerateStringBuilderConsumer(fieldType)) { String transform = isOptionalString(fieldType) ? "Optional.of(builder.toString())" : "builder.toString()"; - result.addMethod(createStringBuilderConsumer(fieldName, transform)); + result.addMethod(createStringBuilderConsumer(fieldName, transform, builderType)); return true; } return false; @@ -358,6 +375,7 @@ private static boolean tryAddListConsumer( String fieldName, TypeName fieldType, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { if (!(isList(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric @@ -376,23 +394,27 @@ private static boolean tryAddListConsumer( if (elementBuilderType.isPresent()) { // Element type has a builder - use ArrayListBuilderWithElementBuilders - TypeName builderType = + TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(ArrayListBuilderWithElementBuilders.class), elementType, elementBuilderType.get()); result.addMethod( - createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get())); + createFieldConsumerWithElementBuilders( + fieldName, collectionBuilderType, elementBuilderType.get(), builderType)); } else { // Regular ArrayListBuilder - TypeName builderType = map2TypeName(ArrayListBuilder.class); - result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType)); + TypeName collectionBuilderType = map2TypeName(ArrayListBuilder.class); + result.addMethod( + createFieldConsumerWithBuilder( + fieldName, collectionBuilderType, elementType, builderType)); } return true; } /** Tries to add Map-specific consumer methods. Returns true if handled. */ - private static boolean tryAddMapConsumer(FieldDto result, String fieldName, TypeName fieldType) { + private static boolean tryAddMapConsumer( + FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) { if (!(isMap(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric && fieldTypeGeneric.getInnerTypeArguments().size() == 2)) { @@ -405,7 +427,8 @@ private static boolean tryAddMapConsumer(FieldDto result, String fieldName, Type fieldTypeGeneric.getInnerTypeArguments().get(0), fieldTypeGeneric.getInnerTypeArguments().get(1)); MethodDto mapConsumerWithBuilder = - BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTargetTypeName); + BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, builderTargetTypeName, builderType); result.addMethod(mapConsumerWithBuilder); return true; } @@ -416,6 +439,7 @@ private static boolean tryAddSetConsumer( String fieldName, TypeName fieldType, VariableElement fieldParameter, + TypeName builderType, ProcessingContext context) { if (!(isSet(fieldType) && fieldType instanceof TypeNameGeneric fieldTypeGeneric @@ -434,34 +458,40 @@ private static boolean tryAddSetConsumer( if (elementBuilderType.isPresent()) { // Element type has a builder - use HashSetBuilderWithElementBuilders - TypeName builderType = + TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(HashSetBuilderWithElementBuilders.class), elementType, elementBuilderType.get()); result.addMethod( - createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get())); + createFieldConsumerWithElementBuilders( + fieldName, collectionBuilderType, elementBuilderType.get(), builderType)); } else { // Regular HashSetBuilder - TypeName builderType = map2TypeName(HashSetBuilder.class); - result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType)); + TypeName collectionBuilderType = map2TypeName(HashSetBuilder.class); + result.addMethod( + createFieldConsumerWithBuilder( + fieldName, collectionBuilderType, elementType, builderType)); } return true; } private static void addSupplierMethodsForField( - FieldDto result, String fieldName, TypeName fieldType, TypeElement fieldTypeElement) { + FieldDto result, + String fieldName, + TypeName fieldType, + TypeElement fieldTypeElement, + TypeName builderType) { // Skip supplier generation for functional interfaces if (isFunctionalInterface(fieldTypeElement)) { return; } - // For all fields including Optional, use the real field type for suppliers - result.addMethod(createFieldSupplier(fieldName, fieldType)); + result.addMethod(createFieldSupplier(fieldName, fieldType, builderType)); } private static Optional createFieldFromSetter( - ExecutableElement mth, ProcessingContext context) { + ExecutableElement mth, TypeName builderType, ProcessingContext context) { String methodName = mth.getSimpleName().toString(); String fieldName = StringUtils.uncapitalize(Strings.CI.removeStart(methodName, "set")); @@ -494,7 +524,7 @@ private static Optional createFieldFromSetter( javaDoc = fieldName; } - return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, context); + return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, builderType, context); } /** @@ -502,14 +532,18 @@ private static Optional createFieldFromSetter( * the constructor argument. */ private static Optional createFieldFromConstructor( - TypeElement dtoType, VariableElement param, ProcessingContext context) { + TypeElement annotatedType, + VariableElement param, + TypeName builderType, + ProcessingContext context) { String fieldName = param.getSimpleName().toString(); // Set javadoc (default to field name if no javadoc found) - String javaDoc = JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(dtoType), param); + String javaDoc = + JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(annotatedType), param); if (javaDoc == null) { javaDoc = fieldName; } - return createFieldDto(fieldName, javaDoc, param, dtoType, context); + return createFieldDto(fieldName, javaDoc, param, annotatedType, builderType, context); } /** @@ -528,6 +562,7 @@ private static Optional createFieldDto( String javaDoc, VariableElement param, TypeElement dtoType, + TypeName builderType, ProcessingContext context) { MethodParameterDto paramDto = map2MethodParameter(param, context); if (paramDto == null) { @@ -558,12 +593,14 @@ private static Optional createFieldDto( } // Add basic setter method with annotations - field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType, annotations)); + field.addMethod( + createFieldSetterWithTransform(fieldName, null, fieldType, annotations, builderType)); // Add consumer/supplier/helper methods - addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context); - addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement); - addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations); + addConsumerMethodsForField( + field, fieldName, fieldType, param, fieldTypeElement, builderType, context); + addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement, builderType); + addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations, builderType); return Optional.of(field); } @@ -577,8 +614,8 @@ private static Optional createFieldDto( * @return the method DTO for the setter */ private static MethodDto createFieldSetterWithTransform( - String fieldName, String transform, TypeName fieldType) { - return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of()); + String fieldName, String transform, TypeName fieldType, TypeName builderType) { + return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of(), builderType); } /** @@ -591,7 +628,11 @@ private static MethodDto createFieldSetterWithTransform( * @return the method DTO for the setter */ private static MethodDto createFieldSetterWithTransform( - String fieldName, String transform, TypeName fieldType, List annotations) { + String fieldName, + String transform, + TypeName fieldType, + List annotations, + TypeName builderType) { MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName); parameter.setParameterTypeName(fieldType); @@ -599,6 +640,7 @@ private static MethodDto createFieldSetterWithTransform( annotations.forEach(parameter::addAnnotation); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.PROXY); @@ -619,13 +661,15 @@ private static MethodDto createFieldSetterWithTransform( return methodDto; } - private static MethodDto createFieldConsumer(String fieldName, TypeName fieldType) { + private static MethodDto createFieldConsumer( + String fieldName, TypeName fieldType, TypeName builderType) { TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), fieldType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + SUFFIX_CONSUMER); parameter.setParameterTypeName(consumerType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.CONSUMER); @@ -643,8 +687,9 @@ private static MethodDto createFieldConsumer(String fieldName, TypeName fieldTyp return methodDto; } - private static MethodDto createStringBuilderConsumer(String fieldName, String transform) { - TypeName stringBuilderType = new TypeName("java.lang", "StringBuilder"); + private static MethodDto createStringBuilderConsumer( + String fieldName, String transform, TypeName builderType) { + TypeName stringBuilderType = map2TypeName(StringBuilder.class); TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), stringBuilderType); MethodParameterDto parameter = new MethodParameterDto(); @@ -666,18 +711,30 @@ private static MethodDto createStringBuilderConsumer(String fieldName, String tr methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); methodDto.addArgument("transform", transform); methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); + methodDto.setReturnType(builderType); return methodDto; } private static MethodDto createFieldConsumerWithBuilder( - String fieldName, TypeName builderType, TypeName builderTargetType) { - TypeNameGeneric builderTypeGeneric = new TypeNameGeneric(builderType, builderTargetType); - return BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTypeGeneric); + String fieldName, + TypeName consumerBuilderType, + TypeName builderTargetType, + TypeName returnBuilderType) { + TypeNameGeneric builderTypeGeneric = + new TypeNameGeneric(consumerBuilderType, builderTargetType); + return BuilderDefinitionCreator.createFieldConsumerWithBuilder( + fieldName, builderTypeGeneric, returnBuilderType); } - private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeName builderType) { + private static MethodDto createFieldConsumerWithBuilder( + String fieldName, TypeName consumerBuilderType, TypeName returnBuilderType) { return createFieldConsumerWithBuilder( - fieldName, builderType, "this.$fieldName:N.value()", "", Map.of()); + fieldName, + consumerBuilderType, + "this.$fieldName:N.value()", + "", + Map.of(), + returnBuilderType); } /** @@ -685,13 +742,17 @@ private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeNa * ArrayListBuilderWithElementBuilders and HashSetBuilderWithElementBuilders. */ private static MethodDto createFieldConsumerWithElementBuilders( - String fieldName, TypeName collectionBuilderType, TypeName elementBuilderType) { + String fieldName, + TypeName collectionBuilderType, + TypeName elementBuilderType, + TypeName returnBuilderType) { return createFieldConsumerWithBuilder( fieldName, collectionBuilderType, "this.$fieldName:N.value(), $elementBuilderType:T::create", "$elementBuilderType:T::create", - Map.of("elementBuilderType", elementBuilderType)); + Map.of("elementBuilderType", elementBuilderType), + returnBuilderType); } /** @@ -707,16 +768,19 @@ private static MethodDto createFieldConsumerWithElementBuilders( */ private static MethodDto createFieldConsumerWithBuilder( String fieldName, - TypeName builderType, + TypeName consumerBuilderType, String constructorArgsWithValue, - String constructorArgsEmpty, - Map additionalArguments) { - TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), builderType); + String additionalConstructorArgs, + Map additionalArguments, + TypeName returnBuilderType) { + TypeNameGeneric consumerType = + new TypeNameGeneric(map2TypeName(Consumer.class), consumerBuilderType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + BUILDER_SUFFIX + SUFFIX_CONSUMER); parameter.setParameterTypeName(consumerType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(returnBuilderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.CONSUMER_BY_BUILDER); @@ -727,22 +791,24 @@ private static MethodDto createFieldConsumerWithBuilder( this.$fieldName:N = $builderFieldWrapper:T.changedValue(builder.build()); return this; """ - .formatted(constructorArgsWithValue, constructorArgsEmpty)); + .formatted(constructorArgsWithValue, additionalConstructorArgs)); methodDto.addArgument(ARG_FIELD_NAME, fieldName); methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName()); - methodDto.addArgument(ARG_HELPER_TYPE, builderType); + methodDto.addArgument(ARG_HELPER_TYPE, consumerBuilderType); additionalArguments.forEach(methodDto::addArgument); methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE); return methodDto; } - private static MethodDto createFieldSupplier(String fieldName, TypeName fieldType) { + private static MethodDto createFieldSupplier( + String fieldName, TypeName fieldType, TypeName builderType) { TypeNameGeneric supplierType = new TypeNameGeneric(map2TypeName(Supplier.class), fieldType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + SUFFIX_SUPPLIER); parameter.setParameterTypeName(supplierType); MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.SUPPLIER); @@ -758,8 +824,8 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp } private static MethodDto createStringFormatMethodWithTransform( - String fieldName, String transform, List annotations) { - TypeName stringType = new TypeName("java.lang", "String"); + String fieldName, String transform, List annotations, TypeName builderType) { + TypeName stringType = map2TypeName(String.class); MethodParameterDto formatParam = new MethodParameterDto(); formatParam.setParameterName("format"); @@ -773,6 +839,7 @@ private static MethodDto createStringFormatMethodWithTransform( MethodDto methodDto = new MethodDto(); methodDto.setMethodName(fieldName); + methodDto.setReturnType(builderType); methodDto.addParameter(formatParam); methodDto.addParameter(argsParam); methodDto.setModifier(Modifier.PUBLIC); @@ -898,4 +965,123 @@ private static TypeMirror extractFirstTypeArgument(TypeMirror typeMirror) { } return null; } + + /** + * Creates the "With" interface definition that allows the DTO to implement fluent modification + * methods. + * + * @param builderDef the builder definition containing type information + * @param context the processing context + * @return the nested type definition for the With interface + */ + private static NestedTypeDto createWithInterface( + BuilderDefinitionDto builderDef, ProcessingContext context) { + context.debug( + "Creating With interface for: %s", builderDef.getBuilderTypeName().getClassName()); + + NestedTypeDto withInterface = new NestedTypeDto(); + withInterface.setTypeName("With"); + withInterface.setKind(NestedTypeDto.NestedTypeKind.INTERFACE); + withInterface.setPublic(true); + withInterface.setJavadoc( + "Interface that can be implemented by the DTO to provide fluent modification methods."); + + // Create the first method: DtoType with(Consumer b) + MethodDto withConsumerMethod = createWithConsumerMethod(builderDef); + withInterface.addMethod(withConsumerMethod); + + // Create the second method: BuilderType with() + MethodDto withBuilderMethod = createWithBuilderMethod(builderDef); + withInterface.addMethod(withBuilderMethod); + + return withInterface; + } + + /** + * Creates the `DtoType with(Consumer b)` method definition. + * + * @param builderDef the builder definition + * @return the method definition + */ + private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDef) { + MethodDto method = new MethodDto(); + method.setMethodName("with"); + + // Return type is the DTO type + TypeName dtoType = builderDef.getBuildingTargetTypeName(); + method.setReturnType(dtoType); + + // Parameter: Consumer b + MethodParameterDto parameter = new MethodParameterDto(); + parameter.setParameterName("b"); + // For interface methods, we store the full type as a string + TypeNameGeneric consumerType = + new TypeNameGeneric(map2TypeName(Consumer.class), builderDef.getBuilderTypeName()); + parameter.setParameterTypeName(consumerType); + method.addParameter(parameter); + + // Add implementation with validation to catch wrong implementations + method.setCode( + """ + $builderType:T builder; + try { + builder = new $builderType:T($dtoType:T.class.cast(this)); + } catch ($classcastexception:T ex) { + throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex); + } + b.accept(builder); + return builder.build(); + """); + method.addArgument("builderType", builderDef.getBuilderTypeName()); + method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); + method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class)); + + method.setJavadoc( + """ + Applies modifications to a builder initialized from this instance and returns the built object. + + @param b the consumer to apply modifications + @return the modified instance + """); + + return method; + } + + /** + * Creates the `BuilderType with()` method definition. + * + * @param builderDef the builder definition + * @return the method definition + */ + private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef) { + MethodDto method = new MethodDto(); + method.setMethodName("with"); + + // Return type is the Builder type + method.setReturnType(builderDef.getBuilderTypeName()); + + // Add implementation with validation to catch wrong implementations + method.setCode( + """ + try { + return new $builderType:T($dtoType:T.class.cast(this)); + } catch ($classcastexception:T ex) { + throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex); + } + """); + method.addArgument("builderType", builderDef.getBuilderTypeName()); + method.addArgument("dtoType", builderDef.getBuildingTargetTypeName()); + method.addArgument("classcastexception", map2TypeName(ClassCastException.class)); + method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class)); + + method.setJavadoc( + """ + Creates a builder initialized from this instance. + + @return a builder initialized with this instance's values + """); + + return method; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java index 017d972a..4654de9a 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java @@ -51,6 +51,9 @@ /** JavaCodeGenerator generates with BuilderDefinitionDto JavaCode for the builder. */ public class JavaCodeGenerator { /** Util class for source code generation of type {@code javax.annotation.processing.Filer}. */ + private static final String METHOD_NAME_CREATE = "create"; + + private static final String THROW_EXCEPTION_FORMAT = "throw new $T($S)"; private final Filer filer; /** Logger for debug output during code generation. */ @@ -149,6 +152,13 @@ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderExcep classBuilder.addMethod(createMethodConditional(builderTypeName)); classBuilder.addMethod(createMethodConditionalPositiveOnly(builderTypeName)); + // Adding nested types (e.g., With interface) + for (NestedTypeDto nestedType : builderDef.getNestedTypes()) { + TypeSpec nestedTypeSpec = createNestedType(nestedType); + classBuilder.addType(nestedTypeSpec); + logger.debug(" Generated nested type: %s", nestedType.getTypeName()); + } + // Adding annotations classBuilder.addAnnotation(createAnnotationGenerated()); classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoBaseClass)); @@ -246,7 +256,7 @@ private void addFieldInitializationWithValidation( if (field.isNonNullable()) { cb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalArgumentException.class, "Cannot initialize builder from instance: field '" + field.getFieldName() @@ -292,13 +302,13 @@ private MethodSpec createMethodBuild( if (field.isNonNullable()) { mb.beginControlFlow("if (!this.$N.isSet())", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Required field '" + field.getFieldName() + "' must be set before calling build()") .endControlFlow(); mb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Field '" + field.getFieldName() @@ -316,7 +326,7 @@ private MethodSpec createMethodBuild( field.getFieldName(), field.getFieldName()) .addStatement( - "throw new $T($S)", + THROW_EXCEPTION_FORMAT, IllegalStateException.class, "Field '" + field.getFieldName() @@ -353,7 +363,7 @@ private MethodSpec createMethodStaticCreate( com.palantir.javapoet.ClassName dtoBaseClass, List generics) { MethodSpec.Builder methodBuilder = - MethodSpec.methodBuilder("create") + MethodSpec.methodBuilder(METHOD_NAME_CREATE) .addModifiers(STATIC, PUBLIC) .addJavadoc( """ @@ -430,6 +440,80 @@ private MethodSpec createMethodConditionalPositiveOnly( .build(); } + /** + * Creates a TypeSpec for a nested type (e.g., With interface). + * + * @param nestedType the nested type definition + * @return the TypeSpec for the nested type + */ + private TypeSpec createNestedType(NestedTypeDto nestedType) { + TypeSpec.Builder typeBuilder; + + boolean isInterface = nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE; + if (isInterface) { + typeBuilder = TypeSpec.interfaceBuilder(nestedType.getTypeName()); + } else { + typeBuilder = TypeSpec.classBuilder(nestedType.getTypeName()); + } + + if (nestedType.isPublic()) { + typeBuilder.addModifiers(PUBLIC); + } + + if (nestedType.getJavadoc() != null) { + typeBuilder.addJavadoc(nestedType.getJavadoc()); + } + + // Add methods to the nested type + for (MethodDto method : nestedType.getMethods()) { + MethodSpec methodSpec = createNestedTypeMethod(method, isInterface); + typeBuilder.addMethod(methodSpec); + } + + return typeBuilder.build(); + } + + /** + * Creates a MethodSpec for a method of a nested type (e.g., With interface). + * + * @param methodDto the method to create + * @param isInterface whether the nested type is an interface + * @return the MethodSpec + */ + private MethodSpec createNestedTypeMethod(MethodDto methodDto, boolean isInterface) { + MethodSpec.Builder methodBuilder = + MethodSpec.methodBuilder(methodDto.getMethodName()).addModifiers(PUBLIC); + + // Set return type using mapper + methodBuilder.returns(JavapoetMapper.map2ParameterType(methodDto.getReturnType())); + + // Add parameters using mapper + for (MethodParameterDto paramDto : methodDto.getParameters()) { + methodBuilder.addParameter(createParameter(paramDto)); + } + + // Add modifiers if defined + methodDto.getModifier().ifPresent(methodBuilder::addModifiers); + + // Add Javadoc + if (methodDto.getJavadoc() != null) { + methodBuilder.addJavadoc(methodDto.getJavadoc()); + } + + // Add method body if present + MethodCodeDto codeDto = methodDto.getMethodCodeDto(); + if (codeDto != null) { + // Add default modifier for interface methods with implementation + if (isInterface) { + methodBuilder.addModifiers(javax.lang.model.element.Modifier.DEFAULT); + } + + methodBuilder.addCode(map2CodeBlock(codeDto)); + } + + return methodBuilder.build(); + } + private List createFieldMethods( FieldDto fieldDto, com.palantir.javapoet.TypeName builderTypeName) { return fieldDto.getMethods().stream() diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java index bc33c47a..1230f537 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java @@ -363,14 +363,13 @@ public static Optional findGetterForField( if (dtoType == null || fieldName == null || fieldTypeMirror == null) { return Optional.empty(); } - String cap = StringUtils.capitalize(fieldName); - String getterCandidate = "get" + cap; - String booleanGetterCandidate = "is" + cap; List classMethods = ElementFilter.methodsIn(context.getAllMembers(dtoType)); - // Prefer boolean-style getter if present + + // Check for accessor methods: + // Record-style (fieldName), boolean-style (isXxx), or standard (getXxx) for (ExecutableElement candidate : classMethods) { String name = candidate.getSimpleName().toString(); - if ((name.equals(booleanGetterCandidate) || name.equals(getterCandidate)) + if (Strings.CI.equalsAny(name, fieldName, "is" + fieldName, "get" + fieldName) && candidate.getParameters().isEmpty() && context.isSameType(candidate.getReturnType(), fieldTypeMirror)) { return Optional.of(candidate); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java index 58ebf185..20001890 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java @@ -35,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.processor.dtos.*; /** Helper functions to create JavaPoet types from DTOs of simple builder. */ @@ -106,7 +107,11 @@ public static TypeName map2ParameterType( */ public static ClassName map2ClassName( org.javahelpers.simple.builders.processor.dtos.TypeName typeName) { - return ClassName.get(typeName.getPackageName(), typeName.getClassName()); + if (StringUtils.isNoneEmpty(typeName.getPackageName())) { + return ClassName.get(typeName.getPackageName(), typeName.getClassName()); + } else { + return ClassName.bestGuess(typeName.getClassName()); + } } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index 606b5404..b795d1bc 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -1,10 +1,11 @@ package org.javahelpers.simple.builders.processor; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; import com.google.testing.compile.Compilation; -import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; @@ -14,9 +15,9 @@ class AnnotationCopyTest { private Compilation compileSources(JavaFileObject... sources) { - BuilderProcessor processor = new BuilderProcessor(); - Compiler compiler = Compiler.javac().withProcessors(processor); - return compiler.compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java index 7ed759e4..c6e917a0 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java @@ -1,10 +1,11 @@ package org.javahelpers.simple.builders.processor; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; import com.google.testing.compile.Compilation; -import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; @@ -14,7 +15,9 @@ class ConditionalExecutionTest { private Compilation compileSources(JavaFileObject... sources) { - return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java index 41a1e3ea..022b5618 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java @@ -25,10 +25,11 @@ package org.javahelpers.simple.builders.processor; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; import com.google.testing.compile.Compilation; -import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; @@ -38,7 +39,9 @@ class NullConstraintTest { private Compilation compileSources(JavaFileObject... sources) { - return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java index dcc6002b..24dc31e1 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java @@ -1,10 +1,11 @@ package org.javahelpers.simple.builders.processor; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; import com.google.testing.compile.Compilation; -import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; @@ -13,9 +14,9 @@ class ReadmeExampleTest { private Compilation compileSources(JavaFileObject... sources) { - BuilderProcessor processor = new BuilderProcessor(); - Compiler compiler = Compiler.javac().withProcessors(processor); - return compiler.compile(sources); + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); + return compilation; } @Test diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java new file mode 100644 index 00000000..3dd32698 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java @@ -0,0 +1,232 @@ +package org.javahelpers.simple.builders.processor; + +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.junit.jupiter.api.Test; + +/** Tests for With interface generation in builders. */ +class WithInterfaceTest { + + private Compilation compileSources(JavaFileObject... sources) { + Compilation compilation = createCompiler().compile(sources); + printDiagnosticsOnVerbose(compilation); // Print diagnostics when verbose mode is enabled + return compilation; + } + + @Test + void withInterface_generatedInBuilder() { + String packageName = "test.withinterface"; + + JavaFileObject project = + JavaFileObjects.forSourceString( + packageName + ".Project", + """ + package test.withinterface; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Project { + private String name; + private String description; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + """); + + Compilation compilation = compileSources(project); + String generatedCode = loadGeneratedSource(compilation, "ProjectBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ProjectBuilder", generatedCode); + + // Verify complete With interface is generated with default implementations + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default Project with(Consumer b) { + ProjectBuilder builder; + try { + builder = new ProjectBuilder(Project.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ProjectBuilder with() { + try { + return new ProjectBuilder(Project.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex); + } + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); + } + + @Test + void withInterface_worksWithConstructorFields() { + String packageName = "test.withinterface.constructor"; + + JavaFileObject user = + JavaFileObjects.forSourceString( + packageName + ".User", + """ + package test.withinterface.constructor; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class User { + private final String username; + private String email; + + public User(String username) { + this.username = username; + } + + public String getUsername() { return username; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + } + """); + + Compilation compilation = compileSources(user); + String generatedCode = loadGeneratedSource(compilation, "UserBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "UserBuilder", generatedCode); + + // Verify complete With interface with default implementations for constructor fields + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default User with(Consumer b) { + UserBuilder builder; + try { + builder = new UserBuilder(User.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default UserBuilder with() { + try { + return new UserBuilder(User.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex); + } + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); + } + + @Test + void withInterface_correctTypeNames() { + String packageName = "test.withinterface.types"; + + JavaFileObject config = + JavaFileObjects.forSourceString( + packageName + ".Config", + """ + package test.withinterface.types; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class Config { + private int timeout; + private boolean enabled; + + public int getTimeout() { return timeout; } + public void setTimeout(int timeout) { this.timeout = timeout; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + } + """); + + Compilation compilation = compileSources(config); + String generatedCode = loadGeneratedSource(compilation, "ConfigBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ConfigBuilder", generatedCode); + + // Verify complete With interface with correct type names (primitives) + String expectedWithInterface = + """ + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Applies modifications to a builder initialized from this instance and returns the built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default Config with(Consumer b) { + ConfigBuilder builder; + try { + builder = new ConfigBuilder(Config.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ConfigBuilder with() { + try { + return new ConfigBuilder(Config.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex); + } + } + } + """; + + ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface)); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java index 589ee87b..b1810942 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java @@ -1,11 +1,14 @@ package org.javahelpers.simple.builders.processor.testing; import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.tools.JavaFileObject; +import org.apache.commons.lang3.Strings; +import org.javahelpers.simple.builders.processor.BuilderProcessor; /** * Utilities to simplify annotation-processor tests by reducing boilerplate for building sources, @@ -15,6 +18,115 @@ public final class ProcessorTestUtils { private ProcessorTestUtils() {} + /** + * Creates a configured {@link Compiler} instance with the BuilderProcessor. + * + *

This method checks for the system property {@code simplebuilder.verbose} (or {@code + * Averbose}) and automatically adds {@code -Averbose=true} to the compiler options if either is + * set to "true". + * + *

This allows developers to enable verbose processor output for all tests by running: {@code + * mvn test -Dsimplebuilder.verbose=true} + * + * @return a Compiler instance configured with BuilderProcessor and optional verbose output + */ + public static Compiler createCompiler() { + Compiler compiler = Compiler.javac().withProcessors(new BuilderProcessor()); + + // Check for verbose flag from Maven property + if (isVerboseEnabled()) { + compiler = compiler.withOptions("-Averbose=true"); + } + + return compiler; + } + + /** + * Checks if verbose mode is enabled via system properties. + * + * @return true if simplebuilder.verbose or Averbose is set to "true" + */ + public static boolean isVerboseEnabled() { + String verboseProperty = System.getProperty("simplebuilder.verbose"); + String averboseProperty = System.getProperty("Averbose"); + return Strings.CI.equalsAny("true", verboseProperty, averboseProperty); + } + + /** + * Prints compilation diagnostics (notes, warnings, errors) and generated source files to + * System.out if verbose mode is enabled. + * + *

This is useful for debugging test failures, as it makes the processor's debug output and + * generated code visible in the test console output and CI logs. + * + * @param compilation the compilation result to print diagnostics from + */ + public static void printDiagnosticsOnVerbose(Compilation compilation) { + if (!isVerboseEnabled()) { + return; + } + + System.out.println("\n========== Compilation Diagnostics =========="); + + // Print notes (includes debug messages) + if (!compilation.notes().isEmpty()) { + System.out.println("--- NOTES ---"); + compilation.notes().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + // Print warnings + if (!compilation.warnings().isEmpty()) { + System.out.println("\n--- WARNINGS ---"); + compilation.warnings().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + // Print errors + if (!compilation.errors().isEmpty()) { + System.out.println("\n--- ERRORS ---"); + compilation.errors().forEach(diag -> System.out.println(diag.getMessage(null))); + } + + System.out.println("=============================================\n"); + + // Print generated source files + printGeneratedSourcesOnVerbose(compilation); + } + + /** + * Prints all generated source files to System.out if verbose mode is enabled. + * + *

This displays the actual generated code before assertions run, making it easy to compare + * expected vs actual output without debugging. + * + * @param compilation the compilation result containing generated files + */ + public static void printGeneratedSourcesOnVerbose(Compilation compilation) { + if (!isVerboseEnabled()) { + return; + } + + var generatedFiles = compilation.generatedSourceFiles(); + if (generatedFiles.isEmpty()) { + System.out.println("========== No Source Files Generated ==========\n"); + return; + } + + System.out.println("========== Generated Source Files =========="); + generatedFiles.forEach( + file -> { + try { + String fileName = file.getName(); + String content = file.getCharContent(false).toString(); + System.out.println("\n--- " + fileName + " ---"); + System.out.println(content); + System.out.println("--- End of " + fileName + " ---"); + } catch (Exception e) { + System.err.println("Failed to read generated file: " + e.getMessage()); + } + }); + System.out.println("=============================================\n"); + } + /** * Creates a {@link JavaFileObject} for a simple class annotated with @SimpleBuilder. You pass the * inner body lines (fields/methods); imports and annotation are handled for you.