diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dc187388..65afe043 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1215,7 +1215,7 @@ processing. A summary report is logged to the compiler output at the end of proc **When enabled**: A hierarchical performance report is printed to the compiler log, including: - Total processing time and average time per class -- Phase breakdown (Element Collection, Configuration Resolution, Builder Definition Extraction, DTO Mapping, Code Generation) +- Phase breakdown (Element Collection, Configuration Resolution, Analysis, Target Mapping, Code Generation) - Top 20 slowest classes with field and collection counts - Top 5 slowest MethodGenerators and BuilderEnhancers diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 11013bca..6b8eed9c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -242,19 +242,39 @@ For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). [DEBUG] simple-builders: Processing round started. [DEBUG] simple-builders: Found 1 annotated elements. [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. +[DEBUG] simple-builders: Processing element: Project +[DEBUG] simple-builders: ├─ Resolving configuration for element: Project +[DEBUG] simple-builders: │ └─ Resulting configuration resolved: BuilderConfiguration[builderSuffix=Builder,...] [DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the builderGenerationPackages scope. -[DEBUG] Processing element: Project -[DEBUG] ├─ Extracting builder definition from: test.Project -[DEBUG] │ ├─ Builder will be generated as: test.ProjectBuilder -[DEBUG] │ ├─ Analysing setters for finding fields -[DEBUG] │ │ ├─ Analyzing method: setName(java.lang.String) -[DEBUG] │ │ │ └─ Adding field: name (type: java.lang.String) -[DEBUG] │ └─ Processed 1 possible setters: added 1 fields, skipped 0 -[DEBUG] ├─ Code generation for builder: ProjectBuilder -[DEBUG] │ ├─ Adding Methods for 4 candidates -[DEBUG] │ │ └─ 4 Methods added -[DEBUG] │ └─ Successfully generated builder: ProjectBuilder -simple-builders: Successfully generated 1 builder(s) in this processing round +[DEBUG] simple-builders: Processing element: Project +[DEBUG] simple-builders: ├─ Extracting builder definition from: test.Project +[DEBUG] simple-builders: │ ├─ Builder will be generated as: test.ProjectBuilder +[DEBUG] simple-builders: │ ├─ Analysing setters for finding fields +[DEBUG] simple-builders: │ │ ├─ Analyzing method: setName(java.lang.String) +[DEBUG] simple-builders: │ │ │ ├─ Processing method generators +[DEBUG] simple-builders: │ │ │ │ ├─ Applied: BasicSetterGenerator (priority: 100): +1 methods +[DEBUG] simple-builders: │ │ │ │ └─ Generated 6 methods +[DEBUG] simple-builders: │ │ │ └─ Adding field: name (type: java.lang.String) +[DEBUG] simple-builders: │ │ └─ Processed 1 possible setters: added 1 fields, skipped 0 +[DEBUG] simple-builders: │ ├─ Processing class based enhancer +[DEBUG] simple-builders: │ │ ├─ Applied: CoreMethodsEnhancer (priority: 100): +3 methods +[DEBUG] simple-builders: │ │ ├─ Applied: ConstructorEnhancer (priority: 95): +2 constructors +[DEBUG] simple-builders: │ │ └─ Applied 8 builder enhancers +[DEBUG] simple-builders: │ ├─ Finalizing builder definition +[DEBUG] simple-builders: │ │ └─ Finalized: 1 class fields, 4 builder-level methods, 2 constructors +[DEBUG] simple-builders: │ └─ Builder definition extracted: ProjectBuilder +[DEBUG] simple-builders: Generating targets for: Project +[DEBUG] simple-builders: ├─ Code generation for class: ProjectBuilder +[DEBUG] simple-builders: │ ├─ Generating 1 fields +[DEBUG] simple-builders: │ │ └─ Fields added: 1 fields +[DEBUG] simple-builders: │ ├─ Generating 4 method candidates +[DEBUG] simple-builders: │ │ ├─ Resolved to 4 methods after conflict resolution +[DEBUG] simple-builders: │ │ └─ Methods added: 4 +[DEBUG] simple-builders: │ ├─ Creating JavaFileObject: test.ProjectBuilder +[DEBUG] simple-builders: │ ├─ Writing class to file: /SOURCE_OUTPUT/test/ProjectBuilder.java +[DEBUG] simple-builders: │ ├─ Successfully generated class: ProjectBuilder +[DEBUG] simple-builders: │ └─ Generated builder with 1 fields and 4 methods for ProjectBuilder + simple-builders: Successfully generated 1 target(s) in this processing round ============================================= ========== Generated Source Files ========== diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index 2a056da4..e6044d72 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -73,67 +73,124 @@ The Simple Builders processor provides **conditional hierarchical logging** to b ### **When Debug Mode is DISABLED (Default):** - INFO and WARNING messages appear flat without indentation - Suitable for production systems where log noise should be minimized -- Example: `simple-builders: Successfully generated 3 builder(s) in this processing round` +- Example: `simple-builders: Successfully generated 3 target(s) in this processing round` ### **When Debug Mode is ENABLED (-Averbose=true):** -- INFO and WARNING messages have no extra prefix, DEBUG messages are posted on `[INFO]` chanel so they have the prefix `[INFO] [DEBUG]` +- Every message carries the `simple-builders:` logger prefix; DEBUG messages are posted on the `[INFO]` channel so they show as `[INFO] [DEBUG]` - Provides full visibility into the processing hierarchy -- Example: `[INFO] │ └─ simple-builders: Successfully generated 3 builder(s) in this processing round` -- Example: `[WARNING] │ │ ├─ Builder field conflict: field 'name'...` -- Example: `[INFO] [DEBUG] │ │ │ └─ Processing method: setName` +- Example: `[INFO] simple-builders: │ └─ Successfully generated 3 target(s) in this processing round` +- Example: `[WARNING] simple-builders: │ │ ├─ Builder field conflict: field 'name'...` +- Example: `[INFO] [DEBUG] simple-builders: │ │ │ └─ Processing method: setName` - All message types align vertically despite different prefix lengths This approach ensures clean production logs while maintaining full debugging capabilities when needed. ## Example Debug Output -When debug logging is enabled, you'll see detailed output with visual separators: +The following is a verbatim excerpt of a real run (`mvn -pl example clean compile -Dsimplebuilder.verbose=true` on the `example` module, slightly truncated where marked with `…`). It follows `SponsorDto`, the smallest annotated class (one field). Every annotated element produces the same step structure — extraction, enhancer pass, finalize, code generation: ``` +[INFO] [DEBUG] simple-builders: Starting BuilderProcessor... +[INFO] [DEBUG] simple-builders: Loaded global configuration from compiler arguments: BuilderConfiguration[verbose=ENABLED] +[INFO] [DEBUG] simple-builders: Initializing generator registry +[INFO] [DEBUG] simple-builders: ├─ Loaded 16 method generators and 10 builder enhancers total +[INFO] [DEBUG] simple-builders: └─ Initialized GeneratorRegistry with 16 method generators and 10 builder enhancers [INFO] simple-builders: PROCESSING ROUND START [INFO] [DEBUG] simple-builders: Processing round started. -[INFO] [DEBUG] simple-builders: Found 3 annotated elements. -[INFO] [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. -[INFO] [DEBUG] simple-builders: 3 of 3 annotated element(s) are inside the builderGenerationPackages scope. -[INFO] [DEBUG] Processing element: PersonDto -[INFO] [DEBUG] ├─ Extracting builder definition from: org.example.PersonDto -[INFO] [DEBUG] │ ├─ Builder will be generated as: org.example.PersonDtoBuilder -[INFO] [DEBUG] │ ├─ Analysing setters for finding fields -[INFO] [DEBUG] │ │ ├─ Analyzing method: setName(java.lang.String) -[INFO] [DEBUG] │ │ │ └─ Adding field: name (type: java.lang.String) -[INFO] [DEBUG] │ │ ├─ Analyzing method: setAge(int) -[INFO] [DEBUG] │ │ │ └─ Adding field: age (type: int) -[INFO] [DEBUG] │ └─ Processed 2 possible setters: added 2 fields, skipped 0 -[INFO] [DEBUG] ├─ Code generation for builder: PersonDtoBuilder -[INFO] [DEBUG] │ ├─ Class builder created -[INFO] [DEBUG] │ ├─ Generating 0 constructor fields and 2 setter fields -[INFO] [DEBUG] │ │ └─ Fields added: 2 fields -[INFO] [DEBUG] │ ├─ Adding Methods for 4 candidates -[INFO] [DEBUG] │ │ └─ 4 Methods added -[INFO] [DEBUG] │ ├─ Using JDT source formatting with Eclipse formatter profile 'eclipse-java-format.xml'. -[INFO] [DEBUG] │ ├─ Writing builder class to file: org.example.PersonDtoBuilder -[INFO] [DEBUG] │ └─ Successfully generated builder: PersonDtoBuilder -[INFO] [DEBUG] Processing element: OrderDto -[INFO] [DEBUG] ├─ Extracting builder definition from: org.example.OrderDto -[INFO] [DEBUG] │ └─ Builder will be generated as: org.example.OrderDtoBuilder -[INFO] [DEBUG] │ └─ Processed 1 possible setters: added 1 fields, skipped 0 -[INFO] [DEBUG] ├─ Code generation for builder: OrderDtoBuilder -[INFO] [DEBUG] │ └─ Successfully generated builder: OrderDtoBuilder -[INFO] [DEBUG] Processing element: CustomerDto (with conflicts) -[INFO] [DEBUG] ├─ Extracting builder definition from: org.example.CustomerDto -[INFO] [DEBUG] │ └─ Builder will be generated as: org.example.CustomerDtoBuilder -[INFO] [DEBUG] │ └─ Processed 2 possible setters: added 2 fields, skipped 0 -[WARNING] │ │ └─ Builder field conflict: field 'name' (type Optional) renamed to 'nameOptional' to avoid conflict -[INFO] [DEBUG] ├─ Code generation for builder: CustomerDtoBuilder -[INFO] [DEBUG] │ └─ Successfully generated builder: CustomerDtoBuilder -[INFO] simple-builders: Successfully generated 3 builder(s) in this processing round +[INFO] [DEBUG] simple-builders: Found 12 annotated elements. +[INFO] [DEBUG] simple-builders: Found 1 type(s) for generation with @SimpleBuilderFor. +[INFO] [DEBUG] simple-builders: Processing element: SponsorDto +[INFO] [DEBUG] simple-builders: ├─ Resolving configuration for element: SponsorDto +[INFO] [DEBUG] simple-builders: │ ├─ Built-in template @SimpleBuilder found in DIRECT scope +[INFO] [DEBUG] simple-builders: │ └─ Resulting configuration resolved: BuilderConfiguration[generateFieldSupplier=ENABLED,…,builderSuffix=Builder,formattingMode=jdt,strict=DISABLED,verbose=ENABLED] +… (the same configuration-resolution steps repeat for each of the 12 elements) +[INFO] [DEBUG] simple-builders: 12 of 12 annotated element(s) are inside the builderGenerationPackages scope. +[INFO] [DEBUG] simple-builders: ├─ Processing element: SponsorDto +[INFO] [DEBUG] simple-builders: │ ├─ Extracting builder definition from: org.javahelpers.simple.builders.example.SponsorDto +[INFO] [DEBUG] simple-builders: │ │ ├─ Builder will be generated as: org.javahelpers.simple.builders.example.SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ ├─ Analysing setters for finding fields +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Analyzing method: setName(java.lang.String) +[INFO] [DEBUG] simple-builders: │ │ │ │ ├─ Processing method generators +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: BasicSetterGenerator (priority: 100): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: StringFormatHelperGenerator (priority: 80): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: SupplierMethodGenerator (priority: 60): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: StringValidationGenerator (priority: 60): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: StringBuilderConsumerGenerator (priority: 45): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ ├─ Applied: UpdateHelperGenerator (priority: 31): +1 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ │ └─ Generated 6 methods +[INFO] [DEBUG] simple-builders: │ │ │ │ └─ Adding field: name (type: java.lang.String) +[INFO] [DEBUG] simple-builders: │ │ │ └─ Processed 1 possible setters: added 1 fields, skipped 0 +[INFO] [DEBUG] simple-builders: │ │ ├─ Processing class based enhancer +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: GeneratedAnnotationEnhancer (priority: 120): +1 annotations +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Added @BuilderImplementation annotation to builder SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: BuilderImplementationAnnotationEnhancer (priority: 115): +1 annotations +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: CoreMethodsEnhancer (priority: 100): +3 methods +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: ConstructorEnhancer (priority: 95): +2 constructors +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: WithInterfaceEnhancer (priority: 95): +1 nested types +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: InterfaceEnhancer (priority: 90): +1 interfaces +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Added conditional methods to builder SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: ConditionalEnhancer (priority: 80): +2 methods +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Applied: ClassJavaDocEnhancer (priority: 10): javadoc added +[INFO] [DEBUG] simple-builders: │ │ │ └─ Applied 8 builder enhancers +[INFO] [DEBUG] simple-builders: │ │ ├─ Finalizing builder definition +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Resolving method conflicts +[INFO] [DEBUG] simple-builders: │ │ │ │ └─ Resolved: 11 signatures, 0 conflicts, 0 methods removed +[INFO] [DEBUG] simple-builders: │ │ │ └─ Finalized: 1 class fields, 5 builder-level methods, 2 constructors +[INFO] [DEBUG] simple-builders: │ │ └─ Builder definition extracted: SponsorDtoBuilder +… (the same extraction steps repeat for each remaining element) +[INFO] [DEBUG] simple-builders: ├─ Generating targets for: SponsorDto +[INFO] [DEBUG] simple-builders: │ ├─ Code generation for class: SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ ├─ JavaClassSource created +[INFO] [DEBUG] simple-builders: │ │ ├─ Class metadata added +[INFO] [DEBUG] simple-builders: │ │ ├─ Generating 1 fields +[INFO] [DEBUG] simple-builders: │ │ │ └─ Fields added: 1 fields +[INFO] [DEBUG] simple-builders: │ │ ├─ Generating 2 constructors +[INFO] [DEBUG] simple-builders: │ │ │ └─ Constructors added: 2 +[INFO] [DEBUG] simple-builders: │ │ ├─ Generating 11 method candidates +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Resolved to 11 methods after conflict resolution +[INFO] [DEBUG] simple-builders: │ │ │ └─ Methods added: 11 +[INFO] [DEBUG] simple-builders: │ │ ├─ Generating 1 nested type(s) +[INFO] [DEBUG] simple-builders: │ │ │ ├─ Generated nested type: With +[INFO] [DEBUG] simple-builders: │ │ │ └─ Nested types added +[INFO] [DEBUG] simple-builders: │ │ ├─ Class-level annotations added +[INFO] [DEBUG] simple-builders: │ │ ├─ Creating JavaFileObject: org.javahelpers.simple.builders.example.SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ ├─ Writing class to file: …/example/generated-example-builder/org/javahelpers/simple/builders/example/SponsorDtoBuilder.java +[INFO] [DEBUG] simple-builders: │ │ ├─ Successfully generated class: SponsorDtoBuilder +[INFO] [DEBUG] simple-builders: │ │ └─ Generated builder with 1 fields and 11 methods for SponsorDtoBuilder +… (the same code-generation steps repeat for each remaining element; elements whose + configuration enables the Jackson module additionally emit a registration line inside the + same generation scope:) +[INFO] [DEBUG] simple-builders: │ │ ├─ Registered JacksonIntegrationDtoBuilder for Jackson module in package 'org.javahelpers.simple.builders.example' +[INFO] simple-builders: Successfully generated 12 target(s) in this processing round [INFO] simple-builders: PROCESSING ROUND START [INFO] [DEBUG] simple-builders: Processing round started. -[INFO] [DEBUG] simple-builders: Found 0 annotated elements. -[INFO] [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. -[INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. +[INFO] [DEBUG] simple-builders: No matching elements collected — skipping planning for this round. +… (empty rounds repeat until processing finishes) +[INFO] simple-builders: Processing OVER. JacksonModuleEnabled: true, Packages: 1 +[INFO] simple-builders: Generating Jackson module in package 'org.javahelpers.simple.builders.example' +[INFO] [DEBUG] simple-builders: Code generation for class: SimpleBuildersJacksonModule +[INFO] [DEBUG] simple-builders: ├─ JavaClassSource created +[INFO] [DEBUG] simple-builders: ├─ Class metadata added +[INFO] [DEBUG] simple-builders: ├─ Generating 1 constructors +[INFO] [DEBUG] simple-builders: │ └─ Constructors added: 1 +[INFO] [DEBUG] simple-builders: ├─ Generating 1 nested type(s) +[INFO] [DEBUG] simple-builders: │ ├─ Generated nested type: JacksonIntegrationDtoMixin +[INFO] [DEBUG] simple-builders: │ └─ Nested types added +[INFO] [DEBUG] simple-builders: ├─ Creating JavaFileObject: org.javahelpers.simple.builders.example.SimpleBuildersJacksonModule +[WARNING] File for type 'org.javahelpers.simple.builders.example.SimpleBuildersJacksonModule' created in the last round will not be subject to annotation processing. +[INFO] [DEBUG] simple-builders: ├─ Writing class to file: …/example/generated-example-builder/org/javahelpers/simple/builders/example/SimpleBuildersJacksonModule.java +[INFO] [DEBUG] simple-builders: ├─ Successfully generated class: SimpleBuildersJacksonModule ``` +Notes on individual lines: + +- `Applied: (priority: N): +M ` — logged **after** each generator/enhancer ran, with what it actually contributed (methods, constructors, nested types, annotations, interfaces, or javadoc). An enhancer that adds nothing measurable prints just `Applied: (priority: N)` with no suffix. +- `Writing class to file:` — the real path of the generated source file, resolved via the filer's `JavaFileObject.toUri()`. +- `Registered for Jackson module in package ''` — emitted inside the class's generation scope by the Jackson module generator, only when the builder actually registered for module generation (i.e. `generateJacksonModule` enabled on that element's configuration and Jackson on the classpath). It is not a generation step of its own. +- `Generated builder with N fields and M methods for ` — the domain's summary line, emitted as the closing line (`└─`) of the `Code generation for class` scope, which stays open until the post-generation step ran. +- A round or element that generates nothing logs why, for error analysis: `No matching elements collected — skipping planning for this round.`, `Processing is already finished — skipping planning for this round.`, `Planning produced no tasks — nothing to generate this round.`, or per element `Analysis produced no targets for `. +- Code-generation phases with nothing to emit log nothing — a class without fields, methods or nested types (like `SimpleBuildersJacksonModule`, which only has a constructor and a mixin) produces no `Generating 0 …` / `added: 0` noise. + **Note**: Debug messages are prefixed with `[DEBUG]` and use `Diagnostic.Kind.OTHER` which appears as `[INFO]` in Maven output. ## Normal Output (Without Debug) @@ -142,7 +199,7 @@ Without debug logging enabled, you only see the INFO-level messages: ``` [INFO] simple-builders: PROCESSING ROUND START -[INFO] simple-builders: Successfully generated 3 builder(s) in this processing round +[INFO] simple-builders: Successfully generated 3 target(s) in this processing round ``` ## Troubleshooting diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/AbstractCodegenProcessor.java b/processor/src/main/java/org/javahelpers/codegen/processing/AbstractCodegenProcessor.java new file mode 100644 index 00000000..19253bd7 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/AbstractCodegenProcessor.java @@ -0,0 +1,683 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.TypeElement; +import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper; +import org.javahelpers.simple.builders.processor.classgen.ClassCodeGenerator; +import org.javahelpers.simple.builders.processor.classgen.CodeGenerationException; +import org.javahelpers.simple.builders.processor.classgen.GenerationEnvironment; +import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; +import org.javahelpers.simple.builders.processor.model.core.GenerationTargetClassDto; +import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.javahelpers.simple.builders.processor.processing.AnnotationProcessingContext; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.javahelpers.simple.builders.processor.processing.SourceFormatterResolver; +import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; +import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; + +/** + * Driver of a generic annotation-processing pipeline: the framework owns the processing lifecycle, + * the domain plugs in its hooks — collect, plan, register, analyse/map, generate, finalize. + * + *

A new processor declares its static surface once as {@link CodegenProcessorConfiguration} + * (passed to the constructor — element collector, options, source level, formatter factory) and + * implements only the domain hooks marked {@code [implement]}: {@link #createContext}, {@link + * #planTasks}, {@link #analyse} and {@link #mapToTargetSpecification}. Every other step is + * framework-owned or an optional {@code [default]} the domain may override. + * + *

The round spine: + * + *

+ *   ctx      = createContext(env, reader, logger)                       [implement — in init]
+ *   onRoundStart(ctx)                                                 [default: no-op]
+ *   elements = findElements(annotations, roundEnv)                 [default: configuration's elementCollector]
+ *   skipsRound(elements, ctx)                                  [framework; empty or finished gate]
+ *   tasks    = planTasks(elements, ctx)                                 [implement]
+ *   onTasksPlanned(tasks, ctx)                                          [default: no-op]
+ *   registerPlannedTaskResults(tasks, ctx)                                  [framework; per-task names from predictTaskResultTypeNames() — default: empty]
+ *   loop 1 — specification (guarded, strict-mode reporting):            [framework]
+ *     defs    = analyse(task, ctx)                                      [implement]
+ *     target  = mapToTargetSpecification(def, cfg, ctx)  (per def)      [implement]
+ *   loop 2 — generation:                                                [framework]
+ *     for each target:
+ *       existsGuard → engine.generate(target, formatterResolver.resolve(target.mode))  [framework]
+ *     onTaskGenerated(generation, ctx)                              [default: no-op]
+ *   endRound(ctx)                                                       [default: no-op]
+ *   last round: onProcessingOver(ctx)                                   [default: no-op]
+ * 
+ * + *

Per-element failures are caught and reported through {@link + * AnnotationProcessingContext#reportBasedOnStrictMode}, so one broken element does not abort the + * round; in strict mode they become errors. + * + * @param the domain's processing context type + * @param the domain's configuration type carried by {@link CodegenTask} + * @param the domain's definition type — the analysis result mapped to render targets + */ +public abstract class AbstractCodegenProcessor< + C extends AnnotationProcessingContext, + K extends CodegenConfiguration, + D extends CodegenDefinition> + extends AbstractProcessor { + + /** The domain's context instance. */ + protected C context; + + /** Logger for the domain's hooks (init, last-round reports). */ + protected ProcessingLogger logger; + + private final CodegenProcessorConfiguration processorConfiguration; + private ClassCodeGenerator codeGenerator; + private SourceFormatterResolver formatterResolver; + private boolean supportedJdk = true; + private boolean processedOnce; + + /** + * Creates the processor on its static configuration. javac still instantiates the concrete + * subclass through its no-arg constructor, which hands the configuration to this constructor. + * + * @param processorConfiguration the processor's static declarations + */ + protected AbstractCodegenProcessor(CodegenProcessorConfiguration processorConfiguration) { + this.processorConfiguration = processorConfiguration; + } + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + CompilerArgumentsReader reader = new CompilerArgumentsReader(processingEnv); + this.logger = createLogger(processingEnv, reader); + this.context = createContext(processingEnv, reader, logger); + this.codeGenerator = createGenerator(processingEnv, logger, context); + this.formatterResolver = createFormatterResolver(reader, logger); + + this.supportedJdk = verifySupportedJdk(); + } + + /** + * Checks the running JDK against {@link #requiredSourceVersion} and reports a descriptive error + * when it cannot meet the requirement. + * + * @return {@code true} when the running JDK supports the required source level + */ + private boolean verifySupportedJdk() { + if (requiredSourceVersion() == null) { + return true; + } + SourceVersion current = processingEnv.getSourceVersion(); + boolean supported = isAtLeast(current, requiredSourceVersion()); + if (!supported) { + String releaseNumber = requiredSourceVersion().replace("RELEASE_", ""); + context.error( + "requires Java %s or higher for annotation processing. Detected: '%s'. Please upgrade to JDK %s+ or disable the processor.", + releaseNumber, current, releaseNumber); + } + return supported; + } + + /** + * Runs one processing round — see the class javadoc for the spine. Claims the supported + * annotations only when {@link #claimsAnnotations(Set)} says so — under the default {@code "*"} + * set it does not claim, because claiming would hide foreign annotations from later processors + * (hence the {@code java:S3516} suppression). + */ + @Override + @SuppressWarnings("java:S3516") + public final boolean process(Set annotations, RoundEnvironment roundEnv) { + if (!supportedJdk) { + // Fail fast: init() already emitted the error; do not attempt any processing. + return false; + } + if (roundEnv.processingOver()) { + onProcessingOver(context); + return claimsAnnotations(getSupportedAnnotationTypes()); + } + + PerformanceTracker tracker = context.getPerformanceTracker(); + logger.info("PROCESSING ROUND START"); + onRoundStart(context); + + tracker.startPhase(); + ElementGroups collected = findElements(annotations, roundEnv); + tracker.endPhase("Element Collection"); + logger.debug("Processing round started."); + + if (skipsRound(collected, context)) { + return claimsAnnotations(getSupportedAnnotationTypes()); + } + + List> tasks = plan(collected, context); + onTasksPlanned(tasks, context); + + // First loop: specification — analyse every task and map its render targets. + List> planned = new ArrayList<>(); + for (CodegenTask task : tasks) { + context.debugStartOperation("Processing element: " + task.element().getSimpleName()); + tracker.startClass(task.element().getSimpleName().toString()); + try { + context.setCurrentTask(task); + planned.addAll(specify(task, context, tracker)); + } catch (Exception ex) { + // By default generation failures are warnings so other targets are still generated. + // In opt-in strict mode they are promoted to errors that fail the build. + context.reportBasedOnStrictMode( + task.reportingElement(), "Failed to generate target - %s", ex.getMessage()); + } finally { + context.debugEndOperation(); + } + } + + // Second loop: generation — every specification exists before any file is written. + int successfulGenerations = 0; + for (PlannedGeneration work : planned) { + // generateTarget leaves its generation-scope operation open so onTaskGenerated can close it + // with a summary line — restore the pre-task indentation whatever the domain did. + int preTaskIndentation = logger.getCurrentIndentationLevel(); + context.debugStartOperation( + "Generating targets for: " + work.task().element().getSimpleName()); + try { + context.setCurrentTask(work.task()); + tracker.startPhase(); + generateTarget(work.target()); + tracker.endPhase("Code Generation"); + onTaskGenerated(work, context); + successfulGenerations++; + } catch (Exception ex) { + context.reportBasedOnStrictMode( + work.task().reportingElement(), "Failed to generate target - %s", ex.getMessage()); + } finally { + while (logger.getCurrentIndentationLevel() > preTaskIndentation) { + context.debugEndOperation(); + } + } + } + + if (successfulGenerations > 0) { + logger.info( + "Successfully generated %d target(s) in this processing round", successfulGenerations); + } + + // Reset indentation level at the end of each processing round to prevent cascading errors + context.resetIndentation(); + endRound(context); + return claimsAnnotations(getSupportedAnnotationTypes()); + } + + /** + * Analyses one task and maps every resulting definition to its render target, tracking both + * phases on the class-level timer the caller started. Runs inside the task's debug scope. + * + * @param task the task to specify + * @param context the processing context + * @param tracker the round's performance tracker + * @return the planned generations of this task (possibly empty) + * @throws Exception forwarded from the domain's {@link #analyse}/{@link + * #mapToTargetSpecification} + */ + private List> specify( + CodegenTask task, C context, PerformanceTracker tracker) throws Exception { + tracker.startPhase(); + List definitions; + try { + definitions = analyse(task, context); + } finally { + tracker.endPhase("Analysis"); + } + if (definitions.isEmpty()) { + context.debug("Analysis produced no targets for " + task.element().getSimpleName()); + } + List> planned = new ArrayList<>(); + for (D definition : definitions) { + tracker.startPhase(); + GenerationTargetClassDto target; + try { + target = mapToTargetSpecification(definition, task.configuration(), context); + } finally { + tracker.endPhase("Target Mapping"); + } + planned.add(new PlannedGeneration<>(task, definition, target)); + } + return planned; + } + + @Override + public final Set getSupportedOptions() { + return optionRegistry().supportedOptions(); + } + + @Override + public final Set getSupportedAnnotationTypes() { + return supportedAnnotationTypesByAnnotation().orElse(Set.of("*")); + } + + /** + * The supported annotation type names declared by the {@link SupportedAnnotationTypes} annotation + * on the processor class. The element collector's annotation types are for collection filtering — + * not the supported-set declaration — so without the annotation the supported set stays {@code + * "*"}. + * + * @return the declared names, or empty when the class is not annotated + */ + protected Optional> supportedAnnotationTypesByAnnotation() { + SupportedAnnotationTypes annotation = getClass().getAnnotation(SupportedAnnotationTypes.class); + return annotation != null + ? Optional.of(Set.copyOf(List.of(annotation.value()))) + : Optional.empty(); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + /** + * Generates one target class through the configured engine after checking the target does not + * already exist. Also usable by the domain's {@link #onProcessingOver} implementation for + * last-round outputs. + * + *

The engine leaves its generation-scope debug operation open so the caller can close it with + * a summary line (see {@link #onTaskGenerated}); callers outside the task driver must close it + * with {@code context.debugEndOperation()}. + * + * @param target the class definition to generate + * @throws CodeGenerationException if the target exists or generation fails + */ + protected final void generateTarget(GenerationTargetClassDto target) + throws CodeGenerationException { + assertTargetDoesNotExist(target); + codeGenerator.generateClass(target, formatterResolver.resolve(target.getFormattingMode())); + } + + /** + * Fails the build with a descriptive error when the class to be generated already exists (a + * manually written class or a leftover from a previous compilation). + * + * @param target the class definition to check + * @throws CodeGenerationException if a class with the target qualified name already exists + */ + private void assertTargetDoesNotExist(GenerationTargetClassDto target) + throws CodeGenerationException { + String qualifiedName = target.getTypeName().getFullQualifiedName(); + boolean exists; + try { + exists = context.getTypeElement(qualifiedName) != null; + } catch (Exception e) { + context.debug( + "Error checking if class '%s' already exists: %s", + qualifiedName, e.getMessage() != null ? e.getMessage() : "No message"); + exists = false; + } + if (exists) { + throw new CodeGenerationException( + """ + Target class '%s' already exists. This may be a manually written target or a previously generated target. + To resolve this: + 1. If you have a manual target, consider renaming it or removing the annotation triggering generation + 2. If this is from a previous compilation, clean and rebuild the project + 3. Check that you're not trying to generate multiple targets for the same target type + """ + .formatted(qualifiedName)); + } + } + + /** + * Creates the logger: verbose when the domain's declared verbose option requests it — see {@link + * ProcessorOptionRegistry#isVerboseRequested} — and carrying the prefix from {@link + * CodegenProcessorConfiguration#loggerPrefix()}, or the processor's simple class name when unset. + */ + private ProcessingLogger createLogger( + ProcessingEnvironment processingEnv, CompilerArgumentsReader reader) { + String configuredPrefix = processorConfiguration.loggerPrefix(); + String loggerPrefix = configuredPrefix != null ? configuredPrefix : getClass().getSimpleName(); + ProcessingLogger processingLogger = + optionRegistry().isVerboseRequested(reader) + ? ProcessingLogger.createVerbose(processingEnv, loggerPrefix) + : ProcessingLogger.create(processingEnv, loggerPrefix); + processingLogger.debug("Starting %s...", getClass().getSimpleName()); + return processingLogger; + } + + /** + * Creates the domain's processing context. + * + * @param processingEnv the processing environment + * @param reader compiler-argument access — global configuration typically comes from here + * @param logger logger for output + * @return the domain's context instance + */ + protected abstract C createContext( + ProcessingEnvironment processingEnv, CompilerArgumentsReader reader, ProcessingLogger logger); + + /** + * Creates the code generation engine — the {@link ClassCodeGenerator} backend port the domain + * wires over {@link #generationEnvironment}. Default: the Roaster backend; override to plug in a + * different engine. + * + * @param processingEnv the processing environment + * @param logger the processing logger + * @param context the processing context + * @return the domain's code generation engine + */ + protected ClassCodeGenerator createGenerator( + ProcessingEnvironment processingEnv, ProcessingLogger logger, C context) { + return new RoasterCodeGenerator(generationEnvironment(processingEnv, logger, context)); + } + + /** + * The {@link GenerationEnvironment} the engine runs against — processing environment, logger, + * performance tracker and the strict-mode reporter, wired from the context the driver created. + * + * @param processingEnv the processing environment + * @param logger the processing logger + * @param context the processing context + * @return the generation environment for the engine's constructor + */ + protected GenerationEnvironment generationEnvironment( + ProcessingEnvironment processingEnv, ProcessingLogger logger, C context) { + return new GenerationEnvironment( + processingEnv, logger, context.getPerformanceTracker(), context::reportBasedOnStrictMode); + } + + /** + * Creates the source formatter resolver for the configured profile — the profile name comes from + * the processor's declared options. + * + * @param reader the compiler arguments reader + * @param logger the processing logger + * @return the formatter resolver + */ + protected SourceFormatterResolver createFormatterResolver( + CompilerArgumentsReader reader, ProcessingLogger logger) { + return new SourceFormatterResolver(logger, optionRegistry().formatterProfile(reader)); + } + + private ElementCollector elementCollector() { + return processorConfiguration.elementCollector(); + } + + private ProcessorOptionRegistry optionRegistry() { + return processorConfiguration.optionRegistry(); + } + + /** + * Whether {@link #process} claims the supported annotations. Default {@code false} — claiming + * would hide them from later processors, which is especially wrong under the default {@code "*"} + * supported set. Override to claim when the processor owns a concrete annotation set. + * + * @param supportedTypes the supported annotation type names the round was processed for + * @return {@code true} to claim the supported annotations + */ + protected boolean claimsAnnotations(Set supportedTypes) { + return false; + } + + private String requiredSourceVersion() { + return processorConfiguration.requiredSourceVersion(); + } + + /** + * Whether {@code current} meets the named minimum. The name resolves inside the check so a JDK + * whose {@link SourceVersion} lacks the constant (e.g. a processor built on a newer JDK running + * under an older one) reports the graceful error path instead of a linkage failure. + */ + private boolean isAtLeast(SourceVersion current, String requiredName) { + try { + return current.ordinal() >= SourceVersion.valueOf(requiredName).ordinal(); + } catch (IllegalArgumentException ex) { + logger.debug( + "Required source version name '%s' is unknown to this JDK — treating as unsupported", + requiredName); + return false; + } + } + + /** + * Finds the elements of this round the domain generates code for. Default delegates to the + * configured {@link CodegenProcessorConfiguration#elementCollector}; override for custom element + * discovery. + * + * @param annotations the annotation types requested this round + * @param roundEnv the round environment + * @return the collected elements + */ + protected ElementGroups findElements( + Set annotations, RoundEnvironment roundEnv) { + return elementCollector().collect(annotations, roundEnv, logger); + } + + private boolean skipsRound(ElementGroups collected, C context) { + if (collected.isEmpty()) { + context.debug("No matching elements collected — skipping planning for this round."); + return true; + } + if (isFinished(context)) { + context.debug("Processing is already finished — skipping planning for this round."); + return true; + } + return false; + } + + /** + * The planning step — runs the domain's {@link #planTasks}, applies the one-shot marker, and + * registers the round's planned outputs. Framework bookkeeping lives here so the domain function + * below stays pure planning. + * + * @param collected the elements matched by {@link #findElements} + * @param context the processing context + * @return the round's tasks, possibly empty when there is nothing to generate + */ + private List> plan(ElementGroups collected, C context) { + List> tasks = planTasks(collected, context); + if (tasks.isEmpty()) { + context.debug("Planning produced no tasks — nothing to generate this round."); + } else { + // The default one-shot is consumed by the first round that planned work — an empty round + // leaves it open for a later one carrying matching elements. + processedOnce = true; + } + registerPlannedTaskResults(tasks, context); + return tasks; + } + + /** + * Plans the tasks of this round from the collected elements: resolves per-element configuration, + * expands multi-target declarations and applies domain scoping rules. + * + * @param collected the elements matched by {@link #findElements} + * @param context the processing context + * @return the generation plan, possibly empty + */ + protected abstract List> planTasks(ElementGroups collected, C context); + + /** + * Registers the types this round will generate, up front, so references to them resolve during + * analysis. Default: clears {@link AnnotationProcessingContext#getGeneratedTypes()} and registers + * every name each task's {@link #predictTaskResultTypeNames} predicts — a task may produce + * several generated types. Each registration fires {@link #onTaskResultRegistered} for domain + * extras (e.g. SPI publication); override this whole method only for a different shape. + * + * @param tasks the planned tasks + * @param context the processing context + */ + protected void registerPlannedTaskResults(List> tasks, C context) { + context.getGeneratedTypes().clear(); + for (CodegenTask task : tasks) { + if (!(task.element() instanceof TypeElement targetType)) { + continue; + } + TypeName elementName = JavaLangMapper.mapToTypeName(targetType, context); + for (TypeName planned : predictTaskResultTypeNames(task, context)) { + context.addGeneratedType(elementName, planned); + onTaskResultRegistered(task, planned, context); + } + } + } + + /** + * Predicts the names of the types this task will generate — evaluated at registration time, + * before anything is generated. The prediction lets generated types reference each other's + * planned names (see {@link AnnotationProcessingContext#resolveGeneratedType}) without a + * generation-order dependency: analysis of every task already knows every target name. + * + *

A domain whose generated types never reference other generated types needs no prediction — + * registration then simply records nothing for the task (the default). Keep this pure: + * registrations and their side effects belong to {@link #onTaskResultRegistered}. + * + * @param task the planned task + * @param context the processing context + * @return the predicted generated type names (empty when the task registers nothing) + */ + protected List predictTaskResultTypeNames(CodegenTask task, C context) { + return List.of(); + } + + /** + * Runs after a predicted type name is registered for a task — the extension point for domain + * extras on registration (e.g. SPI publication). Default: nothing. + * + * @param task the planned task + * @param plannedName the predicted type name just registered for it + * @param context the processing context + */ + protected void onTaskResultRegistered(CodegenTask task, TypeName plannedName, C context) { + // Extension point for domains — registration bookkeeping is already done by the driver. + } + + /** + * Event at the start of every processing round — the domain's hook for per-round preparation + * (resetting round state, round-specific logging). Called after the processing-over check, before + * element collection. Default: nothing. + * + * @param context the processing context + */ + protected void onRoundStart(C context) { + // Extension point for domains — the driver performs no per-round setup beyond logging. + } + + /** + * Event after the round's tasks were planned and their predicted results registered — the + * domain's hook to inspect the plan before analysis starts. Called every generating round with + * that round's task list, which may be empty when the round produced nothing. Default: nothing. + * + * @param tasks the tasks the domain's {@link #planTasks} produced this round + * @param context the processing context + */ + protected void onTasksPlanned(List> tasks, C context) { + // Extension point for domains — planning bookkeeping is already done by the driver. + } + + /** + * Whether the domain's generating round already ran. Default: one-shot — the base marks the + * processor finished after the first round that planned tasks, so elements first appearing in a + * later round get nothing. Override for a custom lifecycle rule (e.g. a cross-compilation + * finished-marker). + * + * @param context the processing context + * @return {@code true} to skip this round + */ + protected boolean isFinished(C context) { + return processedOnce; + } + + /** + * Analyses one planned task into its domain definitions — the analysis half of the + * analysis/generation separation. A task may resolve to several definitions (e.g. a repeatable + * annotation on one element). + * + * @param task the planned task + * @param context the processing context + * @return the domain definitions, possibly empty + * @throws Exception when analysis fails; reported as a per-element failure + */ + @SuppressWarnings("java:S112") // extension point — the domain cannot enumerate its failures + protected abstract List analyse(CodegenTask task, C context) throws Exception; + + /** + * Maps a domain definition to its render target — the generation half of the analysis/generation + * separation, separated by the {@link GenerationTargetClassDto} boundary. One definition + * specifies exactly one target. + * + * @param definition the analysed definition + * @param configuration the configuration the task resolved + * @param context the processing context + * @return the class definition to generate + * @throws Exception when mapping fails; reported as a per-element failure + */ + @SuppressWarnings("java:S112") // extension point — the domain cannot enumerate its failures + protected abstract GenerationTargetClassDto mapToTargetSpecification( + D definition, K configuration, C context) throws Exception; + + /** + * Event after a task's target generated successfully — the insert point for per-class metrics and + * domain bookkeeping. Default: nothing. + * + *

Runs while the generation scope is still open — close it with {@code + * context.debugEndOperation(summary)} for a closing summary line, or leave it; the driver + * restores the pre-task indentation either way. + * + * @param generation the planned generation — task, analysed definition and generated target + * @param context the processing context + */ + protected void onTaskGenerated(PlannedGeneration generation, C context) { + // Extension point for domains — the target is already generated at this point. + } + + /** + * Callback when processing is over — the domain's last-round finalization: SPI flush, reports, + * and last-round outputs generated directly through {@link #generateTarget}. Default: nothing. + * + * @param context the processing context + */ + protected void onProcessingOver(C context) { + // Extension point for domains — e.g. last-round outputs via {@link #generateTarget}. + } + + /** + * Called at the end of every round that ran planning — also when the plan was empty. + * + * @param context the processing context + */ + protected void endRound(C context) { + // Extension point for domains — e.g. per-round finalization. + } + + /** + * One planned generation, carried from the analysis loop into the generation loop and handed to + * {@link #onTaskGenerated} as a whole — the record domains read for their generated-task + * callback. + */ + protected record PlannedGeneration( + CodegenTask task, D definition, GenerationTargetClassDto target) {} +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/CodegenConfiguration.java b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenConfiguration.java new file mode 100644 index 00000000..4607fcb7 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenConfiguration.java @@ -0,0 +1,64 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +/** + * The common configuration contract a code-generation processor's task configuration supports: + * strict/fail-fast reporting and verbose diagnostics. + * + *

Framework defaults for new processors: strict enabled, verbose disabled (the javac {@code + * -verbose} flag enables verbose output too). Domains may deviate — simple-builders keeps strict + * opt-in. + */ +public interface CodegenConfiguration { + + /** The framework default: strict mode enabled, verbose disabled. */ + CodegenConfiguration DEFAULT = + new CodegenConfiguration() { + @Override + public boolean isStrictModeEnabled() { + return true; + } + + @Override + public boolean isVerboseEnabled() { + return false; + } + }; + + /** + * Whether generation problems are reported as errors (fail-fast) instead of warnings. + * + * @return {@code true} for strict reporting + */ + boolean isStrictModeEnabled(); + + /** + * Whether verbose diagnostic output is enabled. + * + * @return {@code true} for verbose output + */ + boolean isVerboseEnabled(); +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenDefinition.java similarity index 51% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java rename to processor/src/main/java/org/javahelpers/codegen/processing/CodegenDefinition.java index f33d0478..20a2a83f 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java +++ b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenDefinition.java @@ -22,22 +22,28 @@ * SOFTWARE. */ -package org.javahelpers.simple.builders.processor.processing; +package org.javahelpers.codegen.processing; -import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; +import org.javahelpers.simple.builders.processor.model.type.TypeName; /** - * Per-target processing state for the type whose builder is currently being generated. - * - *

{@link org.javahelpers.simple.builders.processor.BuilderProcessor} sets one instance per - * processed type on the {@link BuilderProcessingContext} before extraction starts, so downstream - * analysis can reach it without threading both values through every call. - * - * @param configuration the resolved builder configuration for the current target - * @param builderPackage the package the generated builder is written to. This is the configured - * {@code packageName} option when set; otherwise the processed type's own package for - * {@code @SimpleBuilder} targets, resp. the holder's package for {@code @SimpleBuilderFor} - * targets. It cannot always be derived from the processed element itself, which is why it is - * carried explicitly here + * The common contract of a domain's analysed definition — the analysis result the domain maps to + * {@code GenerationTargetClassDto} render targets. */ -public record ProcessingTarget(BuilderConfiguration configuration, String builderPackage) {} +public interface CodegenDefinition { + + /** + * The definition's primary type name — the analysed type's own name. + * + * @return the primary type name + */ + TypeName getTypeName(); + + /** + * The name of the type the definition's target will generate — the prediction a domain makes at + * task level, confirmed once the definition exists. + * + * @return the generated target type's name + */ + TypeName targetTypeName(); +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/CodegenProcessorConfiguration.java b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenProcessorConfiguration.java new file mode 100644 index 00000000..61fc2147 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenProcessorConfiguration.java @@ -0,0 +1,130 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.List; + +/** + * Static declarations of an {@link AbstractCodegenProcessor}: everything the framework needs to + * know up front, collected in one record the processor hands to the base constructor. + * + *

Only {@link #elementCollector} is required; all other components carry defaults a minimal + * processor can leave unset. Use {@link #builder()} to build an instance. + * + * @param elementCollector which elements the domain generates code for (required) + * @param optionRegistry the processor's declared compiler options; default: an empty registry + * @param requiredSourceVersion the minimum source level by name (e.g. {@code "RELEASE_17"}); + * default: the running JDK's latest supported release + * @param loggerPrefix the diagnostic prefix carried by every emitted log line ({@code "prefix: "}); + * default: the processor's simple class name + */ +public record CodegenProcessorConfiguration( + ElementCollector elementCollector, + ProcessorOptionRegistry optionRegistry, + String requiredSourceVersion, + String loggerPrefix) { + + /** + * Creates a builder with all defaults applied — only {@link + * Builder#elementCollector(ElementCollector)} must be set. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Fluent builder for {@link CodegenProcessorConfiguration}. */ + public static final class Builder { + private ElementCollector elementCollector; + private ProcessorOptionRegistry optionRegistry = new ProcessorOptionRegistry(List.of()); + private String requiredSourceVersion; + private String loggerPrefix; + + private Builder() {} + + /** + * Sets which elements the domain generates code for (required). + * + * @param elementCollector the collector declaring which elements to process + * @return this builder + */ + public Builder elementCollector(ElementCollector elementCollector) { + this.elementCollector = elementCollector; + return this; + } + + /** + * Sets the processor's declared compiler options. + * + * @param optionRegistry the option registry + * @return this builder + */ + public Builder optionRegistry(ProcessorOptionRegistry optionRegistry) { + this.optionRegistry = optionRegistry; + return this; + } + + /** + * Sets the minimum source level the processor supports, by {@code SourceVersion} constant name + * (e.g. {@code "RELEASE_17"}). A name — not the enum constant — keeps the check resolvable at + * processing time: a JDK whose {@code SourceVersion} lacks the constant still reports a + * graceful error instead of a linkage failure. + * + * @param requiredSourceVersion the required source version name + * @return this builder + */ + public Builder requiredSourceVersion(String requiredSourceVersion) { + this.requiredSourceVersion = requiredSourceVersion; + return this; + } + + /** + * Sets the diagnostic prefix every emitted log line carries ({@code "prefix: "}). Default: the + * processor's simple class name. + * + * @param loggerPrefix the logger prefix + * @return this builder + */ + public Builder loggerPrefix(String loggerPrefix) { + this.loggerPrefix = loggerPrefix; + return this; + } + + /** + * Builds the configuration. + * + * @return the configuration + * @throws IllegalStateException when no {@link #elementCollector} was set + */ + public CodegenProcessorConfiguration build() { + if (elementCollector == null) { + throw new IllegalStateException("elementCollector is required"); + } + return new CodegenProcessorConfiguration( + elementCollector, optionRegistry, requiredSourceVersion, loggerPrefix); + } + } +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/CodegenTask.java b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenTask.java new file mode 100644 index 00000000..77bd78e8 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/CodegenTask.java @@ -0,0 +1,117 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.Objects; +import javax.lang.model.element.Element; + +/** + * A planned generation: one element to generate code for, with the configuration the domain + * resolved during planning. + * + *

One round element may expand into several tasks (e.g. an annotation declaring multiple + * external types). + * + * @param the domain's configuration type + * @param element the element code is generated for + * @param configuration the resolved per-element configuration + * @param reportingElement the element diagnostics are reported on — the declaring holder for + * externally declared types, otherwise the element itself + * @param outputPackage the package generated code is written to — planning computes it so the + * driver can pass the task to {@link + * org.javahelpers.simple.builders.processor.processing.AnnotationProcessingContext#setCurrentTask} + * before analysis + */ +public record CodegenTask( + Element element, K configuration, Element reportingElement, String outputPackage) { + + /** + * A task builder — {@code reportingElement} defaults to the task's element and {@code + * configuration} to {@link CodegenConfiguration#DEFAULT} when that fits the given type. + * + * @param the configuration type + * @param configurationType the domain's configuration type; {@code build()} applies the {@link + * CodegenConfiguration#DEFAULT} only when it is assignable to it + * @return a new builder + */ + public static Builder builder(Class configurationType) { + return new Builder<>(Objects.requireNonNull(configurationType, "configurationType")); + } + + /** Fluent builder for {@link CodegenTask}. */ + public static final class Builder { + private final Class configurationType; + private Element element; + private K configuration; + private Element reportingElement; + private String outputPackage; + + private Builder(Class configurationType) { + this.configurationType = configurationType; + } + + /** The element the task generates for; also the default {@link #reportingElement}. */ + public Builder element(Element element) { + this.element = element; + return this; + } + + /** The resolved per-element configuration. */ + public Builder configuration(K configuration) { + this.configuration = configuration; + return this; + } + + /** The element diagnostics are reported on; defaults to {@link #element}. */ + public Builder reportingElement(Element reportingElement) { + this.reportingElement = reportingElement; + return this; + } + + /** The package generated code is written to. */ + public Builder outputPackage(String outputPackage) { + this.outputPackage = outputPackage; + return this; + } + + /** + * Builds the task; the reporting element defaults to the task element and the configuration to + * {@link CodegenConfiguration#DEFAULT} — checked: the default is only applied when it is + * assignable to the domain's configuration type, otherwise {@code configuration()} is required. + */ + public CodegenTask build() { + K resolvedConfiguration = configuration; + if (resolvedConfiguration == null + && configurationType.isInstance(CodegenConfiguration.DEFAULT)) { + resolvedConfiguration = configurationType.cast(CodegenConfiguration.DEFAULT); + } + return new CodegenTask<>( + Objects.requireNonNull(element, "element"), + Objects.requireNonNull(resolvedConfiguration, "configuration"), + reportingElement != null ? reportingElement : element, + Objects.requireNonNull(outputPackage, "outputPackage")); + } + } +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/ElementCollector.java b/processor/src/main/java/org/javahelpers/codegen/processing/ElementCollector.java new file mode 100644 index 00000000..e1f570d4 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/ElementCollector.java @@ -0,0 +1,170 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.TypeElement; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; +import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; + +/** + * Declarative specification of which elements a processor works on. + * + *

The spec only finds elements: matching (direct or via meta-annotation), opt-out + * filtering and deterministic ordering. Reading annotation values is the domain's + * responsibility in the planning step — per-instance configuration, repeatable-annotation expansion + * and conflict detection are domain semantics the spec does not know. + */ +public final class ElementCollector { + + private final List selectors; + + /** + * Creates a spec. + * + * @param selectors the named selection groups, in declaration order + */ + public ElementCollector(List selectors) { + this.selectors = selectors; + } + + /** + * A builder for an element spec. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Fluent builder for {@link ElementCollector}. */ + public static final class Builder { + private final List selectors = new ArrayList<>(); + + /** Adds a named selection group, in declaration order. */ + public Builder selector(ElementSelector selector) { + this.selectors.add(selector); + return this; + } + + /** Adds a selection group declared inline on a fresh {@link ElementSelector.Builder}. */ + public Builder selector(Consumer selectorDeclaration) { + ElementSelector.Builder selectorBuilder = ElementSelector.builder(); + selectorDeclaration.accept(selectorBuilder); + return selector(selectorBuilder.build()); + } + + /** + * Adds a selector matching elements by annotation type alone — the convenience for the common + * single-annotation case with no meta-marker, opt-out or named group (read the result via + * {@link ElementGroups#all()}). + * + * @param annotationTypes the annotation types selecting an element + * @return this builder + */ + @SafeVarargs + public final Builder selector( + Class... annotationTypes) { + return selector(selector -> selector.groupName("default").annotationTypes(annotationTypes)); + } + + /** Builds the spec. */ + public ElementCollector build() { + return new ElementCollector(selectors); + } + } + + /** + * Collects the elements selected by every selector in the given round. + * + * @param annotations the annotation types the round carries + * @param roundEnv the current round + * @param logger logger for opt-out diagnostics + * @return the per-selector element groups, sorted by simple name + */ + public ElementGroups collect( + Set annotations, RoundEnvironment roundEnv, ProcessingLogger logger) { + Map> groups = new LinkedHashMap<>(); + for (ElementSelector selector : selectors) { + Set selected = new HashSet<>(); + // Direct matches go through the Class overload, which also unwraps repeatable containers + for (Class type : selector.annotationTypes()) { + selected.addAll(roundEnv.getElementsAnnotatedWith(type)); + } + for (TypeElement annotation : matchingAnnotations(annotations, selector)) { + selected.addAll(roundEnv.getElementsAnnotatedWith(annotation)); + } + if (selector.optOutAnnotation() != null) { + selected.removeIf( + element -> { + if (JavaLangAnalyser.findAnnotation(element, selector.optOutAnnotation()) + .isPresent()) { + logger.debug( + "Skipping element '%s' due to @%s opt-out.", + element.getSimpleName(), selector.optOutAnnotation().getSimpleName()); + return true; + } + return false; + }); + } + groups.put( + selector.groupName(), + selected.stream() + .sorted(Comparator.comparing(element -> element.getSimpleName().toString())) + .toList()); + } + return new ElementGroups(groups); + } + + private static List matchingAnnotations( + Set annotations, ElementSelector selector) { + Class metaMarker = selector.metaMarker(); + if (metaMarker == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (TypeElement annotation : annotations) { + if (annotation.getKind() != ElementKind.ANNOTATION_TYPE) { + continue; + } + // Direct annotation types are already covered by the Class overload above — only + // meta-annotated types (e.g. template annotations carrying the marker) remain to match + if (annotation.getAnnotation(metaMarker) != null) { + result.add(annotation); + } + } + return result; + } +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/ElementGroups.java b/processor/src/main/java/org/javahelpers/codegen/processing/ElementGroups.java new file mode 100644 index 00000000..30564afb --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/ElementGroups.java @@ -0,0 +1,70 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.List; +import java.util.Map; +import javax.lang.model.element.Element; + +/** + * The result of {@link ElementCollector#collect}: per-selector-name, deterministically ordered + * element lists. Elements are sorted by simple name so generated output is reproducible. + */ +public final class ElementGroups { + + private final Map> groups; + + ElementGroups(Map> groups) { + this.groups = groups; + } + + /** + * The elements selected by the named selector, sorted by simple name. + * + * @param groupName name of the selecting {@link ElementSelector}'s group + * @return the selected elements, or an empty list when the selector matched nothing + */ + public List byGroup(String groupName) { + return groups.getOrDefault(groupName, List.of()); + } + + /** + * Every selected element across all groups, in group declaration order. + * + * @return all selected elements + */ + public List all() { + return groups.values().stream().flatMap(List::stream).toList(); + } + + /** + * Whether every selected group is empty. + * + * @return {@code true} when no selector matched any element + */ + public boolean isEmpty() { + return groups.values().stream().allMatch(List::isEmpty); + } +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/ElementSelector.java b/processor/src/main/java/org/javahelpers/codegen/processing/ElementSelector.java new file mode 100644 index 00000000..77b19043 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/ElementSelector.java @@ -0,0 +1,99 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.lang.annotation.Annotation; +import java.util.Set; + +/** + * One named element selection group inside {@link ElementCollector}. + * + *

An element is selected when it carries one of the {@link #annotationTypes}, or an annotation + * type that is itself meta-annotated with {@link #metaMarker} (the only way to discover + * user-defined annotation types, since the supported annotation types stay {@code "*"}). Selected + * elements carrying the {@link #optOutAnnotation} are dropped again. + * + * @param groupName the group name results are keyed by in {@link ElementGroups} + * @param annotationTypes annotation types that directly select an element + * @param metaMarker meta-annotation marking annotation types that also select, or {@code null} + * @param optOutAnnotation annotation that excludes a selected element again, or {@code null} + */ +public record ElementSelector( + String groupName, + Set> annotationTypes, + Class metaMarker, + Class optOutAnnotation) { + + /** + * A builder for an element selector. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Fluent builder for {@link ElementSelector}. */ + public static final class Builder { + private String groupName; + private Set> annotationTypes = Set.of(); + private Class metaMarker; + private Class optOutAnnotation; + + /** The group name results are keyed by. */ + public Builder groupName(String groupName) { + this.groupName = groupName; + return this; + } + + /** Annotation types that directly select an element. */ + @SafeVarargs + public final Builder annotationTypes(Class... annotationTypes) { + this.annotationTypes = Set.of(annotationTypes); + return this; + } + + /** Meta-annotation marking annotation types that also select, or {@code null}. */ + public Builder metaMarker(Class metaMarker) { + this.metaMarker = metaMarker; + return this; + } + + /** Annotation that excludes a selected element again, or {@code null}. */ + public Builder optOutAnnotation(Class optOutAnnotation) { + this.optOutAnnotation = optOutAnnotation; + return this; + } + + /** Builds the selector. */ + public ElementSelector build() { + return new ElementSelector( + java.util.Objects.requireNonNull(groupName, "groupName"), + annotationTypes, + metaMarker, + optOutAnnotation); + } + } +} diff --git a/processor/src/main/java/org/javahelpers/codegen/processing/ProcessorOptionRegistry.java b/processor/src/main/java/org/javahelpers/codegen/processing/ProcessorOptionRegistry.java new file mode 100644 index 00000000..8d2c74c8 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/codegen/processing/ProcessorOptionRegistry.java @@ -0,0 +1,166 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import javax.annotation.processing.ProcessingEnvironment; +import org.apache.commons.lang3.StringUtils; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.javahelpers.simple.builders.processor.processing.CompilerOption; + +/** + * The compiler options a processor declares, in one place. + * + *

{@code getSupportedOptions()} is derived from the declaration — no hand-duplicated option name + * list that can drift from the {@link CompilerOption}s actually parsed. + */ +public final class ProcessorOptionRegistry { + + private final Set options; + private final CompilerOption verboseOption; + private final CompilerOption formatterProfileOption; + + private ProcessorOptionRegistry( + Collection options, + CompilerOption verboseOption, + CompilerOption formatterProfileOption) { + this.options = Collections.unmodifiableSet(new LinkedHashSet<>(options)); + this.verboseOption = verboseOption; + this.formatterProfileOption = formatterProfileOption; + } + + /** + * Creates a registry over the given declared options. + * + * @param options the processor's compiler options + */ + public ProcessorOptionRegistry(Collection options) { + this(options, null, null); + } + + /** + * A builder for a registry with the framework-level options wired: the verbose flag and the + * formatter profile option. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * All supported option names: both the javac-style {@code -A} option name and the legacy + * compiler-argument spelling of every declared option, including the framework-wired verbose and + * formatter-profile options. + * + * @return the supported option names for {@code AbstractProcessor.getSupportedOptions()} + */ + public Set supportedOptions() { + Set supported = new HashSet<>(); + for (CompilerOption option : options) { + supported.add(option.getOptionName()); + supported.add(option.getCompilerArgument()); + } + for (CompilerOption wired : Arrays.asList(verboseOption, formatterProfileOption)) { + if (wired != null) { + supported.add(wired.getOptionName()); + supported.add(wired.getCompilerArgument()); + } + } + return supported; + } + + /** + * A typed reader over the declared options for the given environment. + * + * @param processingEnv the processing environment to read option values from + * @return the option value reader + */ + public CompilerArgumentsReader reader(ProcessingEnvironment processingEnv) { + return new CompilerArgumentsReader(processingEnv); + } + + /** + * Whether verbose diagnostic output was requested via the declared verbose option. The javac + * {@code -verbose} flag (and {@code mvn -X} which sets it) cannot be detected here — javac + * exposes only {@code -A} options in {@link + * javax.annotation.processing.ProcessingEnvironment#getOptions()}. + * + * @param reader the option value reader + * @return {@code true} when verbose output was requested + */ + public boolean isVerboseRequested(CompilerArgumentsReader reader) { + return verboseOption != null && reader.readBooleanValue(verboseOption); + } + + /** + * The configured source-formatter profile name, or {@code null} when no formatter profile option + * was declared or set. + * + * @param reader the option value reader + * @return the trimmed profile name, or {@code null} + */ + public String formatterProfile(CompilerArgumentsReader reader) { + return formatterProfileOption != null + ? StringUtils.trimToNull(reader.readValue(formatterProfileOption)) + : null; + } + + /** Builder for {@link ProcessorOptionRegistry}. */ + public static final class Builder { + private Collection options = List.of(); + private CompilerOption verboseOption; + private CompilerOption formatterProfileOption; + + /** The processor's declared compiler options. */ + public Builder options(Collection options) { + this.options = options; + return this; + } + + /** The option that enables verbose diagnostic output. */ + public Builder verboseOption(CompilerOption verboseOption) { + this.verboseOption = verboseOption; + return this; + } + + /** The option that names the source-formatter profile. */ + public Builder formatterProfileOption(CompilerOption formatterProfileOption) { + this.formatterProfileOption = formatterProfileOption; + return this; + } + + /** Builds the registry. */ + public ProcessorOptionRegistry build() { + return new ProcessorOptionRegistry(options, verboseOption, formatterProfileOption); + } + } +} 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 f666dad4..d2c4c4f8 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 @@ -25,11 +25,7 @@ package org.javahelpers.simple.builders.processor; import static org.javahelpers.simple.builders.processor.processing.BuilderDefinitionCreator.extractFromElement; -import static org.javahelpers.simple.builders.processor.processing.PipelinePhases.PHASE_BUILDER_DEFINITION_EXTRACTION; -import static org.javahelpers.simple.builders.processor.processing.PipelinePhases.PHASE_CODE_GENERATION; import static org.javahelpers.simple.builders.processor.processing.PipelinePhases.PHASE_CONFIGURATION_RESOLUTION; -import static org.javahelpers.simple.builders.processor.processing.PipelinePhases.PHASE_DTO_MAPPING; -import static org.javahelpers.simple.builders.processor.processing.PipelinePhases.PHASE_ELEMENT_COLLECTION; import com.google.auto.service.AutoService; import java.util.ArrayList; @@ -41,12 +37,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; import javax.annotation.processing.Processor; -import javax.annotation.processing.RoundEnvironment; -import javax.annotation.processing.SupportedAnnotationTypes; -import javax.lang.model.SourceVersion; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; @@ -54,19 +46,19 @@ import javax.lang.model.element.Modifier; import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; -import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; +import org.javahelpers.codegen.processing.AbstractCodegenProcessor; +import org.javahelpers.codegen.processing.CodegenProcessorConfiguration; +import org.javahelpers.codegen.processing.CodegenTask; +import org.javahelpers.codegen.processing.ElementCollector; +import org.javahelpers.codegen.processing.ElementGroups; +import org.javahelpers.codegen.processing.ProcessorOptionRegistry; import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors; -import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper; -import org.javahelpers.simple.builders.processor.classgen.ClassCodeGenerator; -import org.javahelpers.simple.builders.processor.classgen.CodeGenerationException; -import org.javahelpers.simple.builders.processor.classgen.GenerationEnvironment; -import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; @@ -81,231 +73,131 @@ import org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; -import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; -import org.javahelpers.simple.builders.processor.processing.SourceFormatterResolver; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; /** * BuilderProcessor is an annotation processor for execution in generate-sources phase. The - * BuilderProcessor using the Java way for generating builders, by implementing {@code - * javax.annotation.processing.AbstractProcessor}. + * BuilderProcessor using the Java way for generating builders, plugged into the generic pipeline + * driven by {@link AbstractCodegenProcessor}. */ @AutoService(Processor.class) -@SupportedAnnotationTypes("*") -public class BuilderProcessor extends AbstractProcessor { - private static final String MSG_FAILED_TO_GENERATE = - "simple-builders: Failed to generate builder - %s"; - - private BuilderProcessingContext context; - private ProcessingLogger logger; - private ClassCodeGenerator codeGenerator; - private SourceFormatterResolver formatterResolver; +public class BuilderProcessor + extends AbstractCodegenProcessor< + BuilderProcessingContext, BuilderConfiguration, BuilderDefinitionDto> { + /** Group of elements carrying {@code @SimpleBuilder} or a template-meta-annotated annotation. */ + private static final String GROUP_ANNOTATED_ELEMENTS = "annotatedElements"; + + /** Group of holder classes declaring external types via {@code @SimpleBuilderFor}. */ + private static final String GROUP_EXTERNAL_TYPE_HOLDERS = "externalTypeHolders"; + + /** The processor's declared compiler options — the supported set is derived, not duplicated. */ + private static final ProcessorOptionRegistry OPTION_REGISTRY = + ProcessorOptionRegistry.builder() + .options(List.of(CompilerArgumentsEnum.values())) + .verboseOption(CompilerArgumentsEnum.VERBOSE) + .formatterProfileOption(CompilerArgumentsEnum.FORMATTER_PROFILE) + .build(); + + /** + * Which elements builders are generated for: types carrying {@code @SimpleBuilder} or a + * user-defined {@code @SimpleBuilder.Template}-meta-annotated annotation (opt-out via + * {@code @Ignore4BuilderGeneration}), plus classes declaring external types with + * {@code @SimpleBuilderFor}. + */ + private static final ElementCollector ELEMENT_COLLECTOR = + ElementCollector.builder() + .selector( + selector -> + selector + .groupName(GROUP_ANNOTATED_ELEMENTS) + .metaMarker(Template.class) + .optOutAnnotation(Ignore4BuilderGeneration.class)) + .selector( + selector -> + selector + .groupName(GROUP_EXTERNAL_TYPE_HOLDERS) + .annotationTypes(SimpleBuilderFor.class, SimpleBuilderFors.class)) + .build(); + + /** The processor's static declarations handed to the framework driver. */ + private static final CodegenProcessorConfiguration PROCESSOR_CONFIGURATION = + CodegenProcessorConfiguration.builder() + .elementCollector(ELEMENT_COLLECTOR) + .optionRegistry(OPTION_REGISTRY) + .requiredSourceVersion("RELEASE_17") + .loggerPrefix("simple-builders") + .build(); + + /** Creates the processor — javac instantiates it reflectively. */ + public BuilderProcessor() { + super(PROCESSOR_CONFIGURATION); + } + private JacksonModuleGenerator jacksonModuleGenerator; - private boolean supportedJdk = true; @Override - public synchronized void init(ProcessingEnvironment processingEnv) { - super.init(processingEnv); - // Read global configuration from compiler arguments - CompilerArgumentsReader reader = new CompilerArgumentsReader(processingEnv); - this.logger = - reader.readBooleanValue(CompilerArgumentsEnum.VERBOSE) - ? ProcessingLogger.createVerbose(processingEnv) - : ProcessingLogger.create(processingEnv); - logger.debug("Starting BuilderProcessor..."); - + protected BuilderProcessingContext createContext( + ProcessingEnvironment processingEnv, + CompilerArgumentsReader reader, + ProcessingLogger logger) { BuilderConfiguration globalConfig = BuilderConfigurationReader.readCompilerConfiguration(reader, logger); logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); - SimpleBuildersSpiIntegration.initCompilation(processingEnv.getElementUtils()); - - this.context = new BuilderProcessingContext(logger, globalConfig, processingEnv); - this.codeGenerator = - new RoasterCodeGenerator( - new GenerationEnvironment( - processingEnv, - logger, - context.getPerformanceTracker(), - context::reportBasedOnStrictMode)); - this.formatterResolver = new SourceFormatterResolver(processingEnv, logger); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig); + BuilderProcessingContext ctx = + new BuilderProcessingContext(logger, globalConfig, processingEnv); // Initialize GeneratorRegistry once during processor initialization - context.debugStartOperation("Initializing generator registry"); + ctx.debugStartOperation("Initializing generator registry"); try { - context.getGeneratorRegistry(); + ctx.initGeneratorRegistry(); } finally { - context.debugEndOperation(); - } - - SourceVersion current = processingEnv.getSourceVersion(); - this.supportedJdk = isAtLeastJava17(current); - if (!this.supportedJdk) { - context.error( - "simple-builders requires Java 17 or higher for annotation processing. Detected: '%s'. Please upgrade to JDK 17+ or disable the processor.", - current); + ctx.debugEndOperation(); } + return ctx; } - /** - * Generates all builders in exactly one round — the first round carrying simple-builders - * annotations — and marks the SPI registry final when that round ends. - * - *

Always returns {@code false}, so this method intentionally never claims annotations (hence - * the {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and - * {@link SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template - * annotations; claiming would therefore hide every annotation in the round — including foreign - * ones like MapStruct's {@code @Mapper} — from later processors. - */ @Override - @SuppressWarnings("java:S3516") - public boolean process(Set annotations, RoundEnvironment roundEnv) { - if (!supportedJdk) { - // Fail fast: we already emitted an error in init(); do not attempt any processing. - return false; - } - - // Generate Jackson Module if processing is over and feature is enabled - if (roundEnv.processingOver()) { - SimpleBuildersSpiIntegration.finishCompilation(); - generateJacksonModules(context.getPerformanceTracker()); - return false; - } - PerformanceTracker tracker = context.getPerformanceTracker(); - context.info("simple-builders: PROCESSING ROUND START"); - - tracker.startPhase(); - Set elementsToProcess = collectElementsToProcess(annotations, roundEnv); - Set externalTypeHolders = collectExternalTypeHolders(roundEnv); - // Sort elements alphabetically by simple name for deterministic processing - List sortedElements = - elementsToProcess.stream() - .sorted(Comparator.comparing(element -> element.getSimpleName().toString())) - .toList(); - List sortedHolders = - externalTypeHolders.stream() - .sorted(Comparator.comparing(element -> element.getSimpleName().toString())) - .toList(); - tracker.endPhase(PHASE_ELEMENT_COLLECTION); - - context.debug("simple-builders: Processing round started."); - context.debug("simple-builders: Found %d annotated elements.", elementsToProcess.size()); - context.debug( - "simple-builders: Found %d type(s) for generation with @SimpleBuilderFor.", - externalTypeHolders.size()); - - // Resolve configuration and apply generation scopes before processing any builder. This lets - // the scope resolver know every builder that will be generated in this round. - List elementsToGenerate = + protected List> planTasks( + ElementGroups collected, BuilderProcessingContext ctx) { + List annotatedElements = collected.byGroup(GROUP_ANNOTATED_ELEMENTS); + List holderElements = collected.byGroup(GROUP_EXTERNAL_TYPE_HOLDERS); + ctx.debug("Found %d annotated elements.", annotatedElements.size()); + ctx.debug("Found %d type(s) for generation with @SimpleBuilderFor.", holderElements.size()); + List> tasks = resolveGenerationPlan( - sortedElements, sortedHolders, context.getConfigurationReader(), tracker); - context.debug( - "simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.", - elementsToGenerate.size(), sortedElements.size()); - if (SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration( - processingEnv.getElementUtils()) - || (sortedElements.isEmpty() && sortedHolders.isEmpty())) { - // simple-builders generates in exactly one round — the first round carrying its - // annotations; elements first appearing in later rounds get no builder. - return false; - } - registerGeneratedTypes(elementsToGenerate); - - int successfulGenerations = generateBuilders(elementsToGenerate, tracker); - - // Log summary of builder generation - if (successfulGenerations > 0) { - context.info( - "simple-builders: Successfully generated %d builder(s) in this processing round", - successfulGenerations); - } - - // Reset indentation level at the end of each processing round to prevent cascading errors - context.resetIndentation(); - // The generating round is done — the registry is final for SPI adapters from here on. - SimpleBuildersSpiIntegration.finishCompilation(); - return false; + annotatedElements, + holderElements, + ctx.getConfigurationReader(), + ctx.getPerformanceTracker()); + ctx.debug( + "%d of %d annotated element(s) are inside the builderGenerationPackages scope.", + tasks.size(), annotatedElements.size()); + return tasks; } - /** Generates all Jackson modules after the last processing round and reports the metrics. */ - private void generateJacksonModules(PerformanceTracker tracker) { - tracker.generateReport(logger); - - List moduleClassDefs = jacksonModuleGenerator.getModuleDefinitions(); - for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) { - String packageName = moduleClassDef.getTypeName().getPackageName(); - context.info("Generating Jackson Module in package '%s'", packageName); - try { - assertTargetClassDoesNotExist(moduleClassDef); - codeGenerator.generateClass( - moduleClassDef, formatterResolver.resolve(moduleClassDef.getFormattingMode())); - } catch (CodeGenerationException e) { - // By default Jackson module generation failures are warnings. In opt-in strict mode - // they are promoted to errors that fail the build. - context.reportBasedOnStrictMode( - "simple-builders: Error generating Jackson module for package %s: %s", - packageName, e.getMessage()); - } - } - // Reset indentation after Jackson module generation as well - context.resetIndentation(); - } - - /** - * Collects all elements annotated with {@code @SimpleBuilderFor} in this round. These are holder - * classes declaring external types a builder is generated for. Repeated declarations arrive - * wrapped in the {@code @SimpleBuilderFors} container, so both are collected. - */ - private Set collectExternalTypeHolders(RoundEnvironment roundEnv) { - Set holders = new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class)); - holders.addAll(roundEnv.getElementsAnnotatedWith(SimpleBuilderFors.class)); - return holders; - } - - /** - * Collects all elements to process in this round: any element annotated with an annotation that - * is meta-annotated with {@code @SimpleBuilder.Template} (including {@code @SimpleBuilder} - * itself), minus elements opted out via {@code @Ignore4BuilderGeneration}. - */ - private Set collectElementsToProcess( - Set annotations, RoundEnvironment roundEnv) { - Set elementsToProcess = new HashSet<>(); - for (TypeElement annotation : extractingAnnotationsWithTemplate(annotations)) { - elementsToProcess.addAll(roundEnv.getElementsAnnotatedWith(annotation)); - } - - // Only directly-declared annotations are checked: the type itself must carry - // the opt-out; a parent carrying it does not cascade, matching the fact that - // @Ignore4BuilderGeneration is intentionally NOT @Inherited. - elementsToProcess.removeIf( - element -> { - Optional ignoreAnnotation = - JavaLangAnalyser.findAnnotation(element, Ignore4BuilderGeneration.class); - if (ignoreAnnotation.isPresent()) { - context.debug( - "Skipping element '%s' due to @Ignore4BuilderGeneration opt-out.", - element.getSimpleName()); - return true; - } - return false; - }); - return elementsToProcess; + @Override + protected boolean isFinished(BuilderProcessingContext ctx) { + // simple-builders generates in exactly one round — the first round carrying its + // annotations; elements first appearing in later rounds get no builder. + return SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration( + processingEnv.getElementUtils()); } /** * Resolves the configuration per element and applies the {@code builderGenerationPackages} scope, - * returning the elements that will have a builder generated in this round. External types listed - * in {@code @SimpleBuilderFor} are expanded afterwards so a directly annotated DTO always wins - * over an external-type request for the same builder name. + * returning the tasks for this round. External types listed in {@code @SimpleBuilderFor} are + * expanded afterwards so a directly annotated DTO always wins over an external-type request for + * the same builder name. */ - private List resolveGenerationPlan( + private List> resolveGenerationPlan( List sortedElements, List sortedHolders, BuilderConfigurationReader reader, PerformanceTracker tracker) { - List elementsToGenerate = new ArrayList<>(); + List> tasks = new ArrayList<>(); // Builder names already planned in this round: needed for conflict detection, so a directly // annotated type wins over an external-type declaration producing the same builder name // (the conflicting @SimpleBuilderFor entry is skipped with a warning). @@ -314,11 +206,12 @@ private List resolveGenerationPlan( context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); try { planGenerationOfAnnotatedElement(annotatedElement, reader, alreadyPlannedBuilders, tracker) - .ifPresent(elementsToGenerate::add); + .ifPresent(tasks::add); } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. - context.reportBasedOnStrictMode(annotatedElement, MSG_FAILED_TO_GENERATE, ex.getMessage()); + context.reportBasedOnStrictMode( + annotatedElement, "Failed to generate builder - %s", ex.getMessage()); } finally { context.debugEndOperation(); } @@ -327,22 +220,21 @@ private List resolveGenerationPlan( for (Element holder : sortedHolders) { context.debugStartOperation("Processing @SimpleBuilderFor holder: " + holder.getSimpleName()); try { - elementsToGenerate.addAll( - planGenerationOfTypeByHolder(holder, reader, alreadyPlannedBuilders, tracker)); + tasks.addAll(planGenerationOfTypeByHolder(holder, reader, alreadyPlannedBuilders, tracker)); } catch (BuilderException ex) { - context.reportBasedOnStrictMode(holder, MSG_FAILED_TO_GENERATE, ex.getMessage()); + context.reportBasedOnStrictMode(holder, "Failed to generate builder - %s", ex.getMessage()); } finally { context.debugEndOperation(); } } - return elementsToGenerate; + return tasks; } /** * Resolves the configuration of one annotated element and plans its builder, or returns empty * when the element is outside the {@code builderGenerationPackages} scope. */ - private Optional planGenerationOfAnnotatedElement( + private Optional> planGenerationOfAnnotatedElement( Element annotatedElement, BuilderConfigurationReader reader, Set alreadyPlannedBuilders, @@ -355,10 +247,10 @@ private Optional planGenerationOfAnnotatedElement( if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { return Optional.empty(); } - alreadyPlannedBuilders.add( - builderTypeName( - annotatedElement, effectiveBuilderPackage(annotatedElement, config), config)); - return Optional.of(new ElementToGenerate(annotatedElement, config, annotatedElement)); + String builderPackage = effectiveBuilderPackage(annotatedElement, config); + alreadyPlannedBuilders.add(builderTypeName(annotatedElement, builderPackage, config)); + return Optional.of( + new CodegenTask<>(annotatedElement, config, annotatedElement, builderPackage)); } /** @@ -368,7 +260,7 @@ private Optional planGenerationOfAnnotatedElement( * into different packages via {@code packageName}; without a configured package the holder's * package is used. */ - private List planGenerationOfTypeByHolder( + private List> planGenerationOfTypeByHolder( Element holder, BuilderConfigurationReader reader, Set alreadyPlannedBuilders, @@ -379,13 +271,13 @@ private List planGenerationOfTypeByHolder( JavaLangAnalyser.findAnnotationInstances( holder, SimpleBuilderFor.class, SimpleBuilderFors.class, context); - List results = new ArrayList<>(); + List> results = new ArrayList<>(); for (AnnotationMirror annotationInstance : annotationInstances) { List targets = extractExternalTargetTypes(holder, annotationInstance); if (targets.isEmpty()) { context.warning( holder, - "simple-builders: @SimpleBuilderFor on '%s' resolves no types - nothing to generate", + "@SimpleBuilderFor on '%s' resolves no types - nothing to generate", holder.getSimpleName()); continue; } @@ -410,7 +302,7 @@ private List planGenerationOfTypeByHolder( * and the {@code builderGenerationPackages} scope only filters annotated types, so a scope that * would exclude an explicitly named type is a contradictory configuration and only warns. */ - private Optional planExternalTarget( + private Optional> planExternalTarget( TypeElement target, Element holder, BuilderConfiguration config, @@ -420,7 +312,7 @@ private Optional planExternalTarget( && !config.builderGenerationPackages().includes(builderPackage)) { context.warning( holder, - "simple-builders: @SimpleBuilderFor on '%s' generates builder for '%s' in package '%s', which is outside builderGenerationPackages - the explicit declaration takes precedence", + "@SimpleBuilderFor on '%s' generates builder for '%s' in package '%s', which is outside builderGenerationPackages - the explicit declaration takes precedence", holder.getSimpleName(), target.getQualifiedName(), builderPackage); @@ -429,13 +321,13 @@ private Optional planExternalTarget( if (!alreadyPlannedBuilders.add(builderName)) { context.warning( holder, - "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", + "skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", target.getQualifiedName(), holder.getSimpleName(), builderName.getFullQualifiedName()); return Optional.empty(); } - return Optional.of(new ElementToGenerate(target, config, holder)); + return Optional.of(new CodegenTask<>(target, config, holder, builderPackage)); } /** @@ -448,7 +340,9 @@ private List extractExternalTargetTypes( Element holder, AnnotationMirror annotationInstance) throws BuilderException { Map targets = new LinkedHashMap<>(); // For an array-valued attribute javac always delivers a list, even for a single entry. - for (Object item : readArrayAttribute(annotationInstance, "value")) { + List annotationAttributeArray = + JavaLangAnalyser.findAnnotationAttributeArray(annotationInstance, "value", context); + for (Object item : annotationAttributeArray) { TypeElement target = resolveExternalTargetType(holder, item); targets.put(target.getQualifiedName().toString(), target); } @@ -460,21 +354,14 @@ private List extractExternalTargetTypes( return List.copyOf(targets.values()); } - /** Reads an array-typed attribute of an annotation mirror as the raw item list. */ - private List readArrayAttribute(AnnotationMirror annotationInstance, String attributeName) { - return JavaLangAnalyser.findAnnotationAttribute(annotationInstance, attributeName, context) - .map(AnnotationValue::getValue) - .filter(List.class::isInstance) - .map(List.class::cast) - .orElse(List.of()); - } - /** * Reads the {@code sourcePackages} attribute of one {@code @SimpleBuilderFor} annotation * instance. */ private List readSourcePackageNames(AnnotationMirror annotationInstance) { - return readArrayAttribute(annotationInstance, "sourcePackages").stream() + return JavaLangAnalyser.findAnnotationAttributeArray( + annotationInstance, "sourcePackages", context) + .stream() .map(item -> item instanceof AnnotationValue value ? value.getValue() : null) .filter(String.class::isInstance) .map(String.class::cast) @@ -491,7 +378,7 @@ private List findPackageTargetTypes(Element holder, String packageN if (packageElement == null) { context.warning( holder, - "simple-builders: package '%s' in @SimpleBuilderFor on '%s' could not be resolved - skipped", + "package '%s' in @SimpleBuilderFor on '%s' could not be resolved - skipped", packageName, holder.getSimpleName()); return List.of(); @@ -502,7 +389,7 @@ private List findPackageTargetTypes(Element holder, String packageN targets.add(type); } else { context.debug( - "simple-builders: skipping %s '%s' in package '%s' declared by @SimpleBuilderFor on '%s' - no builder can be generated", + "skipping %s '%s' in package '%s' declared by @SimpleBuilderFor on '%s' - no builder can be generated", type.getKind().toString().toLowerCase(), type.getQualifiedName(), packageName, @@ -524,17 +411,14 @@ private static boolean isConstructibleTopLevelType(TypeElement type) { /** Resolves one entry of a {@code @SimpleBuilderFor} {@code value} attribute to its type. */ private TypeElement resolveExternalTargetType(Element holder, Object item) throws BuilderException { - Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; - Element resolved = - typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; - if (!(resolved instanceof TypeElement targetType)) { - throw new BuilderException( - holder, - "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", - typeValue, - holder.getSimpleName()); - } - return targetType; + return JavaLangAnalyser.resolveTypeElement(item, context) + .orElseThrow( + () -> + new BuilderException( + holder, + "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", + item instanceof AnnotationValue value ? value.getValue() : null, + holder.getSimpleName())); } /** Computes the type name of the builder a given target type would produce. */ @@ -544,118 +428,55 @@ private TypeName builderTypeName( } /** - * Registers the types whose builders will be generated this round with the scope resolver, so it - * can trust them without a type search. The actual builder type name is registered, which may - * differ from the target's package for {@code @SimpleBuilderFor} targets. + * The builder type this task will generate — registered up front so references to it resolve + * during analysis. The package may differ from the target's for {@code @SimpleBuilderFor} + * targets. */ - private void registerGeneratedTypes(List elementsToGenerate) { - BuilderScopeResolver scopeResolver = context.getBuilderScopeResolver(); - scopeResolver.resetGeneratedBuilders(); - for (ElementToGenerate elementToGenerate : elementsToGenerate) { - if (!(elementToGenerate.element() instanceof TypeElement targetType)) { - continue; - } - TypeName beanType = JavaLangMapper.mapToTypeName(targetType, context); - TypeName builderType = - builderTypeName( - targetType, - effectiveBuilderPackage( - elementToGenerate.reportingElement(), elementToGenerate.config()), - elementToGenerate.config()); - scopeResolver.registerGeneratedBuilder(beanType, builderType); - scopeResolver - .resolveGeneratedBuilder(targetType) - .ifPresent( - resolvedBuilder -> - SimpleBuildersSpiIntegration.registerBuilder( - beanType, resolvedBuilder, elementToGenerate.config().getSetterPrefix())); - } - } - - /** Generates a builder for each planned element and returns the number of successes. */ - private int generateBuilders( - List elementsToGenerate, PerformanceTracker tracker) { - int successfulGenerations = 0; - for (ElementToGenerate elementToGenerate : elementsToGenerate) { - Element annotatedElement = elementToGenerate.element(); - context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); - tracker.startClass(annotatedElement.getSimpleName().toString()); - try { - process(annotatedElement, elementToGenerate.config(), builderPackageOf(elementToGenerate)); - successfulGenerations++; - } catch (BuilderException ex) { - // By default builder generation failures are warnings so other builders are still - // generated. In opt-in strict mode they are promoted to errors that fail the build. - context.reportBasedOnStrictMode( - elementToGenerate.reportingElement(), MSG_FAILED_TO_GENERATE, ex.getMessage()); - } finally { - context.debugEndOperation(); - } + @Override + protected List predictTaskResultTypeNames( + CodegenTask task, BuilderProcessingContext ctx) { + if (!(task.element() instanceof TypeElement targetType)) { + return List.of(); } - return successfulGenerations; + return List.of(builderTypeName(targetType, task.outputPackage(), task.configuration())); } + /** SPI-exports the registered builder on top of the generic generated-type registration. */ @Override - public Set getSupportedOptions() { - Set options = new HashSet<>(); - for (CompilerArgumentsEnum arg : CompilerArgumentsEnum.values()) { - options.add(arg.getOptionName()); // e.g., "verbose" - options.add(arg.getCompilerArgument()); // e.g., "simplebuilder.verbose" + protected void onTaskResultRegistered( + CodegenTask task, TypeName plannedName, BuilderProcessingContext ctx) { + if (task.element() instanceof TypeElement targetType) { + SimpleBuildersSpiIntegration.registerBuilder( + JavaLangMapper.mapToTypeName(targetType, ctx), + ctx.getBuilderScopeResolver().generatedBuilderContract(plannedName), + task.configuration().getSetterPrefix()); } - return options; } @Override - public SourceVersion getSupportedSourceVersion() { - return SourceVersion.latestSupported(); + protected List analyse( + CodegenTask task, BuilderProcessingContext ctx) + throws BuilderException { + return List.of(extractFromElement(task.element(), ctx)); } - /** - * Fails the build with a descriptive error when the class to be generated already exists (a - * manually written class or a leftover from a previous compilation). - * - * @param classDef the class definition to check - * @throws CodeGenerationException if a class with the target qualified name already exists - */ - private void assertTargetClassDoesNotExist(GenerationTargetClassDto classDef) - throws CodeGenerationException { - String qualifiedName = classDef.getTypeName().getFullQualifiedName(); - boolean exists; - try { - exists = context.getTypeElement(qualifiedName) != null; - } catch (Exception e) { - context.debug( - "Error checking if class '%s' already exists: %s", - qualifiedName, e.getMessage() != null ? e.getMessage() : "No message"); - exists = false; - } - if (exists) { - throw new CodeGenerationException( - """ - Builder class '%s' already exists. This may be a manually written builder or a previously generated builder. - To resolve this: - 1. If you have a manual builder, consider renaming it or removing @SimpleBuilder from the DTO - 2. If this is from a previous compilation, clean and rebuild the project - 3. Check that you're not trying to generate multiple builders for the same DTO - """ - .formatted(qualifiedName)); - } + @Override + protected GenerationTargetClassDto mapToTargetSpecification( + BuilderDefinitionDto definition, + BuilderConfiguration configuration, + BuilderProcessingContext ctx) { + return new BuilderToGenerationTypeMapper(configuration).toRenderingDto(definition); } - private void process(Element annotatedElement, BuilderConfiguration config, String builderPackage) - throws BuilderException { - context.initProcessingTarget(new ProcessingTarget(config, builderPackage)); - PerformanceTracker tracker = context.getPerformanceTracker(); - // Track Builder Definition Extraction - tracker.startPhase(); - BuilderDefinitionDto builderDef = extractFromElement(annotatedElement, context); - tracker.endPhase(PHASE_BUILDER_DEFINITION_EXTRACTION); - - // Compute class-level metrics now that the definition is available - int fieldCount = builderDef.getAllFieldsForBuilder().size(); + @Override + protected void onTaskGenerated( + PlannedGeneration generation, + BuilderProcessingContext ctx) { + BuilderDefinitionDto definition = generation.definition(); + GenerationTargetClassDto renderingDto = generation.target(); int collectionCount = (int) - builderDef.getAllFieldsForBuilder().stream() + definition.getAllFieldsForBuilder().stream() .filter( f -> f.getFieldType() instanceof TypeNameList @@ -663,58 +484,19 @@ private void process(Element annotatedElement, BuilderConfiguration config, Stri || f.getFieldType() instanceof TypeNameMap) .count(); - // Track DTO Mapping - tracker.startPhase(); - GenerationTargetClassDto renderingDto = - new BuilderToGenerationTypeMapper(config).toRenderingDto(builderDef); - tracker.endPhase(PHASE_DTO_MAPPING); - - // Track Code Generation (parent phase; sub-phases tracked inside RoasterCodeGenerator) - tracker.startPhase(); + // Collect info for Jackson Module if enabled (the generator logs each registration) + jacksonModuleGenerator.addEntry(definition, generation.task().element()); - try { - assertTargetClassDoesNotExist(renderingDto); - codeGenerator.generateClass( - renderingDto, formatterResolver.resolve(renderingDto.getFormattingMode())); - } catch (CodeGenerationException e) { - throw new BuilderException(annotatedElement, e); - } - tracker.endPhase(PHASE_CODE_GENERATION); + // End class-level timing (started by the driver before analysis) + ctx.getPerformanceTracker() + .endClass(definition.getAllFieldsForBuilder().size(), collectionCount); - // Collect info for Jackson Module if enabled - jacksonModuleGenerator.addEntry(builderDef, annotatedElement); - context.debug("Jackson module entry added"); - - // End class-level timing (started in caller before config resolution) - tracker.endClass(fieldCount, collectionCount); - - // Add summary of what was generated - context.debugEndOperation( + // Close the generation scope with the summary of what was generated + ctx.debugEndOperation( "Generated builder with %d fields and %d methods for %s", - builderDef.getAllFieldsForBuilder().size(), + definition.getAllFieldsForBuilder().size(), renderingDto.getMethods().size(), - builderDef.getBuilderTypeName().getClassName()); - } - - /** - * A type a builder is generated for. - * - * @param element the type element to generate the builder for - * @param config the resolved builder configuration - * @param reportingElement the element diagnostics are reported on - the {@code @SimpleBuilderFor} - * holder for external types, otherwise the type itself - */ - private record ElementToGenerate( - Element element, BuilderConfiguration config, Element reportingElement) {} - - /** - * The package the builder is generated into: the configured {@code packageName} option when set, - * otherwise the package of the element the generation is reported on - the - * {@code @SimpleBuilderFor} holder for external types, the annotated type itself otherwise. - */ - private String builderPackageOf(ElementToGenerate elementToGenerate) { - return effectiveBuilderPackage( - elementToGenerate.reportingElement(), elementToGenerate.config()); + definition.getBuilderTypeName().getClassName()); } /** @@ -726,39 +508,34 @@ private String effectiveBuilderPackage(Element reportingElement, BuilderConfigur return configured == null ? context.getPackageName(reportingElement) : configured; } - /** - * Checks whether the provided SourceVersion is at least Java 17 in a backwards compatible way. - */ - private static boolean isAtLeastJava17(SourceVersion current) { - try { - SourceVersion seventeen = SourceVersion.valueOf("RELEASE_17"); - return current.ordinal() >= seventeen.ordinal(); - } catch (IllegalArgumentException ex) { - // Running on a JDK where RELEASE_17 does not exist (e.g., JDK 8) - return false; - } - } - - private static List extractingAnnotationsWithTemplate( - Set annotationsFound) { - List result = new ArrayList<>(); - for (TypeElement annotation : annotationsFound) { - if (shouldSkipAnnotation(annotation)) { - continue; - } - Template templateAnnotation = - annotation.getAnnotation( - org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template.class); - if (templateAnnotation != null) { - result.add(annotation); + /** Generates all Jackson modules after the last processing round and reports the metrics. */ + @Override + protected void onProcessingOver(BuilderProcessingContext ctx) { + SimpleBuildersSpiIntegration.finishCompilation(); + ctx.getPerformanceTracker().generateReport(logger); + for (GenerationTargetClassDto moduleDefinition : + jacksonModuleGenerator.getModuleDefinitions()) { + ctx.info( + "Generating Jackson module in package '%s'", + moduleDefinition.getTypeName().getPackageName()); + try { + generateTarget(moduleDefinition); + } catch (Exception e) { + ctx.reportBasedOnStrictMode( + "Error generating Jackson module for package %s: %s", + moduleDefinition.getTypeName().getPackageName(), e.getMessage()); + } finally { + // generateTarget leaves its generation-scope operation open — close it here + ctx.debugEndOperation(); } } - return result; + // Reset indentation after Jackson module generation as well + ctx.resetIndentation(); } - private static boolean shouldSkipAnnotation(TypeElement annotation) { - // Only process real annotation specifications. @SimpleBuilder is now a built-in template, so - // it is processed through the same path as custom template annotations. - return annotation.getKind() != javax.lang.model.element.ElementKind.ANNOTATION_TYPE; + @Override + protected void endRound(BuilderProcessingContext ctx) { + // The generating round is done — the registry is final for SPI adapters from here on. + SimpleBuildersSpiIntegration.finishCompilation(); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 85b5d7a1..1197eda9 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -79,7 +79,6 @@ private record ResolutionInputs(BuilderConfiguration configuration, String build private ResolutionInputs cachedResolutionInputs; private PackageScopes usagePackages = PackageScopes.unscoped(); private final Map> resolvedBuilderTypes = new HashMap<>(); - private final GeneratedTypes generatedBuilders = new GeneratedTypes(); /** * Creates a new resolver for the given processing context. @@ -96,7 +95,7 @@ public BuilderScopeResolver(BuilderProcessingContext context) { *

This method reads the configuration from the processing context via {@link * org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext#getConfiguration()}. * The caller must ensure that {@link - * org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext#initProcessingTarget} + * org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext#setCurrentTask} * has been invoked with the owner element's resolved configuration beforehand, so that * per-element {@code builderUsagePackages} overrides are respected. * @@ -141,26 +140,17 @@ public Optional resolveUsableBuilderType(TypeElement referenced } /** - * The builder contract of a type whose builder this processor registered for the current round — - * always the generated {@code create()} factory for the empty path and the constructor for the - * copy path. Only the SPI integrations consume this, to publish the emitted contract. + * Wraps a builder type name with the instantiation contract every generated builder emits — a + * static {@code create()} factory for the empty path and a constructor for the copy path. * - *

Unlike {@link #resolveUsableBuilderType(TypeElement)} this does not read the per-element - * configuration and is safe to call during generation-plan registration. - * - * @param referencedType the type element being referenced - * @return the resolved builder, or empty if no generated builder is registered for the type + * @param builder the generated builder's type name + * @return the resolved builder carrying its contract */ - public Optional resolveGeneratedBuilder(TypeElement referencedType) { - TypeName referencedTypeName = JavaLangMapper.mapToTypeName(referencedType, context); - return generatedBuilders - .findGenerated(referencedTypeName) - .map( - builder -> - new ResolvedBuilder( - builder, - new TypeInstantiation.StaticFactoryCall(GENERATED_FACTORY_METHOD_NAME), - new TypeInstantiation.ConstructorCall())); + public ResolvedBuilder generatedBuilderContract(TypeName builder) { + return new ResolvedBuilder( + builder, + new TypeInstantiation.StaticFactoryCall(GENERATED_FACTORY_METHOD_NAME), + new TypeInstantiation.ConstructorCall()); } /** @@ -170,7 +160,7 @@ public Optional resolveGeneratedBuilder(TypeElement referencedT *

Unlike {@link #resolveUsableBuilderType(TypeElement)}, this method takes the configuration * as an explicit parameter rather than reading it from the processing context. This is because it * is called during generation-plan resolution, before {@link - * org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext#initProcessingTarget} + * org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext#setCurrentTask} * has been invoked for the element, so the context does not yet hold the per-element * configuration. * @@ -210,7 +200,7 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu * @param builderType the generated builder's type name */ public void registerGeneratedBuilder(TypeName targetType, TypeName builderType) { - generatedBuilders.add(targetType, builderType); + context.getGeneratedTypes().add(targetType, builderType); resolvedBuilderTypes.clear(); } @@ -219,7 +209,7 @@ public void registerGeneratedBuilder(TypeName targetType, TypeName builderType) * processing round, so stale registrations and resolutions of the previous round are dropped. */ public void resetGeneratedBuilders() { - generatedBuilders.clear(); + context.getGeneratedTypes().clear(); resolvedBuilderTypes.clear(); } @@ -241,16 +231,12 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - Optional generatedBuilder = generatedBuilders.findGenerated(referencedTypeName); + Optional generatedBuilder = + context.getGeneratedTypes().findGenerated(referencedTypeName); if (generatedBuilder.isPresent()) { // Our generators always emit a static create() and no create(T) - the empty path uses // the factory, the copy path the constructor - return generatedBuilder.map( - builder -> - new ResolvedBuilder( - builder, - new TypeInstantiation.StaticFactoryCall(GENERATED_FACTORY_METHOD_NAME), - new TypeInstantiation.ConstructorCall())); + return generatedBuilder.map(this::generatedBuilderContract); } // Reusing builders not generated in this round - nested or anchored inside the referenced diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index e7ca58bd..28679afe 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -312,6 +312,43 @@ public static Optional findAnnotationAttribute( return Optional.empty(); } + /** + * Reads an array-typed attribute of an annotation mirror as the raw item list. + * + *

For an array-valued attribute javac always delivers a list, even for a single entry. + * + * @param annotationMirror the annotation to read + * @param attributeName the simple name of the attribute (e.g. {@code "value"}) + * @param context the processing context providing element utilities + * @return the attribute's raw items, or an empty list when absent or not array-valued + */ + public static List findAnnotationAttributeArray( + AnnotationMirror annotationMirror, + String attributeName, + AnnotationProcessingContext context) { + Object value = + findAnnotationAttribute(annotationMirror, attributeName, context) + .map(AnnotationValue::getValue) + .orElse(null); + return value instanceof List list ? new ArrayList<>(list) : List.of(); + } + + /** + * Resolves one class-valued item of an annotation attribute to its type element. + * + * @param item a raw attribute item, typically an {@link AnnotationValue} wrapping a {@link + * TypeMirror} + * @param context the processing context providing type utilities + * @return the resolved type element, or empty when the item does not hold a type + */ + public static Optional resolveTypeElement( + Object item, AnnotationProcessingContext context) { + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; + Element resolved = + typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; + return resolved instanceof TypeElement targetType ? Optional.of(targetType) : Optional.empty(); + } + /** * Single-parameter variant of {@link #findMethodsStatic(TypeElement, List, TypeName, * AnnotationProcessingContext)}. 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 c8763693..69568ea3 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 @@ -147,8 +147,9 @@ public void generateClass(GenerationTargetClassDto classDef, SourceFormatter for writeClassToFile(sourceCode, classDef); performanceTracker.endPhase(PHASE_FILE_WRITING); - logger.debugEndOperation( - "Successfully generated class: %s", classDef.getTypeName().getClassName()); + // The generation scope stays open — the caller's post-generation step (onTaskGenerated in the + // task driver) closes it with its summary line (see AbstractCodegenProcessor.generateTarget). + logger.debug("Successfully generated class: %s", classDef.getTypeName().getClassName()); } private JavaClassSource buildClassSource(GenerationTargetClassDto classDef) { @@ -222,12 +223,14 @@ private void addClassMetadata(JavaClassSource source, GenerationTargetClassDto c private void appendFields(JavaClassSource source, GenerationTargetClassDto classDef) { performanceTracker.startPhase(); + if (CollectionUtils.isEmpty(classDef.getClassFields())) { + performanceTracker.endPhase(PHASE_FIELDS); + return; + } logger.debugStartOperation("Generating %d fields", classDef.getClassFields().size()); - for (ClassFieldDto fieldDto : classDef.getClassFields()) { appendField(source, fieldDto); } - logger.debugEndOperation("Fields added: %d fields", source.getFields().size()); performanceTracker.endPhase(PHASE_FIELDS); } @@ -243,12 +246,14 @@ private void appendField(JavaClassSource source, ClassFieldDto fieldDto) { private void appendConstructors(JavaClassSource source, GenerationTargetClassDto classDef) { performanceTracker.startPhase(); + if (CollectionUtils.isEmpty(classDef.getConstructors())) { + performanceTracker.endPhase(PHASE_CONSTRUCTORS); + return; + } logger.debugStartOperation("Generating %d constructors", classDef.getConstructors().size()); - for (ConstructorDto constructor : classDef.getConstructors()) { appendConstructor(source, constructor); } - logger.debugEndOperation("Constructors added: %d", classDef.getConstructors().size()); performanceTracker.endPhase(PHASE_CONSTRUCTORS); } @@ -267,6 +272,10 @@ private void appendConstructor(JavaClassSource source, ConstructorDto constructo private void appendMethods(JavaClassSource source, GenerationTargetClassDto classDef) { performanceTracker.startPhase(); + if (CollectionUtils.isEmpty(classDef.getMethods())) { + performanceTracker.endPhase(PHASE_METHODS); + return; + } logger.debugStartOperation("Generating %d method candidates", classDef.getMethods().size()); // Resolve method conflicts by signature and priority @@ -562,13 +571,11 @@ private String formatSource(String rawSource, SourceFormatter formatter) { private void writeClassToFile(String sourceCode, GenerationTargetClassDto classDef) throws CodeGenerationException { - logger.debug( - "Writing class to file: %s.%s", - classDef.getTypeName().getPackageName(), classDef.getTypeName().getClassName()); - String qualifiedName = classDef.getTypeName().getFullQualifiedName(); + logger.debug("Creating JavaFileObject: %s", qualifiedName); try { JavaFileObject file = processingEnv.getFiler().createSourceFile(qualifiedName); + logger.debug("Writing class to file: %s", file.toUri().getPath()); try (Writer writer = file.openWriter()) { writer.write(sourceCode); } 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 f2642b2b..53762940 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 @@ -94,6 +94,9 @@ public void addEntry(BuilderDefinitionDto builderDef, Element sourceElement) { targetPackage, new JacksonModuleEntryDto( builderDef.getBuildingTargetTypeName(), builderDef.getBuilderTypeName())); + logger.debug( + "Registered %s for Jackson module in package '%s'", + builderDef.getBuilderTypeName().getClassName(), targetPackage); } private String getTargetPackage(BuilderConfiguration config, BuilderDefinitionDto builderDef) { @@ -112,7 +115,7 @@ private boolean validateForModuleGeneration(BuilderConfiguration config, Element if (!jacksonAvailable) { logger.warning( sourceElement, - "simple-builders: generateJacksonModule is enabled for %s, but Jackson dependencies (com.fasterxml.jackson.databind.module.SimpleModule) are not found on the classpath. Module generation skipped.", + "generateJacksonModule is enabled for %s, but Jackson dependencies (com.fasterxml.jackson.databind.module.SimpleModule) are not found on the classpath. Module generation skipped.", sourceElement.getSimpleName()); return false; } @@ -120,7 +123,7 @@ private boolean validateForModuleGeneration(BuilderConfiguration config, Element if (!config.shouldUseJacksonDeserializerAnnotation()) { logger.warning( sourceElement, - "simple-builders: generateJacksonModule is enabled but usingJacksonDeserializerAnnotation is disabled. " + "generateJacksonModule is enabled but usingJacksonDeserializerAnnotation is disabled. " + "This is a misconfiguration. Jackson Module will NOT be generated for %s.", sourceElement.getSimpleName()); return false; @@ -143,7 +146,7 @@ public List getModuleDefinitions() { List definitions = new ArrayList<>(); if (!entriesByPackage.isEmpty()) { logger.info( - "simple-builders: Processing OVER. JacksonModuleEnabled: true, Packages: %d", + "Processing OVER. JacksonModuleEnabled: true, Packages: %d", entriesByPackage.keySet().size()); for (String packageName : entriesByPackage.keySet()) { diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/registry/GeneratorRegistry.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/registry/GeneratorRegistry.java index 484709c3..710a1021 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/registry/GeneratorRegistry.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/registry/GeneratorRegistry.java @@ -101,16 +101,18 @@ public List generateAllMethods( for (MethodGenerator generator : methodGenerators) { if (generator.appliesTo(field, dtoType, context)) { try { - context.debug( - "Applying: %s (priority: %d)", - generator.getClass().getSimpleName(), generator.getPriority()); - String genName = generator.getClass().getSimpleName(); performanceTracker.startGenerator(); List generatedMethods = generator.generateMethods(field, builderType, context); performanceTracker.endGenerator(genName); + context.debug( + "Applied: %s (priority: %d): +%d methods", + genName, + generator.getPriority(), + generatedMethods == null ? 0 : generatedMethods.size()); + if (CollectionUtils.isNotEmpty(generatedMethods)) { allMethods.addAll(generatedMethods); } @@ -138,15 +140,18 @@ public void enhanceBuilder(BuilderDefinitionDto builderDto, TypeName dtoType) { for (BuilderEnhancer enhancer : builderEnhancers) { if (enhancer.appliesTo(builderDto, dtoType, context)) { try { - context.debug( - "Applying: %s (priority: %d)", - enhancer.getClass().getSimpleName(), enhancer.getPriority()); - + int[] before = contentCounts(builderDto); String enhName = enhancer.getClass().getSimpleName(); performanceTracker.startEnhancer(); enhancer.enhanceBuilder(builderDto, context); performanceTracker.endEnhancer(enhName); appliedEnhancers++; + + context.debug( + "Applied: %s (priority: %d)%s", + enhName, + enhancer.getPriority(), + describeContribution(contentCounts(builderDto), before)); } catch (Exception e) { context.error( "Failed to apply enhancer %s to builder %s: %s", @@ -157,11 +162,41 @@ public void enhanceBuilder(BuilderDefinitionDto builderDto, TypeName dtoType) { } } - if (appliedEnhancers > 0) { - context.debugEndOperation("Applied %d builder enhancers", appliedEnhancers); - } else { - context.debugEndOperation(); + context.debugEndOperation("Applied %d builder enhancers", appliedEnhancers); + } + + /** Counts the content slots of a builder definition an enhancer can contribute to. */ + private static int[] contentCounts(BuilderDefinitionDto builderDto) { + return new int[] { + builderDto.getMethods().size(), + builderDto.getConstructors().size(), + builderDto.getNestedTypes().size(), + builderDto.getClassAnnotations().size(), + builderDto.getInterfaces().size(), + builderDto.getClassJavadoc() == null ? 0 : 1 + }; + } + + /** + * Renders what an enhancer added, e.g. {@code ": +2 methods, +1 constructor"}, or an empty string + * when it changed nothing measurable. + */ + private static String describeContribution(int[] after, int[] before) { + String[] labels = {"methods", "constructors", "nested types", "annotations", "interfaces"}; + StringBuilder contribution = new StringBuilder(); + for (int i = 0; i < labels.length; i++) { + if (after[i] > before[i]) { + contribution + .append(contribution.isEmpty() ? ": +" : ", +") + .append(after[i] - before[i]) + .append(' ') + .append(labels[i]); + } + } + if (after[5] > before[5]) { + contribution.append(contribution.isEmpty() ? ": " : ", ").append("javadoc added"); } + return contribution.toString(); } /** 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 62291f7f..4eaba272 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 @@ -31,6 +31,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; +import org.javahelpers.codegen.processing.CodegenConfiguration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; import org.javahelpers.simple.builders.core.enums.AccessModifier; import org.javahelpers.simple.builders.core.enums.FormattingMode; @@ -83,6 +84,7 @@ * @param formattingMode Formatting mode for generated source code (null = inherit from compiler * arg) * @param strict Strict/fail-fast generation mode + * @param verbose Verbose diagnostic output */ public record BuilderConfiguration( OptionState generateFieldSupplier, @@ -120,7 +122,9 @@ public record BuilderConfiguration( String builderUsageSuffix, String setterPrefix, String formattingMode, - OptionState strict) { + OptionState strict, + OptionState verbose) + implements CodegenConfiguration { public BuilderConfiguration { builderGenerationPackages = @@ -165,6 +169,7 @@ public record BuilderConfiguration( .setterPrefix("") .formattingMode(FormattingMode.JDT.getOptionValue()) .strict(DISABLED) + .verbose(DISABLED) .build(); // === Convenience accessors with 'is' prefix for boolean properties === @@ -326,10 +331,16 @@ public String getSetterPrefix() { return setterPrefix; } + @Override public boolean isStrictModeEnabled() { return strict == ENABLED; } + @Override + public boolean isVerboseEnabled() { + return verbose == ENABLED; + } + public FormattingMode formattingModeEnum() { return FormattingMode.fromString(formattingMode); } @@ -414,6 +425,7 @@ public BuilderConfiguration merge(BuilderConfiguration other) { .setterPrefix(mergeString(other.setterPrefix, this.setterPrefix)) .formattingMode(mergeString(other.formattingMode, this.formattingMode)) .strict(mergeOptionState(other.strict, this.strict)) + .verbose(mergeOptionState(other.verbose, this.verbose)) .build(); } @@ -505,6 +517,7 @@ public String toString() { .appendIfNotEmpty("setterPrefix", setterPrefix) .appendIfNotEmpty("formattingMode", formattingMode) .appendValueIfSet("strict", strict) + .appendValueIfSet("verbose", verbose) .toString(); } @@ -601,6 +614,9 @@ public static class Builder { // === Error Handling === private OptionState strict = OptionState.UNSET; + // === Diagnostics === + private OptionState verbose = OptionState.UNSET; + // === Setters === public Builder generateSupplier(OptionState value) { this.generateFieldSupplier = value; @@ -932,6 +948,16 @@ public Builder strict(boolean value) { return this; } + public Builder verbose(OptionState value) { + this.verbose = value; + return this; + } + + public Builder verbose(boolean value) { + this.verbose = value ? ENABLED : DISABLED; + return this; + } + public BuilderConfiguration build() { return new BuilderConfiguration( generateFieldSupplier, @@ -969,7 +995,8 @@ public BuilderConfiguration build() { builderUsageSuffix, setterPrefix, formattingMode, - strict); + strict, + verbose); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderDefinitionDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderDefinitionDto.java index f9c083b8..74de1924 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderDefinitionDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderDefinitionDto.java @@ -30,6 +30,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Set; +import org.javahelpers.codegen.processing.CodegenDefinition; import org.javahelpers.simple.builders.core.enums.AccessModifier; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.annotation.InterfaceName; @@ -53,7 +54,7 @@ * GenerationTargetClassDto} (rendering DTO) during finalization in {@code * BuilderDefinitionCreator.finalizeDefinition()}. */ -public class BuilderDefinitionDto { +public class BuilderDefinitionDto implements CodegenDefinition { // --- Builder-specific fields --- @@ -138,10 +139,16 @@ public void setBuilderTypeName(TypeName builderClassName) { this.typeName = builderClassName; } + @Override public TypeName getTypeName() { return getBuilderTypeName(); } + @Override + public TypeName targetTypeName() { + return getBuilderTypeName(); + } + public void setTypeName(TypeName typeName) { setBuilderTypeName(typeName); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/AnnotationProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/AnnotationProcessingContext.java index 608d9f99..55f280ce 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/AnnotationProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/AnnotationProcessingContext.java @@ -39,6 +39,8 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; +import org.javahelpers.codegen.processing.CodegenTask; +import org.javahelpers.simple.builders.processor.analysis.GeneratedTypes; import org.javahelpers.simple.builders.processor.analysis.TypeMappingContext; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.logging.ActivePerformanceTracker; @@ -73,8 +75,14 @@ public class AnnotationProcessingContext implements TypeMappingContext { /** Static options supplied by the concrete context. */ protected final AnnotationProcessingOptions options; - /** Package generated code is written to, or {@code null} when not generating into a package. */ - private String generatedPackageName; + /** + * Registry of the types generated in the current processing round — the up-front recognition of + * former (and planned) generated classes. + */ + private final GeneratedTypes generatedTypes = new GeneratedTypes(); + + /** The task currently being processed — set by the driver before analysis and generation. */ + private CodegenTask currentTask; /** * Creates the context with default options (strict mode enabled, performance tracking disabled). @@ -110,13 +118,24 @@ public AnnotationProcessingContext( } /** - * Sets the name of the package generated code is written to. Called when the current generation - * target is known. + * Sets the task currently being processed. The processing driver calls this once per planned task + * before analysis starts, so downstream code can reach the current configuration and output + * package without threading both through every call. * - * @param generatedPackageName the qualified name of the package generated classes are placed in + * @param task the task currently being processed */ - protected void setGeneratedPackageName(String generatedPackageName) { - this.generatedPackageName = generatedPackageName; + public void setCurrentTask(CodegenTask task) { + this.currentTask = task; + } + + /** + * The task currently being processed. + * + * @return the current task with its element, configuration and output package + */ + @SuppressWarnings("java:S1452") // the task's configuration type varies per domain + public CodegenTask getCurrentTask() { + return currentTask; } /** @@ -139,7 +158,7 @@ public boolean isAccessibleFromGeneratedPackage(Element member) { if (member.getModifiers().contains(Modifier.PRIVATE)) { return false; } - return generatedPackageName != null && getPackageName(member).equals(generatedPackageName); + return currentTask != null && getPackageName(member).equals(currentTask.outputPackage()); } /** @@ -207,6 +226,28 @@ public Optional resolveGeneratedType(TypeElement typeElement) { return Optional.empty(); } + /** + * The registry of types generated in the current processing round. Domains register planned + * outputs before generating, so references to them resolve; downstream code queries via {@link + * GeneratedTypes#findGenerated}. + * + * @return the generated-types registry + */ + public GeneratedTypes getGeneratedTypes() { + return generatedTypes; + } + + /** + * Registers one type planned to be generated for a processed element — a convenience over {@link + * #getGeneratedTypes()} for the common registration call. + * + * @param elementType the processed element's type the generated type belongs to + * @param generatedType the type planned to be generated for it + */ + public void addGeneratedType(TypeName elementType, TypeName generatedType) { + generatedTypes.add(elementType, generatedType); + } + /** * Get the TypeElement for a given qualified class name. * 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 2868ffa8..367488fd 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 @@ -39,6 +39,7 @@ import javax.lang.model.element.TypeElement; import javax.lang.model.util.Elements; import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -150,6 +151,9 @@ public static BuilderConfiguration readCompilerConfiguration( Stream.of(CompilerArgumentsEnum.values()) .filter(option -> !option.isAnnotationOnly()) .forEach(option -> option.apply(builder, reader.readValue(option), logger)); + if (reader.readBooleanValue(CompilerArgumentsEnum.VERBOSE)) { + builder.verbose(OptionState.ENABLED); + } return builder.build(); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index b33e4ac5..fdb12345 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -129,7 +129,7 @@ public static BuilderDefinitionDto extractFromElement( result.addAllFields(setterFields); // Apply builder enhancers (including With interface generation) - context.getGeneratorRegistry().enhanceBuilder(result, result.getBuildingTargetTypeName()); + context.initGeneratorRegistry().enhanceBuilder(result, result.getBuildingTargetTypeName()); // Propagate @Deprecated from the DTO type to the generated builder class and its factory // methods. Done after enhancement so the class javadoc, create() method and constructors @@ -144,8 +144,6 @@ public static BuilderDefinitionDto extractFromElement( // Finalize the definition - convert to generic class representation finalizeDefinition(result, context); - context.debug("Builder will be generated as: %s", result.getBuilderTypeName().getClassName()); - context.debugEndOperation( "Builder definition extracted: %s", result.getBuilderTypeName().getClassName()); @@ -878,7 +876,7 @@ private static Optional createFieldDto( // Use GeneratorRegistry to generate all methods for this field List generatedMethods = - context.getGeneratorRegistry().generateAllMethods(field, dtoType, builderType); + context.initGeneratorRegistry().generateAllMethods(field, dtoType, builderType); generatedMethods.forEach(field::addMethod); // Apply @Deprecated annotation and @deprecated javadoc to every generated builder method for diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderProcessingContext.java index 9bd05e69..65b88aae 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderProcessingContext.java @@ -46,7 +46,6 @@ public final class BuilderProcessingContext extends AnnotationProcessingContext private final BuilderScopeResolver builderScopeResolver; private GeneratorRegistry generatorRegistry; - private ProcessingTarget processingTarget; /** * Creates a new processing context. @@ -79,24 +78,13 @@ private static AnnotationProcessingOptions createOptions( .build(); } - /** - * Initializes the per-target state ({@link ProcessingTarget}) for the type whose builder is - * currently being generated. Called once per target before extraction starts. - * - * @param processingTarget the resolved configuration and builder package of the current target - */ - public void initProcessingTarget(ProcessingTarget processingTarget) { - this.processingTarget = processingTarget; - setGeneratedPackageName(processingTarget.builderPackage()); - } - /** * Gets the configuration for the current processing target. * * @return the builder configuration for the target being processed */ public BuilderConfiguration getConfiguration() { - return this.processingTarget.configuration(); + return (BuilderConfiguration) getCurrentTask().configuration(); } /** @@ -105,7 +93,7 @@ public BuilderConfiguration getConfiguration() { * @return the qualified package name of the generated builder */ public String getBuilderPackageName() { - return this.processingTarget.builderPackage(); + return getCurrentTask().outputPackage(); } /** @@ -128,14 +116,13 @@ public BuilderConfigurationReader getConfigurationReader() { } /** - * Get the unified generator registry for both field-level method generation and builder-level - * enhancement. - * - *

The registry is lazily initialized on first access to avoid circular dependency issues. + * The unified generator registry for both field-level method generation and builder-level + * enhancement — lazily initialized on first access to avoid circular dependency issues, so the + * first call doubles as the init. * * @return the generator registry */ - public GeneratorRegistry getGeneratorRegistry() { + public GeneratorRegistry initGeneratorRegistry() { if (generatorRegistry == null) { generatorRegistry = new GeneratorRegistry(this, processingEnv); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/PipelinePhases.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/PipelinePhases.java index f045feea..a1095b81 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/PipelinePhases.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/PipelinePhases.java @@ -41,8 +41,8 @@ public final class PipelinePhases { // Top-level phases public static final String PHASE_ELEMENT_COLLECTION = "Element Collection"; public static final String PHASE_CONFIGURATION_RESOLUTION = "Configuration Resolution"; - public static final String PHASE_BUILDER_DEFINITION_EXTRACTION = "Builder Definition Extraction"; - public static final String PHASE_DTO_MAPPING = "DTO Mapping"; + public static final String PHASE_ANALYSIS = "Analysis"; + public static final String PHASE_TARGET_MAPPING = "Target Mapping"; public static final String PHASE_CODE_GENERATION = "Code Generation"; /** Top-level phases of the pipeline, in report display order. */ @@ -50,8 +50,8 @@ public final class PipelinePhases { List.of( PHASE_ELEMENT_COLLECTION, PHASE_CONFIGURATION_RESOLUTION, - PHASE_BUILDER_DEFINITION_EXTRACTION, - PHASE_DTO_MAPPING, + PHASE_ANALYSIS, + PHASE_TARGET_MAPPING, PHASE_CODE_GENERATION); /** Phase hierarchy for report display: parent phase to ordered child phases. */ diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SourceFormatterResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SourceFormatterResolver.java index 92bb00ad..a619ecdd 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SourceFormatterResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SourceFormatterResolver.java @@ -25,16 +25,14 @@ package org.javahelpers.simple.builders.processor.processing; import java.util.EnumMap; -import javax.annotation.processing.ProcessingEnvironment; -import org.apache.commons.lang3.StringUtils; import org.javahelpers.simple.builders.core.enums.FormattingMode; import org.javahelpers.simple.builders.processor.classgen.SourceFormatter; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterSourceFormatter; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; /** - * Resolves the {@link SourceFormatter} for a formatting mode, applying the Eclipse formatter - * profile configured via the {@code formatterProfile} compiler argument. + * Resolves the {@link SourceFormatter} for a formatting mode, applying the formatter profile + * configured via the {@code formatterProfile} compiler argument. * *

At most one formatter instance per {@link FormattingMode} is created, lazily on first resolve. */ @@ -46,17 +44,14 @@ public class SourceFormatterResolver { new EnumMap<>(FormattingMode.class); /** - * Creates a resolver reading the formatter profile from compiler arguments. + * Creates a resolver. * - * @param processingEnv the processing environment (for compiler argument access) * @param logger logger for formatter warnings + * @param formatterProfile the configured formatter profile path, or {@code null} */ - public SourceFormatterResolver(ProcessingEnvironment processingEnv, ProcessingLogger logger) { + public SourceFormatterResolver(ProcessingLogger logger, String formatterProfile) { this.logger = logger; - this.formatterProfile = - StringUtils.trimToNull( - new CompilerArgumentsReader(processingEnv) - .readValue(CompilerArgumentsEnum.FORMATTER_PROFILE)); + this.formatterProfile = formatterProfile; } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java index bc0759d8..b3636e7e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java @@ -182,7 +182,7 @@ public void generateReport(ProcessingLogger logger) { long totalTime = System.nanoTime() - totalStartTime; double totalSeconds = totalTime / 1_000_000_000.0; - logger.info("simple-builders: PERFORMANCE REPORT"); + logger.info("PERFORMANCE REPORT"); logger.info("================================"); logger.info("Total classes processed: %d", totalClasses.get()); logger.info(String.format(Locale.US, "Total processing time: %.1fs", totalSeconds)); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ProcessingLogger.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ProcessingLogger.java index 171eb9bb..b3de8e9a 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ProcessingLogger.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ProcessingLogger.java @@ -32,7 +32,8 @@ /** * Logger for all messages during annotation processing. Providing util-functions for posting * messages easily. Using a {@code javax.annotation.processing.Messager} inside to publish the - * messages. + * messages. Every emitted message is prefixed with the logger's diagnostic prefix ("{@code prefix: + * }") when one was supplied at creation. */ public class ProcessingLogger { /** Util class for exposing messages of type {@code javax.annotation.processing.Messager}. */ @@ -41,6 +42,9 @@ public class ProcessingLogger { /** Verbosity level of this logger. */ private final Verbosity verbosity; + /** Diagnostic prefix identifying the processor — every emitted message is prefixed with it. */ + private final String loggingPrefix; + /** Thread-local indentation level for hierarchical logging. */ private static final ThreadLocal indentationLevel = ThreadLocal.withInitial(() -> 0); @@ -61,7 +65,18 @@ private enum Verbosity { * @return a processing logger reporting errors, warnings and info messages */ public static ProcessingLogger create(ProcessingEnvironment processingEnv) { - return new ProcessingLogger(processingEnv, Verbosity.NORMAL); + return new ProcessingLogger(processingEnv, Verbosity.NORMAL, null); + } + + /** + * Creates a new ProcessingLogger carrying a diagnostic prefix. + * + * @param processingEnv the processing environment providing the messager + * @param loggingPrefix the processor's diagnostic prefix, or {@code null} + * @return a processing logger reporting errors, warnings and info messages + */ + public static ProcessingLogger create(ProcessingEnvironment processingEnv, String loggingPrefix) { + return new ProcessingLogger(processingEnv, Verbosity.NORMAL, loggingPrefix); } /** @@ -71,15 +86,40 @@ public static ProcessingLogger create(ProcessingEnvironment processingEnv) { * @return a debug-enabled processing logger */ public static ProcessingLogger createVerbose(ProcessingEnvironment processingEnv) { - return new ProcessingLogger(processingEnv, Verbosity.VERBOSE); + return new ProcessingLogger(processingEnv, Verbosity.VERBOSE, null); + } + + /** + * Creates a new debug-enabled ProcessingLogger carrying a diagnostic prefix. + * + * @param processingEnv the processing environment providing the messager + * @param loggingPrefix the processor's diagnostic prefix, or {@code null} + * @return a debug-enabled processing logger + */ + public static ProcessingLogger createVerbose( + ProcessingEnvironment processingEnv, String loggingPrefix) { + return new ProcessingLogger(processingEnv, Verbosity.VERBOSE, loggingPrefix); } - private ProcessingLogger(ProcessingEnvironment processingEnv, Verbosity verbosity) { + private ProcessingLogger( + ProcessingEnvironment processingEnv, Verbosity verbosity, String loggingPrefix) { // Reset ThreadLocal state to ensure clean state between test runs resetThreadLocalState(); this.messager = processingEnv.getMessager(); this.verbosity = verbosity; + this.loggingPrefix = loggingPrefix; + } + + /** + * Applies this logger's "{@code prefix: }" prefix to a message — the one place the prefix pattern + * is defined. Loggers without a prefix return the message unchanged. + * + * @param message the message or message format + * @return the message prefixed with this logger's prefix + */ + private String prefixed(String message) { + return loggingPrefix != null ? loggingPrefix + ": " + message : message; } /** Resets ThreadLocal state to ensure clean state between test runs. */ @@ -94,7 +134,7 @@ private void resetThreadLocalState() { * @param args arguments referenced by the format specifiers in the format string */ public void error(String format, Object... args) { - messager.printMessage(Diagnostic.Kind.ERROR, String.format(format, args)); + messager.printMessage(Diagnostic.Kind.ERROR, String.format(prefixed(format), args)); } /** @@ -105,7 +145,7 @@ public void error(String format, Object... args) { * @param args arguments referenced by the format specifiers in the format string */ public void error(Element e, String format, Object... args) { - messager.printMessage(Diagnostic.Kind.ERROR, String.format(format, args), e); + messager.printMessage(Diagnostic.Kind.ERROR, String.format(prefixed(format), args), e); } /** @@ -115,7 +155,7 @@ public void error(Element e, String format, Object... args) { * @param message the info message to be posted */ public void info(String message) { - messager.printMessage(Diagnostic.Kind.NOTE, message); + messager.printMessage(Diagnostic.Kind.NOTE, prefixed(message)); } /** @@ -129,7 +169,7 @@ public void info(String message) { private String formatHierarchicalMessage(String message, int spaces) { String prefix = getCurrentIndentationLevel() > 0 ? "└─ " : ""; String spacing = " ".repeat(spaces); - return String.format("%s%s", spacing, formatWithIndentationNoDebug(message, prefix)); + return spacing + prefixed(formatWithIndentationNoDebug(message, prefix)); } /** @@ -147,7 +187,7 @@ public void info(String format, Object... args) { messager.printMessage(Diagnostic.Kind.NOTE, indentedMessage); } else { // When debug is disabled, use flat formatting (current behavior) - messager.printMessage(Diagnostic.Kind.NOTE, message); + messager.printMessage(Diagnostic.Kind.NOTE, prefixed(message)); } } @@ -196,7 +236,7 @@ public void warning(String format, Object... args) { messager.printMessage(Diagnostic.Kind.WARNING, indentedMessage); } else { // When debug is disabled, print flat - messager.printMessage(Diagnostic.Kind.WARNING, message); + messager.printMessage(Diagnostic.Kind.WARNING, prefixed(message)); } } @@ -216,7 +256,7 @@ public void warning(Element e, String format, Object... args) { messager.printMessage(Diagnostic.Kind.WARNING, indentedMessage, e); } else { // When debug is disabled, print flat - messager.printMessage(Diagnostic.Kind.WARNING, message, e); + messager.printMessage(Diagnostic.Kind.WARNING, prefixed(message), e); } } @@ -231,7 +271,7 @@ private String formatWithIndentation(String message, String prefix) { int level = indentationLevel.get(); if (level == 0) { // At level 0, no indentation or prefix - return "[DEBUG] " + message; + return "[DEBUG] " + prefixed(message); } // Use │ characters with proper spacing for better visual connection between hierarchical levels @@ -241,7 +281,7 @@ private String formatWithIndentation(String message, String prefix) { } // Add the specified prefix indent.append(prefix); - return "[DEBUG] " + indent + message; + return "[DEBUG] " + prefixed(indent.toString() + message); } /** diff --git a/processor/src/test/java/org/javahelpers/codegen/processing/CodegenProcessorConfigurationTest.java b/processor/src/test/java/org/javahelpers/codegen/processing/CodegenProcessorConfigurationTest.java new file mode 100644 index 00000000..ccff53be --- /dev/null +++ b/processor/src/test/java/org/javahelpers/codegen/processing/CodegenProcessorConfigurationTest.java @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link CodegenProcessorConfiguration}. */ +class CodegenProcessorConfigurationTest { + + @Test + void build_withoutElementCollector_throwsIllegalState() { + CodegenProcessorConfiguration.Builder builder = CodegenProcessorConfiguration.builder(); + assertThrows(IllegalStateException.class, () -> builder.build()); + } +} diff --git a/processor/src/test/java/org/javahelpers/codegen/processing/ElementCollectorTest.java b/processor/src/test/java/org/javahelpers/codegen/processing/ElementCollectorTest.java new file mode 100644 index 00000000..83793514 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/codegen/processing/ElementCollectorTest.java @@ -0,0 +1,145 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ElementVisitor; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.Name; +import javax.lang.model.element.NestingKind; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.TypeParameterElement; +import javax.lang.model.type.TypeMirror; +import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link ElementCollector}. */ +class ElementCollectorTest { + + @Test + void collect_nonAnnotationTypeElementsAreSkipped() { + ElementCollector collector = + ElementCollector.builder().selector(selector -> selector.groupName("group")).build(); + + // a javac round only ever passes annotation types — a non-annotation entry is skipped early + ElementGroups groups = + collector.collect( + Set.of(new StubTypeElement(ElementKind.CLASS)), + null, + CapturingProcessingLogger.create().logger()); + + assertTrue(groups.byGroup("group").isEmpty()); + } + + private static class StubTypeElement implements TypeElement { + private final ElementKind kind; + + StubTypeElement(ElementKind kind) { + this.kind = kind; + } + + @Override + public ElementKind getKind() { + return kind; + } + + @Override + public TypeMirror asType() { + return null; + } + + @Override + public Set getModifiers() { + return Set.of(); + } + + @Override + public Name getSimpleName() { + return null; + } + + @Override + public Element getEnclosingElement() { + return null; + } + + @Override + public List getEnclosedElements() { + return List.of(); + } + + @Override + public List getAnnotationMirrors() { + return List.of(); + } + + @Override + public A getAnnotation(Class annotationType) { + return null; + } + + @Override + public A[] getAnnotationsByType(Class annotationType) { + return null; + } + + @Override + public R accept(ElementVisitor visitor, P parameter) { + return null; + } + + @Override + public List getTypeParameters() { + return List.of(); + } + + @Override + public TypeMirror getSuperclass() { + return null; + } + + @Override + public List getInterfaces() { + return List.of(); + } + + @Override + public NestingKind getNestingKind() { + return NestingKind.TOP_LEVEL; + } + + @Override + public Name getQualifiedName() { + return null; + } + } +} diff --git a/processor/src/test/java/org/javahelpers/codegen/processing/MinimalCodegenProcessorTest.java b/processor/src/test/java/org/javahelpers/codegen/processing/MinimalCodegenProcessorTest.java new file mode 100644 index 00000000..ff812e12 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/codegen/processing/MinimalCodegenProcessorTest.java @@ -0,0 +1,217 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertNormalizedEquals; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; + +import com.google.testing.compile.Compilation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.javahelpers.simple.builders.core.enums.AccessModifier; +import org.javahelpers.simple.builders.core.enums.FormattingMode; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; +import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper; +import org.javahelpers.simple.builders.processor.model.core.ClassFieldDto; +import org.javahelpers.simple.builders.processor.model.core.GenerationTargetClassDto; +import org.javahelpers.simple.builders.processor.model.method.MethodDto; +import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.javahelpers.simple.builders.processor.processing.AnnotationProcessingContext; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +/** + * A complete codegen processor in minimal form — only the domain members that have no generic + * default are implemented; collection, round-driving, generator and formatter wiring come from + * {@link AbstractCodegenProcessor}. + */ +public class MinimalCodegenProcessorTest { + + /** Marks a type for delegate-builder generation in this test. */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.SOURCE) + public @interface GenerateBuilder {} + + /** Analysis result: target type, builder name and the setters to delegate to. */ + record MinimalTargetDefinition( + TypeName sourceTypeName, TypeName builderTypeName, List setters) + implements CodegenDefinition { + @Override + public TypeName getTypeName() { + return sourceTypeName; + } + + @Override + public TypeName targetTypeName() { + return builderTypeName; + } + } + + /** A processor that generates a builder delegating to the target type's setters. */ + static class MinimalProcessor + extends AbstractCodegenProcessor< + AnnotationProcessingContext, CodegenConfiguration, MinimalTargetDefinition> { + + MinimalProcessor() { + super( + CodegenProcessorConfiguration.builder() + .elementCollector(ElementCollector.builder().selector(GenerateBuilder.class).build()) + .loggerPrefix("minimal") + .build()); + } + + @Override + protected AnnotationProcessingContext createContext( + ProcessingEnvironment processingEnv, + CompilerArgumentsReader reader, + ProcessingLogger logger) { + return new AnnotationProcessingContext(logger, processingEnv); + } + + @Override + protected List> planTasks( + ElementGroups collected, AnnotationProcessingContext ctx) { + return collected.all().stream() + .map( + element -> + CodegenTask.builder(CodegenConfiguration.class) + .element(element) + .outputPackage(ctx.getPackageName(element)) + .build()) + .toList(); + } + + @Override + protected List analyse( + CodegenTask task, AnnotationProcessingContext ctx) { + if (!(task.element() instanceof TypeElement target)) { + return List.of(); + } + return List.of( + new MinimalTargetDefinition( + JavaLangMapper.mapToTypeName(target, ctx), + builderName(target, ctx), + JavaLangAnalyser.findAllPossibleSettersOfClass(target, ctx))); + } + + @Override + protected GenerationTargetClassDto mapToTargetSpecification( + MinimalTargetDefinition definition, + CodegenConfiguration configuration, + AnnotationProcessingContext ctx) { + GenerationTargetClassDto target = new GenerationTargetClassDto(); + target.setTypeName(definition.builderTypeName()); + target.setClassAccessModifier(AccessModifier.PUBLIC); + target.setFormattingMode(FormattingMode.NONE); + + ClassFieldDto field = new ClassFieldDto(); + field.setFieldName("target"); + field.setFieldType(definition.sourceTypeName()); + field.setVisibility(AccessModifier.PRIVATE); + field.setLiteralInitializer("new " + definition.sourceTypeName().getClassName() + "()"); + target.addClassField(field); + + for (ExecutableElement setter : definition.setters()) { + String setterName = setter.getSimpleName().toString(); + String methodName = StringUtils.uncapitalize(Strings.CI.removeStart(setterName, "set")); + MethodDto method = new MethodDto(methodName, definition.builderTypeName()); + method.setModifier(AccessModifier.PUBLIC); + String paramName = setter.getParameters().get(0).getSimpleName().toString(); + method.addParameter(JavaLangMapper.map2MethodParameter(setter.getParameters().get(0), ctx)); + method.setCode( + """ + target.$setterName:N($paramName:N); + return this;"""); + method.addArgument("setterName", setterName); + method.addArgument("paramName", paramName); + target.addMethod(method); + } + MethodDto build = new MethodDto("build", definition.sourceTypeName()); + build.setModifier(AccessModifier.PUBLIC); + build.setCode("return target;"); + target.addMethod(build); + return target; + } + + private static TypeName builderName(TypeElement target, AnnotationProcessingContext ctx) { + return new TypeName(ctx.getPackageName(target), target.getSimpleName() + "Builder"); + } + } + + @Test + void minimalProcessorGeneratesDelegatingBuilder() { + // Given + JavaFileObject source = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.codegen.processing.MinimalCodegenProcessorTest.GenerateBuilder; + @GenerateBuilder + public class Person { + private String name; + public void setName(String name) { this.name = name; } + } + """); + + // When + Compilation compilation = + ProcessorTestUtils.createCompiler(new MinimalProcessor()).compile(source); + + // Then + assertThat(compilation).succeeded(); + String generatedCode = loadGeneratedSource(compilation, "PersonBuilder"); + // The whole expected target as one textblock — pins structure, delegation and the target + // field; whitespace-normalized compare, nothing generated is left unchecked. + assertNormalizedEquals( + """ + package test; + public class PersonBuilder { + + private Person target = new Person(); + + public PersonBuilder name(String name) { + target.setName(name); + return this; + } + + public Person build() { + return target; + } + }""", + generatedCode, + "generated PersonBuilder must match the expected source"); + } +} diff --git a/processor/src/test/java/org/javahelpers/codegen/processing/ProcessorOptionRegistryTest.java b/processor/src/test/java/org/javahelpers/codegen/processing/ProcessorOptionRegistryTest.java new file mode 100644 index 00000000..dc0aed7d --- /dev/null +++ b/processor/src/test/java/org/javahelpers/codegen/processing/ProcessorOptionRegistryTest.java @@ -0,0 +1,49 @@ +/* + * MIT License + * + * Copyright (c) 2025-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.codegen.processing; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Set; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link ProcessorOptionRegistry}. */ +class ProcessorOptionRegistryTest { + + private static ProcessorOptionRegistry minimalRegistry() { + return ProcessorOptionRegistry.builder().options(Set.of()).build(); + } + + @Test + void reader_returnsReaderForEnvironment() { + assertNotNull(minimalRegistry().reader(null)); + } + + @Test + void formatterProfile_withoutConfiguredOption_returnsNull() { + assertNull(minimalRegistry().formatterProfile(new CompilerArgumentsReader(null))); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 136590d5..62e737df 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -91,85 +91,84 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { ProcessorAsserts.assertNotesInOrder( compilation, // Processor init - "[DEBUG] Starting BuilderProcessor...", - "[DEBUG] Loaded global configuration from compiler arguments: BuilderConfiguration[]", - "[DEBUG] Initializing generator registry", - "[DEBUG] ├─ Loaded 15 method generators and 10 builder enhancers total", - "[DEBUG] └─ Initialized GeneratorRegistry with 15 method generators and 10 builder", + "[DEBUG] simple-builders: Starting BuilderProcessor...", + "[DEBUG] simple-builders: Loaded global configuration from compiler arguments: BuilderConfiguration[verbose=ENABLED]", + "[DEBUG] simple-builders: Initializing generator registry", + "[DEBUG] simple-builders: ├─ Loaded 15 method generators and 10 builder enhancers total", + "[DEBUG] simple-builders: └─ Initialized GeneratorRegistry with 15 method generators and 10 builder", // Round 1 — start "simple-builders: PROCESSING ROUND START", "[DEBUG] simple-builders: Processing round started.", "[DEBUG] simple-builders: Found 1 annotated elements.", "[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor.", // Round 1 — configuration resolution - "[DEBUG] Processing element: VerboseTest", - "[DEBUG] ├─ Resolving configuration for element: VerboseTest", - "[DEBUG] │ ├─ Built-in template @SimpleBuilder found in DIRECT scope", - "[DEBUG] │ └─ Resulting configuration resolved: BuilderConfiguration[", + "[DEBUG] simple-builders: Processing element: VerboseTest", + "[DEBUG] simple-builders: ├─ Resolving configuration for element: VerboseTest", + "[DEBUG] simple-builders: │ ├─ Built-in template @SimpleBuilder found in DIRECT scope", + "[DEBUG] simple-builders: │ └─ Resulting configuration resolved: BuilderConfiguration[", "[DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the" + " builderGenerationPackages scope.", // Round 1 — builder definition extraction - "[DEBUG] Processing element: VerboseTest", - "[DEBUG] ├─ Extracting builder definition from: test.VerboseTest", - "[DEBUG] │ ├─ Builder will be generated as: test.VerboseTestBuilder", - "[DEBUG] │ ├─ Analysing setters for finding fields", - "[DEBUG] │ │ ├─ Analyzing method: setName(java.lang.String)", - "[DEBUG] │ │ │ ├─ Processing method generators", - "[DEBUG] │ │ │ │ ├─ Applying: BasicSetterGenerator (priority: 100)", - "[DEBUG] │ │ │ │ ├─ Applying: StringFormatHelperGenerator (priority: 80)", - "[DEBUG] │ │ │ │ ├─ Applying: SupplierMethodGenerator (priority: 60)", - "[DEBUG] │ │ │ │ ├─ Applying: StringBuilderConsumerGenerator (priority: 45)", - "[DEBUG] │ │ │ │ ├─ Applying: UpdateHelperGenerator (priority: 31)", - "[DEBUG] │ │ │ │ └─ Generated 5 methods", - "[DEBUG] │ │ │ └─ Adding field: name (type: java.lang.String)", - "[DEBUG] │ │ └─ Processed 1 possible setters: added 1 fields, skipped 0", - "[DEBUG] │ ├─ Processing class based enhancer", - "[DEBUG] │ │ ├─ Applying: GeneratedAnnotationEnhancer (priority: 120)", - "[DEBUG] │ │ ├─ Applying: BuilderImplementationAnnotationEnhancer (priority: 115)", - "[DEBUG] │ │ ├─ Added @BuilderImplementation annotation to builder VerboseTestBuilder", - "[DEBUG] │ │ ├─ Applying: CoreMethodsEnhancer (priority: 100)", - "[DEBUG] │ │ ├─ Applying: ConstructorEnhancer (priority: 95)", - "[DEBUG] │ │ ├─ Applying: WithInterfaceEnhancer (priority: 95)", - "[DEBUG] │ │ ├─ Applying: InterfaceEnhancer (priority: 90)", - "[DEBUG] │ │ ├─ Applying: ConditionalEnhancer (priority: 80)", - "[DEBUG] │ │ ├─ Added conditional methods to builder VerboseTestBuilder", - "[DEBUG] │ │ ├─ Applying: ClassJavaDocEnhancer (priority: 10)", - "[DEBUG] │ │ └─ Applied 8 builder enhancers", - "[DEBUG] │ ├─ Finalizing builder definition", - "[DEBUG] │ │ ├─ Resolving method conflicts", - "[DEBUG] │ │ │ └─ Resolved: 10 signatures, 0 conflicts, 0 methods removed", - "[DEBUG] │ │ └─ Finalized: 1 class fields, 5 builder-level methods, 2 constructors", - "[DEBUG] │ ├─ Builder will be generated as: VerboseTestBuilder", - "[DEBUG] │ └─ Builder definition extracted: VerboseTestBuilder", + "[DEBUG] simple-builders: Processing element: VerboseTest", + "[DEBUG] simple-builders: ├─ Extracting builder definition from: test.VerboseTest", + "[DEBUG] simple-builders: │ ├─ Builder will be generated as: test.VerboseTestBuilder", + "[DEBUG] simple-builders: │ ├─ Analysing setters for finding fields", + "[DEBUG] simple-builders: │ │ ├─ Analyzing method: setName(java.lang.String)", + "[DEBUG] simple-builders: │ │ │ ├─ Processing method generators", + "[DEBUG] simple-builders: │ │ │ │ ├─ Applied: BasicSetterGenerator (priority: 100): +1 methods", + "[DEBUG] simple-builders: │ │ │ │ ├─ Applied: StringFormatHelperGenerator (priority: 80): +1 methods", + "[DEBUG] simple-builders: │ │ │ │ ├─ Applied: SupplierMethodGenerator (priority: 60): +1 methods", + "[DEBUG] simple-builders: │ │ │ │ ├─ Applied: StringBuilderConsumerGenerator (priority: 45): +1 methods", + "[DEBUG] simple-builders: │ │ │ │ ├─ Applied: UpdateHelperGenerator (priority: 31): +1 methods", + "[DEBUG] simple-builders: │ │ │ │ └─ Generated 5 methods", + "[DEBUG] simple-builders: │ │ │ └─ Adding field: name (type: java.lang.String)", + "[DEBUG] simple-builders: │ │ └─ Processed 1 possible setters: added 1 fields, skipped 0", + "[DEBUG] simple-builders: │ ├─ Processing class based enhancer", + "[DEBUG] simple-builders: │ │ ├─ Applied: GeneratedAnnotationEnhancer (priority: 120): +1 annotations", + "[DEBUG] simple-builders: │ │ ├─ Added @BuilderImplementation annotation to builder VerboseTestBuilder", + "[DEBUG] simple-builders: │ │ ├─ Applied: BuilderImplementationAnnotationEnhancer (priority: 115): +1 annotations", + "[DEBUG] simple-builders: │ │ ├─ Applied: CoreMethodsEnhancer (priority: 100): +3 methods", + "[DEBUG] simple-builders: │ │ ├─ Applied: ConstructorEnhancer (priority: 95): +2 constructors", + "[DEBUG] simple-builders: │ │ ├─ Applied: WithInterfaceEnhancer (priority: 95): +1 nested types", + "[DEBUG] simple-builders: │ │ ├─ Applied: InterfaceEnhancer (priority: 90): +1 interfaces", + "[DEBUG] simple-builders: │ │ ├─ Added conditional methods to builder VerboseTestBuilder", + "[DEBUG] simple-builders: │ │ ├─ Applied: ConditionalEnhancer (priority: 80): +2 methods", + "[DEBUG] simple-builders: │ │ ├─ Applied: ClassJavaDocEnhancer (priority: 10): javadoc added", + "[DEBUG] simple-builders: │ │ └─ Applied 8 builder enhancers", + "[DEBUG] simple-builders: │ ├─ Finalizing builder definition", + "[DEBUG] simple-builders: │ │ ├─ Resolving method conflicts", + "[DEBUG] simple-builders: │ │ │ └─ Resolved: 10 signatures, 0 conflicts, 0 methods removed", + "[DEBUG] simple-builders: │ │ └─ Finalized: 1 class fields, 5 builder-level methods, 2 constructors", + "[DEBUG] simple-builders: │ └─ Builder definition extracted: VerboseTestBuilder", // Round 1 — code generation - "[DEBUG] ├─ Code generation for class: VerboseTestBuilder", - "[DEBUG] │ ├─ JavaClassSource created", - "[DEBUG] │ ├─ Class metadata added", - "[DEBUG] │ ├─ Generating 1 fields", - "[DEBUG] │ │ └─ Fields added: 1 fields", - "[DEBUG] │ ├─ Generating 2 constructors", - "[DEBUG] │ │ └─ Constructors added: 2", - "[DEBUG] │ ├─ Generating 10 method candidates", - "[DEBUG] │ │ ├─ Resolved to 10 methods after conflict resolution", - "[DEBUG] │ │ └─ Methods added: 10", - "[DEBUG] │ ├─ Generating 1 nested type(s)", - "[DEBUG] │ │ ├─ Generated nested type: With", - "[DEBUG] │ │ └─ Nested types added", - "[DEBUG] │ ├─ Class-level annotations added", - "[DEBUG] │ ├─ Using JDT source formatting with Eclipse formatter" + "[DEBUG] simple-builders: Generating targets for: VerboseTest", + "[DEBUG] simple-builders: ├─ Code generation for class: VerboseTestBuilder", + "[DEBUG] simple-builders: │ ├─ JavaClassSource created", + "[DEBUG] simple-builders: │ ├─ Class metadata added", + "[DEBUG] simple-builders: │ ├─ Generating 1 fields", + "[DEBUG] simple-builders: │ │ └─ Fields added: 1 fields", + "[DEBUG] simple-builders: │ ├─ Generating 2 constructors", + "[DEBUG] simple-builders: │ │ └─ Constructors added: 2", + "[DEBUG] simple-builders: │ ├─ Generating 10 method candidates", + "[DEBUG] simple-builders: │ │ ├─ Resolved to 10 methods after conflict resolution", + "[DEBUG] simple-builders: │ │ └─ Methods added: 10", + "[DEBUG] simple-builders: │ ├─ Generating 1 nested type(s)", + "[DEBUG] simple-builders: │ │ ├─ Generated nested type: With", + "[DEBUG] simple-builders: │ │ └─ Nested types added", + "[DEBUG] simple-builders: │ ├─ Class-level annotations added", + "[DEBUG] simple-builders: │ ├─ Using JDT source formatting with Eclipse formatter" + " profile 'eclipse-java-format.xml'.", - "[DEBUG] │ ├─ Writing class to file: test.VerboseTestBuilder", - "[DEBUG] │ └─ Successfully generated class: VerboseTestBuilder", - "[DEBUG] ├─ Jackson module entry added", - "[DEBUG] └─ Generated builder with 1 fields and 10 methods for VerboseTestBuilder", - "simple-builders: Successfully generated 1 builder(s) in this processing round", - // Round 2 — no new elements + "[DEBUG] simple-builders: │ ├─ Creating JavaFileObject: test.VerboseTestBuilder", + "[DEBUG] simple-builders: │ ├─ Writing class to file: /SOURCE_OUTPUT/test/VerboseTestBuilder.java", + "[DEBUG] simple-builders: │ ├─ Successfully generated class: VerboseTestBuilder", + "[DEBUG] simple-builders: │ └─ Generated builder with 1 fields and 10 methods for" + + " VerboseTestBuilder", + "simple-builders: Successfully generated 1 target(s) in this processing round", + // Round 2 — no new elements: planning is skipped before the domain hook runs "simple-builders: PROCESSING ROUND START", "[DEBUG] simple-builders: Processing round started.", - "[DEBUG] simple-builders: Found 0 annotated elements.", - "[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor.", - "[DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the" - + " builderGenerationPackages scope."); + "[DEBUG] simple-builders: No matching elements collected — skipping planning for this" + + " round."); } @Test @@ -1501,7 +1500,7 @@ public class ForcedOldRelease { public ForcedOldRelease() {} } assertThat(compilation).failed(); assertThat(compilation) .hadErrorContaining( - "simple-builders requires Java 17 or higher for annotation processing."); + "simple-builders: requires Java 17 or higher for annotation processing."); } @Test @@ -3591,7 +3590,7 @@ public PersonDto build() { assertThat(compilation).succeeded(); assertThat(compilation) .hadWarningContaining( - "Failed to generate builder - Builder class 'test.PersonDtoBuilder' already exists."); + "Failed to generate target - Target class 'test.PersonDtoBuilder' already exists."); // The processor should detect the conflict and handle it gracefully // Either by skipping generation or by generating with a different approach @@ -3638,7 +3637,7 @@ public class OrderDto { assertThat(compilation).succeeded(); assertThat(compilation) .hadWarningContaining( - "Failed to generate builder - Unable to create builder class 'test.OrderDtoFactory': Attempt to recreate a file for type test.OrderDtoFactory."); + "Failed to generate target - Unable to create builder class 'test.OrderDtoFactory': Attempt to recreate a file for type test.OrderDtoFactory."); // Verify that a builder was generated String generatedCode = loadGeneratedSource(compilation, "OrderDtoFactory"); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 3f13288a..fe8b62fa 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -41,6 +41,7 @@ import javax.lang.model.SourceVersion; import javax.lang.model.element.TypeElement; import javax.tools.JavaFileObject; +import org.javahelpers.codegen.processing.CodegenTask; import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; @@ -48,7 +49,6 @@ import org.javahelpers.simple.builders.processor.model.type.TypeInstantiation; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.BuilderProcessingContext; -import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; import org.junit.jupiter.api.BeforeEach; @@ -1314,7 +1314,8 @@ public boolean process(Set annotations, RoundEnvironment BuilderProcessingContext context = new BuilderProcessingContext( ProcessingLogger.create(processingEnv), BuilderConfiguration.DEFAULT, processingEnv); - context.initProcessingTarget(new ProcessingTarget(configuration("lib", "Builder"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, configuration("lib", "Builder"), helper, "")); BuilderScopeResolver resolver = context.getBuilderScopeResolver(); // Register the type as generated, mirroring the real processor which calls // registerGeneratedBuilder before any resolution happens. @@ -1324,52 +1325,56 @@ public boolean process(Set annotations, RoundEnvironment second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior resolver.resetGeneratedBuilders(); - context.initProcessingTarget( - new ProcessingTarget(configuration("other", "OtherBuilder"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, configuration("other", "OtherBuilder"), helper, "")); afterConfigurationChange = resolver.resolveUsableBuilderType(helper); - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("other"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, usageOnlyConfiguration("other"), helper, "")); beforeRegistration = resolver.resolveUsableBuilderType(helper); // Registration alone is not enough — the type must be in scope resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + context.setCurrentTask(new CodegenTask<>(helper, usageOnlyConfiguration("lib"), helper, "")); resolver.resetGeneratedBuilders(); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + context.setCurrentTask(new CodegenTask<>(helper, usageOnlyConfiguration("lib"), helper, "")); resolver.resetGeneratedBuilders(); usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper); // Usage scope with builderUsageSuffix="Factory" - context.initProcessingTarget( - new ProcessingTarget(usageWithSuffixConfiguration("lib", "Factory"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, usageWithSuffixConfiguration("lib", "Factory"), helper, "")); usageWithSuffix = resolver.resolveUsableBuilderType(helper); // Usage scope with default suffix (no builderUsageSuffix configured) - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + context.setCurrentTask(new CodegenTask<>(helper, usageOnlyConfiguration("lib"), helper, "")); usageDefaultSuffix = resolver.resolveUsableBuilderType(helper); // Generated builder in the same package as the referenced builder: package-private // contract members are accessible - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "lib")); + context.setCurrentTask( + new CodegenTask<>(helper, usageOnlyConfiguration("lib"), helper, "lib")); usagePackagePrivate = resolver.resolveUsableBuilderType(helper); // usingExistingBuilders=DISABLED: existing builders are not reused, but a builder // generated in the current round is own generation and still resolves - context.initProcessingTarget( - new ProcessingTarget(existingBuildersDisabledConfiguration("lib"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, existingBuildersDisabledConfiguration("lib"), helper, "")); usageWithExistingDisabled = resolver.resolveUsableBuilderType(helper); resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageGeneratedWithExistingDisabled = resolver.resolveUsableBuilderType(helper); // usingFieldFunctionPrefill=DISABLED: field functions are not used as prefilled path - context.initProcessingTarget(new ProcessingTarget(prefillDisabledConfiguration("lib"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, prefillDisabledConfiguration("lib"), helper, "")); resolver.resetGeneratedBuilders(); usagePrefillDisabled = resolver.resolveUsableBuilderType(helper); // Builder-contract probe for java.lang.String: StringBuilder must not resolve because its // only parameterless method returning String is toString(), an Object signature - context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("java.lang"), "")); + context.setCurrentTask( + new CodegenTask<>(helper, usageOnlyConfiguration("java.lang"), helper, "")); resolver.resetGeneratedBuilders(); TypeElement stringElement = processingEnv.getElementUtils().getTypeElement("java.lang.String"); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java index 7be84fff..5295b17c 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -74,6 +74,25 @@ void mapStruct_processorOrderForward_shouldStillUseGeneratedBuilder() { assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); } + @Test + void mapStruct_markedBeanOutsideGenerationScope_shouldMapViaSetters() { + // A round whose plan is empty still finalizes the registry — MapStruct maps the out-of-scope + // bean without a builder instead of deferring until no implementation is created + Compilation compilation = + mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) + .withOptions( + "-Amapstruct.suppressGeneratorTimestamp=true", + "-Amapstruct.suppressGeneratorVersionInfoComment=true", + "-Asimplebuilder.builderGenerationPackages=scoped") + .compile(mutableDto(), mutableDtoMapper()); + assertThat(compilation).succeeded(); + + String mapperImpl = loadGeneratedSource(compilation, "MutableDtoMapperImpl"); + assertFalse( + mapperImpl.contains("MutableDtoBuilder"), "An out-of-scope bean gets no builder to bind"); + assertTrue(mapperImpl.contains(".setName("), "MapStruct should fall back to setter mapping"); + } + @Test void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper()); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorEdgeCasesTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorEdgeCasesTest.java index c1d9944d..a0775814 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorEdgeCasesTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorEdgeCasesTest.java @@ -183,7 +183,7 @@ public class PersonDtoBuilder { assertThat(compilation).succeeded(); assertThat(compilation) .hadWarningContaining( - "Failed to generate builder - Builder class 'test.PersonDtoBuilder' already exists"); + "Failed to generate target - Target class 'test.PersonDtoBuilder' already exists"); } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java index 428a3125..379f07ae 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/RoasterCodeGeneratorResilienceTest.java @@ -93,7 +93,7 @@ void shouldWrapRenderingRuntimeExceptionInCodeGenerationException() { env, logger, context.getPerformanceTracker(), context::reportBasedOnStrictMode)); SourceFormatter formatter = - new SourceFormatterResolver(env, logger).resolve(FormattingMode.NONE); + new SourceFormatterResolver(logger, null).resolve(FormattingMode.NONE); CodeGenerationException thrown = assertThrows( CodeGenerationException.class, () -> generator.generateClass(classDef, formatter)); @@ -129,7 +129,7 @@ void shouldKeepFirstMethodAndWarnOnDuplicateSignature() { context::reportBasedOnStrictMode)); SourceFormatter formatter = - new SourceFormatterResolver(env, capturing.logger()).resolve(FormattingMode.NONE); + new SourceFormatterResolver(capturing.logger(), null).resolve(FormattingMode.NONE); // generation itself succeeds; only the file-write step fails on the stub's null filer assertThrows(NullPointerException.class, () -> generator.generateClass(classDef, formatter)); capturing.assertMessage( diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java index 93a5a098..8a412fb9 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java @@ -120,7 +120,7 @@ private Singleton() {} .compile(unconstructable, holder("test", "Builders", "ext.Singleton")); assertThat(compilation).succeeded(); - assertThat(compilation).hadWarningContaining("Failed to generate builder"); + assertThat(compilation).hadWarningContaining("Failed to generate target"); assertThat(compilation).hadWarningContaining("No accessible constructor"); ProcessorAsserts.assertNoBuilderGenerated( compilation, "Singleton", "A type without accessible constructor must not get a builder"); @@ -165,7 +165,7 @@ private Singleton() {} .compile(unconstructable, holder("test", "Builders", "ext.Singleton")); assertThat(compilation).failed(); - assertThat(compilation).hadErrorContaining("Failed to generate builder"); + assertThat(compilation).hadErrorContaining("Failed to generate target"); assertThat(compilation).hadErrorContaining("No accessible constructor"); } @@ -754,6 +754,22 @@ public BaseDto() {} """); } + @Test + void sourcePackages_NoResolvableTypes_Warns() { + JavaFileObject holder = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor(sourcePackages = "nonexistent.pkg") + public class Builders {} + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(holder); + + assertThat(compilation).hadWarningContaining("resolves no types - nothing to generate"); + } + private static JavaFileObject holder(String pkg, String className, String targetType) { return ProcessorTestUtils.forSource( """ diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/StrictModeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/StrictModeTest.java index b7495cec..23dac4df 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/StrictModeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/StrictModeTest.java @@ -68,7 +68,7 @@ void defaultMode_generationFailure_isWarning_andCompilationSucceeds() { Compilation compilation = createCompiler().compile(dto(), collidingBuilder()); assertThat(compilation).succeeded(); - assertThat(compilation).hadWarningContaining("Failed to generate builder"); + assertThat(compilation).hadWarningContaining("Failed to generate target"); } @Test @@ -79,6 +79,6 @@ void strictMode_generationFailure_isError_andCompilationFails() { .compile(dto(), collidingBuilder()); assertThat(compilation).failed(); - assertThat(compilation).hadErrorContaining("Failed to generate builder"); + assertThat(compilation).hadErrorContaining("Failed to generate target"); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java index c8ea663b..5620e299 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java @@ -31,6 +31,8 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; import org.javahelpers.simple.builders.processor.classgen.GenerationPhases; import org.javahelpers.simple.builders.processor.processing.PipelinePhases; import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger; @@ -53,7 +55,7 @@ private JsonNode generateReportAndParseJson(String outputFile) throws IOExceptio new ActivePerformanceTracker(outputFile, PipelinePhases.PIPELINE_HIERARCHY); tracker.startClass("TestClassA"); tracker.startPhase(); - tracker.endPhase(PipelinePhases.PHASE_BUILDER_DEFINITION_EXTRACTION); + tracker.endPhase(PipelinePhases.PHASE_ANALYSIS); tracker.startGenerator(); tracker.endGenerator("FieldSupplierGenerator"); tracker.startEnhancer(); @@ -62,7 +64,7 @@ private JsonNode generateReportAndParseJson(String outputFile) throws IOExceptio tracker.startClass("TestClassB"); tracker.startPhase(); - tracker.endPhase(PipelinePhases.PHASE_DTO_MAPPING); + tracker.endPhase(PipelinePhases.PHASE_TARGET_MAPPING); tracker.endClass(3, 1); CapturingProcessingLogger capturing = CapturingProcessingLogger.create(); @@ -73,6 +75,108 @@ private JsonNode generateReportAndParseJson(String outputFile) throws IOExceptio return new ObjectMapper().readTree(jsonContent); } + /** Asserts the metric entries are ordered by {@code elapsedNanos} descending. */ + private static void assertSortedByElapsedDesc(JsonNode metrics) { + for (int i = 1; i < metrics.size(); i++) { + assertTrue( + metrics.get(i - 1).get("elapsedNanos").asLong() + >= metrics.get(i).get("elapsedNanos").asLong(), + "metrics not sorted by elapsedNanos descending at index " + i); + } + } + + /** Asserts the class-metric entries carry exactly the given class names. */ + private static void assertClassNames(JsonNode classMetrics, Set expected) { + Set names = new HashSet<>(); + for (JsonNode cm : classMetrics) { + names.add(cm.get("className").asText()); + } + assertEquals(expected, names); + } + + /** The class-metric entry for the given class name — fails the test when absent. */ + private static JsonNode classMetricByName(JsonNode root, String className) { + for (JsonNode cm : root.get("classMetrics")) { + if (className.equals(cm.get("className").asText())) { + return cm; + } + } + return fail("No classMetric entry for " + className); + } + + /** + * Pins the complete tagged report layout: every line carries the logger's diagnostic prefix and + * the tree connectors sit after it. Timing values are normalized to {@code } — the structure, + * labels and indentation are what is asserted. + */ + @Test + void generateReport_prefixedLogger_producesExpectedTaggedLayout() { + ActivePerformanceTracker tracker = + new ActivePerformanceTracker(null, PipelinePhases.PIPELINE_HIERARCHY); + tracker.startClass("MyClass"); + tracker.startPhase(); + tracker.endPhase(PipelinePhases.PHASE_ANALYSIS); + tracker.startGenerator(); + tracker.endGenerator("MyGenerator"); + tracker.startEnhancer(); + tracker.endEnhancer("MyEnhancer"); + tracker.endClass(5, 2); + + CapturingProcessingLogger capturing = CapturingProcessingLogger.create("simple-builders"); + tracker.generateReport(capturing.logger()); + + // Remove the diagnostic kind marker and the exact measurements (durations, percentages, + // counts) before comparing — the assertion pins the report's structure, labels and + // indentation, not the timings + String actual = + String.join( + "\n", + capturing.messages().stream() + .map(m -> m.replaceFirst("^[A-Z]+: ", "")) + .map(m -> m.replaceAll("\\d+(\\.\\d+)?", "")) + .map(String::stripTrailing) + .toList()); + + assertEquals( + """ + simple-builders: PERFORMANCE REPORT + simple-builders: ================================ + simple-builders: Total classes processed: + simple-builders: Total processing time: s + simple-builders: + simple-builders: Phase breakdown: + simple-builders: ├─ Element Collection: s (%) + simple-builders: ├─ Configuration Resolution: s (%) + simple-builders: ├─ Analysis: s (%) + simple-builders: ├─ Target Mapping: s (%) + simple-builders: └─ Code Generation: s (%) + simple-builders: ├─ Source Construction: s (%) + simple-builders: │ ├─ Element Building: s (%) + simple-builders: │ │ ├─ Class Creation: s (%) + simple-builders: │ │ ├─ Class Metadata: s (%) + simple-builders: │ │ ├─ Fields: s (%) + simple-builders: │ │ ├─ Constructors: s (%) + simple-builders: │ │ ├─ Methods: s (%) + simple-builders: │ │ ├─ Nested Types: s (%) + simple-builders: │ │ └─ Class Annotations: s (%) + simple-builders: │ ├─ String Generation: s (%) + simple-builders: │ └─ Formatting: s (%) + simple-builders: └─ File Writing: s (%) + simple-builders: + simple-builders: Average per class: ms + simple-builders: + simple-builders: Top slowest classes: + simple-builders: . MyClass - ms ( fields, collections) + simple-builders: + simple-builders: Top slowest MethodGenerators: + simple-builders: . MyGenerator - s ( calls, ms/call) + simple-builders: + simple-builders: Top slowest BuilderEnhancers: + simple-builders: . MyEnhancer - s ( calls, ms/call)\ + """, + actual); + } + @Test void generateReport_withNoData_logsBasicReport() { ActivePerformanceTracker tracker = @@ -236,11 +340,9 @@ void jsonReport_classMetrics_containsAllClassesSortedByElapsedDesc() throws IOEx JsonNode classMetrics = root.get("classMetrics"); assertEquals(2, classMetrics.size()); - assertEquals("TestClassA", classMetrics.get(0).get("className").asText()); - assertEquals("TestClassB", classMetrics.get(1).get("className").asText()); - assertTrue( - classMetrics.get(0).get("elapsedNanos").asLong() - >= classMetrics.get(1).get("elapsedNanos").asLong()); + // Ordering is the contract (desc by elapsed); which class is first is timing-dependent. + assertClassNames(classMetrics, Set.of("TestClassA", "TestClassB")); + assertSortedByElapsedDesc(classMetrics); } @Test @@ -248,7 +350,7 @@ void jsonReport_classMetrics_containsFieldAndCollectionCounts() throws IOExcepti Path jsonFile = tempDir.resolve("report.json"); JsonNode root = generateReportAndParseJson(jsonFile.toString()); - JsonNode classA = root.get("classMetrics").get(0); + JsonNode classA = classMetricByName(root, "TestClassA"); assertEquals(5, classA.get("fieldCount").asInt()); assertEquals(2, classA.get("collectionCount").asInt()); assertTrue(classA.has("elapsedMs")); @@ -285,8 +387,8 @@ void jsonReport_phaseBreakdown_containsPhaseHierarchy() throws IOException { JsonNode phases = root.get("phaseBreakdown"); assertTrue(phases.has(PipelinePhases.PHASE_CONFIGURATION_RESOLUTION)); - assertTrue(phases.has(PipelinePhases.PHASE_BUILDER_DEFINITION_EXTRACTION)); - assertTrue(phases.has(PipelinePhases.PHASE_DTO_MAPPING)); + assertTrue(phases.has(PipelinePhases.PHASE_ANALYSIS)); + assertTrue(phases.has(PipelinePhases.PHASE_TARGET_MAPPING)); assertTrue(phases.has(PipelinePhases.PHASE_CODE_GENERATION)); JsonNode codeGen = phases.get(PipelinePhases.PHASE_CODE_GENERATION); @@ -481,13 +583,9 @@ void jsonReport_generatorAndEnhancerStats_sortedByElapsedDesc() throws IOExcepti JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile)); JsonNode genStats = root.get("generatorStats"); - long first = genStats.get(0).get("elapsedNanos").asLong(); - long second = genStats.get(1).get("elapsedNanos").asLong(); - assertTrue(first >= second, "Generator stats should be sorted by elapsed descending"); + assertSortedByElapsedDesc(genStats); JsonNode enhStats = root.get("enhancerStats"); - long firstE = enhStats.get(0).get("elapsedNanos").asLong(); - long secondE = enhStats.get(1).get("elapsedNanos").asLong(); - assertTrue(firstE >= secondE, "Enhancer stats should be sorted by elapsed descending"); + assertSortedByElapsedDesc(enhStats); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java index c524a352..63961d33 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java @@ -74,9 +74,20 @@ private CapturingProcessingLogger(List messages, ProcessingLogger logger * @return a new capturing logger instance */ public static CapturingProcessingLogger create() { + return create(null); + } + + /** + * Creates a new capturing logger carrying a diagnostic logging prefix. + * + * @param loggingPrefix the logger's diagnostic prefix, or {@code null} for unprefixed output + * @return a new capturing logger instance + */ + public static CapturingProcessingLogger create(String loggingPrefix) { List messages = new ArrayList<>(); ProcessingLogger logger = - ProcessingLogger.create(createCapturingEnvironment(messages, Collections.emptyMap())); + ProcessingLogger.create( + createCapturingEnvironment(messages, Collections.emptyMap()), loggingPrefix); return new CapturingProcessingLogger(messages, logger); }