diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 0a9d5b16..42fc873b 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -61,6 +61,8 @@ * usingArrayListBuilderWithElementBuilders, usingHashSetBuilder, * usingHashSetBuilderWithElementBuilders, usingHashMapBuilder (all default: true) *
  • Integration: generateWithInterface (default: true) + *
  • Builder Scoping: builderGenerationPackages, builderUsagePackages (default: "" = all + * annotated DTOs; comma-separated package list, subpackages included) * * *

    This annotation is itself a built-in {@link Template}: it is meta-annotated with @@ -666,6 +668,46 @@ */ String jacksonModulePackage() default ""; + /** + * Comma-separated list of packages for which builders should be generated by this processor. + *
    + * When set, builder generation is restricted to DTOs whose package equals or is a subpackage of + * one of the listed packages. Builder references to DTOs inside this scope are emitted directly + * without an extra existence check. + * + *

    Subpackages are included automatically ({@code com.example} also matches {@code + * com.example.sub}). + * + *

    Default: "" (empty - no package restriction; generate builders for all annotated DTOs) + *
    + * Compiler option: -Asimplebuilder.builderGenerationPackages + * + *

    This option is intended as a project-wide setting, usually configured via the compiler + * arguments for an annotation processor. + * + * @return the packages for which builders are generated + */ + String builderGenerationPackages() default ""; + + /** + * Comma-separated list of packages whose builders may be referenced as helper methods by other + * generated builders. DTOs in a listed package but not covered by {@link + * #builderGenerationPackages()} are only referenced when the processor can resolve the compiled + * builder type on the classpath. + * + *

    Subpackages are included automatically ({@code com.example} also matches {@code + * com.example.sub}). + * + *

    Default: "" (empty - any annotated type may be referenced; no type existence check)
    + * Compiler option: -Asimplebuilder.builderUsagePackages + * + *

    This option is intended as a project-wide setting, usually configured via the compiler + * arguments for an annotation processor. + * + * @return the packages whose builders may be used by other builders + */ + String builderUsagePackages() default ""; + // === Naming === /** * Suffix to append to the DTO name to generate the builder class name.
    diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 01a0ca35..3c32a2c2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -678,6 +678,32 @@ See [**CUSTOMIZING.md**](CUSTOMIZING.md) for complete implementation examples an --- +### Builder Scoping + +#### `builderGenerationPackages` + +**Default**: `""` (empty) | **Compiler Option**: `-Asimplebuilder.builderGenerationPackages=pkg1,pkg2,...` + +Restricts builder generation to DTOs whose package equals or is a subpackage of one of the listed packages. Subpackages are included automatically (`com.example` also matches `com.example.sub`). + +When set, the processor only generates builders for annotated DTOs inside this scope. Because the processor is generating these builders, references to them from other generated builders can be emitted directly without an extra type-existence check. + +This option is intended as a project-wide setting, usually configured via the processor's compiler arguments. + +#### `builderUsagePackages` + +**Default**: `""` (empty) | **Compiler Option**: `-Asimplebuilder.builderUsagePackages=pkg1,pkg2,...` + +Allows generated builders in other packages to reference builders from the listed packages. Subpackages are included automatically. + +A DTO inside the usage scope but **not** inside the generation scope is only referenced as a builder when the processor can resolve the compiled builder type on the classpath. If the builder type cannot be found, the field falls back to a plain setter. + +**Typical use case**: referencing builders from precompiled library DTOs that carry the `@SimpleBuilder` annotation but are processed in a different compilation unit. + +**Backward compatibility**: When both options are empty (the default), the processor behaves exactly as before and references any `@SimpleBuilder`-annotated type without a type-existence check. + +--- + ### Integration & Annotations #### `generateWithInterface` @@ -1277,6 +1303,10 @@ methodAccess = AccessModifier.PRIVATE # Component Filtering -Asimplebuilder.deactivateGenerationComponents=pattern1,pattern2,... +# Builder Scoping +-Asimplebuilder.builderGenerationPackages=pkg1,pkg2,... +-Asimplebuilder.builderUsagePackages=pkg1,pkg2,... + # Integration & Annotations -Asimplebuilder.generateWithInterface=ENABLED|DISABLED -Asimplebuilder.implementsBuilderBase=ENABLED|DISABLED @@ -1328,6 +1358,10 @@ methodAccess = AccessModifier.PRIVATE usingBuilderImplementationAnnotation = OptionState.ENABLED, usingJacksonDeserializerAnnotation = OptionState.ENABLED, + // Builder Scoping (project-wide compiler arguments are recommended) + builderGenerationPackages = "", + builderUsagePackages = "", + // Naming builderSuffix = "Builder", setterSuffix = "" diff --git a/docs/CUSTOMIZING.md b/docs/CUSTOMIZING.md index b9ff2b8d..1144d519 100644 --- a/docs/CUSTOMIZING.md +++ b/docs/CUSTOMIZING.md @@ -296,6 +296,7 @@ compileJava { - **Unified Registry** - A single `GeneratorRegistry` loads all generators from one service file - **Type-Based Separation** - The registry automatically separates generators by type using `instanceof` - **Single Service File** - All generators (both method and builder) are registered in one place +- **Builder Scope Resolution** - The decision whether a DTO type may be referenced as a builder is centralized in `BuilderScopeResolver`. Custom generators and enhancers should continue to rely on `TypeName.getBuilderType().isPresent()` instead of reimplementing the scoping rules. ### Priority Management 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 d89d43e1..98b4e7b8 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 @@ -164,6 +164,25 @@ public boolean process(Set annotations, RoundEnvironment return false; }); + // Filter out elements whose package is not in the configured builder generation scope. + // An empty scope means "unscoped" and keeps the current backward-compatible behavior. + BuilderConfiguration globalConfig = context.getConfigurationReader().getGlobalConfiguration(); + elementsToProcess.removeIf( + element -> { + String packageName = context.getPackageName(element); + if (!globalConfig.isInGenerationScope(packageName)) { + context.debug( + "Skipping element '%s' because package '%s' is outside builderGenerationPackages.", + element.getSimpleName(), packageName); + return true; + } + return false; + }); + + // Register the types that will actually be generated so the builder scope resolver can trust + // in-compilation references without an expensive type-existence search. + context.setGeneratedTypeNames(elementsToProcess); + context.info("simple-builders: PROCESSING ROUND START"); context.debug( "simple-builders: Processing round started. Found %d annotated elements.", 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 new file mode 100644 index 00000000..1310660b --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -0,0 +1,153 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.analysis; + +import java.util.Optional; +import java.util.Set; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.TypeElement; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; +import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; +import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.javahelpers.simple.builders.processor.processing.ProcessingContext; + +/** + * Resolves whether a builder type may be referenced for a given DTO type. + * + *

    This resolver centralizes the decision so that generators and enhancers can keep relying on + * {@link TypeName#getBuilderType()} without knowing about package scoping rules. + */ +public class BuilderScopeResolver { + private final ProcessingContext context; + + public BuilderScopeResolver(ProcessingContext context) { + this.context = context; + } + + /** + * Returns whether the given package is inside the configured builder generation scope. + * + *

    An empty generation scope means "unscoped" and returns {@code true} for every package. + */ + public boolean isInGenerationScope(String packageName) { + return context + .getConfigurationReader() + .getGlobalConfiguration() + .isInGenerationScope(packageName); + } + + /** + * Returns whether the given package is inside the configured builder usage scope. + * + *

    An empty usage scope means "unscoped" and returns {@code true} for every package. + */ + public boolean isInUsageScope(String packageName) { + return context.getConfiguration().isInUsageScope(packageName); + } + + /** + * Resolves the builder type that may be used for the referenced type, if any. + * + *

    The decision is based on the configured {@code builderGenerationPackages} and {@code + * builderUsagePackages} lists and preserves the existing opt-out rules for types that must not + * have a builder reference. + * + * @param referencedType the type for which a builder reference may be emitted + * @param resolverContext the processing context used for type lookup + * @return the builder type to use, or empty if no reference is allowed + */ + public Optional resolveUsableBuilderType( + TypeElement referencedType, ProcessingContext resolverContext) { + if (JavaLangAnalyser.findAnnotation( + referencedType, + org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration.class) + .isPresent()) { + return Optional.empty(); + } + + if (!hasBuilderTemplateAnnotation(referencedType)) { + return Optional.empty(); + } + + TypeName candidate = JavaLangMapper.createBuilderTypeName(referencedType, resolverContext); + + // Trust builders that the current processing round will actually generate, regardless of + // package scoping. This must be the first decision so a filtered local type is never trusted. + if (resolverContext.isGeneratedType(referencedType)) { + return Optional.of(candidate); + } + + String packageName = + JavaLangMapper.extractPackageName(referencedType.getQualifiedName().toString()); + + // For external/precompiled types, use the globally configured generation scope as the source + // of truth and the per-target usage scope for optional references. + BuilderConfiguration globalConfig = + resolverContext.getConfigurationReader().getGlobalConfiguration(); + BuilderConfiguration config = resolverContext.getConfiguration(); + + Set generationPackages = globalConfig.getBuilderGenerationPackagesSet(); + Set usagePackages = config.getBuilderUsagePackagesSet(); + + if (generationPackages.isEmpty() && usagePackages.isEmpty()) { + return Optional.of(candidate); + } + + if (isPackageIn(generationPackages, packageName)) { + return Optional.of(candidate); + } + + if (isPackageIn(usagePackages, packageName) + && resolverContext.getTypeElement(candidate.getFullQualifiedName()) != null) { + return Optional.of(candidate); + } + + return Optional.empty(); + } + + private boolean hasBuilderTemplateAnnotation(TypeElement typeElement) { + for (AnnotationMirror mirror : context.getAllAnnotationMirrors(typeElement)) { + TypeElement annotationTypeElement = (TypeElement) mirror.getAnnotationType().asElement(); + if (annotationTypeElement != null + && JavaLangAnalyser.findAnnotation(annotationTypeElement, SimpleBuilder.Template.class) + .isPresent()) { + return true; + } + } + return false; + } + + private static boolean isPackageIn(Set packages, String packageName) { + if (packageName == null || packages.isEmpty()) { + return false; + } + for (String p : packages) { + if (packageName.equals(p) || packageName.startsWith(p + ".")) { + return true; + } + } + return false; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java index d7610c4e..d3000acf 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java @@ -30,7 +30,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.Set; import javax.lang.model.element.Element; import javax.lang.model.element.Modifier; @@ -43,7 +42,6 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.type.TypeVariable; import javax.lang.model.util.SimpleTypeVisitor14; -import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.enums.AccessModifier; import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto; @@ -215,22 +213,10 @@ private static void setBuilderAndConstructorInfo( */ private static void setBuilderTypeIfAnnotated( TypeName typeName, TypeElement typeElement, ProcessingContext context) { - // Types explicitly opted out must never be referenced as builders by other DTOs. - if (JavaLangAnalyser.findAnnotation(typeElement, Ignore4BuilderGeneration.class).isPresent()) { - return; - } - - Optional foundBuilderAnnotation = - JavaLangAnalyser.findAnnotation( - typeElement, org.javahelpers.simple.builders.core.annotations.SimpleBuilder.class); - - // Type element must have @SimpleBuilder annotation - if (foundBuilderAnnotation.isEmpty()) { - return; - } - - TypeName builderType = createBuilderTypeName(typeElement, context); - typeName.setBuilderType(builderType); + context + .getBuilderScopeResolver() + .resolveUsableBuilderType(typeElement, context) + .ifPresent(typeName::setBuilderType); } /** @@ -276,20 +262,10 @@ private static void setElementBuilderTypeForGenericCollections( return; } - // Opted-out element types must never be referenced as element builders. - if (JavaLangAnalyser.findAnnotation(elementTypeElement, Ignore4BuilderGeneration.class) - .isPresent()) { - return; - } - - // Element type must have @SimpleBuilder annotation - if (!hasSimpleBuilderAnnotation(elementTypeElement)) { - return; - } - - // Set the element builder type - TypeName elementBuilderType = createBuilderTypeName(elementTypeElement, context); - genericType.setElementBuilderType(elementBuilderType); + context + .getBuilderScopeResolver() + .resolveUsableBuilderType(elementTypeElement, context) + .ifPresent(genericType::setElementBuilderType); } /** @@ -305,19 +281,6 @@ private static TypeElement retrieveTypeElementIfExists( return element instanceof TypeElement typeElement ? typeElement : null; } - /** - * Checks if a TypeElement has the @SimpleBuilder annotation. - * - * @param typeElement the type element to check - * @return true if the element has @SimpleBuilder annotation - */ - private static boolean hasSimpleBuilderAnnotation(TypeElement typeElement) { - Optional annotation = - JavaLangAnalyser.findAnnotation( - typeElement, org.javahelpers.simple.builders.core.annotations.SimpleBuilder.class); - return annotation.isPresent(); - } - /** * Creates a TypeName for the builder of a given TypeElement. * @@ -325,8 +288,7 @@ private static boolean hasSimpleBuilderAnnotation(TypeElement typeElement) { * @param context the processing context * @return the TypeName for the builder */ - private static TypeName createBuilderTypeName( - TypeElement typeElement, ProcessingContext context) { + static TypeName createBuilderTypeName(TypeElement typeElement, ProcessingContext context) { String builderClassName = typeElement.getSimpleName().toString() + context.getConfiguration().getBuilderSuffix(); String packageName = extractPackageName(typeElement.getQualifiedName().toString()); @@ -339,7 +301,7 @@ private static TypeName createBuilderTypeName( * @param qualifiedName the fully qualified class name (e.g., "com.example.MyClass") * @return the package name (e.g., "com.example"), or empty string if no package */ - private static String extractPackageName(String qualifiedName) { + static String extractPackageName(String qualifiedName) { int lastDot = qualifiedName.lastIndexOf('.'); return lastDot > 0 ? qualifiedName.substring(0, lastDot) : ""; } 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 b61c60d3..a5475678 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 @@ -62,6 +62,10 @@ * @param usingBuilderImplementationAnnotation Use BuilderImplementation annotation * @param implementsBuilderBase Implement IBuilderBase interface * @param generateWithInterface Generate With interface + * @param jacksonModulePackage Package name for the Jackson module + * @param builderGenerationPackages Comma-separated packages for which builders are generated + * @param builderUsagePackages Comma-separated packages whose builders may be referenced by other + * builders * @param builderSuffix Suffix for builder class name * @param setterSuffix Suffix for setter method names * @param strict Strict/fail-fast generation mode @@ -91,6 +95,8 @@ public record BuilderConfiguration( OptionState usingJacksonDeserializerAnnotation, OptionState generateJacksonModule, String jacksonModulePackage, + String builderGenerationPackages, + String builderUsagePackages, String builderSuffix, String setterSuffix, OptionState strict) { @@ -121,6 +127,8 @@ public record BuilderConfiguration( .usingJacksonDeserializerAnnotation(DISABLED) .generateJacksonModule(DISABLED) .jacksonModulePackage(null) + .builderGenerationPackages(null) + .builderUsagePackages(null) .builderSuffix("Builder") .setterSuffix("") .strict(DISABLED) @@ -232,6 +240,62 @@ public String getSetterSuffix() { return setterSuffix; } + public String getBuilderGenerationPackages() { + return builderGenerationPackages; + } + + public String getBuilderUsagePackages() { + return builderUsagePackages; + } + + public java.util.Set getBuilderGenerationPackagesSet() { + return parsePackageSet(builderGenerationPackages); + } + + public java.util.Set getBuilderUsagePackagesSet() { + return parsePackageSet(builderUsagePackages); + } + + public boolean isInGenerationScope(String packageName) { + java.util.Set packages = getBuilderGenerationPackagesSet(); + return packages.isEmpty() || matchesPackage(packages, packageName); + } + + public boolean isInUsageScope(String packageName) { + java.util.Set packages = getBuilderUsagePackagesSet(); + return packages.isEmpty() || matchesPackage(packages, packageName); + } + + public boolean isPackageInGenerationScope(String packageName) { + return matchesPackage(getBuilderGenerationPackagesSet(), packageName); + } + + public boolean isPackageInUsageScope(String packageName) { + return matchesPackage(getBuilderUsagePackagesSet(), packageName); + } + + private static boolean matchesPackage(java.util.Set packages, String packageName) { + if (packageName == null) { + return false; + } + for (String p : packages) { + if (packageName.equals(p) || packageName.startsWith(p + ".")) { + return true; + } + } + return false; + } + + private static java.util.Set parsePackageSet(String value) { + if (StringUtils.isBlank(value)) { + return java.util.Collections.emptySet(); + } + return java.util.Arrays.stream(StringUtils.split(value, ",")) + .map(String::trim) + .filter(java.util.function.Predicate.not(String::isEmpty)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + public boolean isStrictModeEnabled() { return strict == ENABLED; } @@ -300,6 +364,9 @@ public BuilderConfiguration merge(BuilderConfiguration other) { .generateJacksonModule( mergeOptionState(other.generateJacksonModule, this.generateJacksonModule)) .jacksonModulePackage(mergeString(other.jacksonModulePackage, this.jacksonModulePackage)) + .builderGenerationPackages( + mergeString(other.builderGenerationPackages, this.builderGenerationPackages)) + .builderUsagePackages(mergeString(other.builderUsagePackages, this.builderUsagePackages)) .builderSuffix(mergeString(other.builderSuffix, this.builderSuffix)) .setterSuffix(mergeString(other.setterSuffix, this.setterSuffix)) .strict(mergeOptionState(other.strict, this.strict)) @@ -366,6 +433,8 @@ public String toString() { .appendValueIfSet("usingJacksonDeserializerAnnotation", usingJacksonDeserializerAnnotation) .appendValueIfSet("generateJacksonModule", generateJacksonModule) .appendIfNotEmpty("jacksonModulePackage", jacksonModulePackage) + .appendIfNotEmpty("builderGenerationPackages", builderGenerationPackages) + .appendIfNotEmpty("builderUsagePackages", builderUsagePackages) .appendIfNotEmpty("builderSuffix", builderSuffix) .appendIfNotEmpty("setterSuffix", setterSuffix) .appendValueIfSet("strict", strict) @@ -446,6 +515,8 @@ public static class Builder { private String jacksonModulePackage = null; // === Naming === + private String builderGenerationPackages = null; + private String builderUsagePackages = null; private String builderSuffix = null; private String setterSuffix = null; @@ -528,6 +599,16 @@ public Builder jacksonModulePackage(String value) { return this; } + public Builder builderGenerationPackages(String value) { + this.builderGenerationPackages = StringUtils.trimToNull(value); + return this; + } + + public Builder builderUsagePackages(String value) { + this.builderUsagePackages = StringUtils.trimToNull(value); + return this; + } + public Builder generateVarArgsHelpers(OptionState value) { this.generateVarArgsHelpers = value; return this; @@ -734,6 +815,8 @@ public BuilderConfiguration build() { usingJacksonDeserializerAnnotation, generateJacksonModule, jacksonModulePackage, + builderGenerationPackages, + builderUsagePackages, builderSuffix, setterSuffix, strict); 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 6abb5259..208a66e1 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 @@ -332,6 +332,8 @@ private BuilderConfiguration parseOptionsFromMirror(AnnotationMirror optionsMirr case "generateJacksonModule" -> builder.generateJacksonModule(OptionState.valueOf(enumValue)); case "jacksonModulePackage" -> builder.jacksonModulePackage(value.toString()); + case "builderGenerationPackages" -> builder.builderGenerationPackages(value.toString()); + case "builderUsagePackages" -> builder.builderUsagePackages(value.toString()); case "builderSuffix" -> builder.builderSuffix(value.toString()); case "setterSuffix" -> builder.setterSuffix(value.toString()); default -> diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java index cf40d714..061f813d 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java @@ -116,6 +116,12 @@ public enum CompilerArgumentsEnum { /** Option for Jackson Module package name. */ JACKSON_MODULE_PACKAGE("jacksonModulePackage"), + /** Option for comma-separated packages for which builders are generated. */ + BUILDER_GENERATION_PACKAGES("builderGenerationPackages"), + + /** Option for comma-separated packages whose builders may be referenced by other builders. */ + BUILDER_USAGE_PACKAGES("builderUsagePackages"), + // === Naming === /** Option for builder class name suffix. */ BUILDER_SUFFIX("builderSuffix"), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java index 884c2820..ffc15c59 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java @@ -172,6 +172,8 @@ public BuilderConfiguration readBuilderConfiguration() { readOptionState(CompilerArgumentsEnum.USING_JACKSON_DESERIALIZER_ANNOTATION)) .generateJacksonModule(readOptionState(CompilerArgumentsEnum.GENERATE_JACKSON_MODULE)) .jacksonModulePackage(readValue(CompilerArgumentsEnum.JACKSON_MODULE_PACKAGE)) + .builderGenerationPackages(readValue(CompilerArgumentsEnum.BUILDER_GENERATION_PACKAGES)) + .builderUsagePackages(readValue(CompilerArgumentsEnum.BUILDER_USAGE_PACKAGES)) .builderSuffix(readValue(CompilerArgumentsEnum.BUILDER_SUFFIX)) .setterSuffix(readValue(CompilerArgumentsEnum.SETTER_SUFFIX)) .strict(readOptionState(CompilerArgumentsEnum.STRICT)) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index 2886227f..64de0cbb 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -24,7 +24,9 @@ package org.javahelpers.simple.builders.processor.processing; +import java.util.HashSet; import java.util.List; +import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.Element; import javax.lang.model.element.PackageElement; @@ -32,6 +34,7 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; +import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.generators.registry.GeneratorRegistry; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.type.TypeName; @@ -49,7 +52,9 @@ public final class ProcessingContext { private final BuilderConfigurationReader configurationReader; private final ProcessingEnvironment processingEnv; private GeneratorRegistry generatorRegistry; + private BuilderScopeResolver builderScopeResolver; private BuilderConfiguration configurationForProcessingTarget; + private final Set generatedTypeNames = new HashSet<>(); /** * Creates a new processing context. @@ -78,6 +83,8 @@ public ProcessingContext( */ public void initConfigurationForProcessingTarget(BuilderConfiguration config) { this.configurationForProcessingTarget = config; + // Recompute scope resolver for the new target configuration + this.builderScopeResolver = null; } /** @@ -113,6 +120,50 @@ public GeneratorRegistry getGeneratorRegistry() { return generatorRegistry; } + /** + * Get the builder scope resolver for the current target configuration. + * + *

    The resolver is lazily initialized on first access and invalidated when the target + * configuration changes. + * + * @return the builder scope resolver + */ + public BuilderScopeResolver getBuilderScopeResolver() { + if (builderScopeResolver == null) { + builderScopeResolver = new BuilderScopeResolver(this); + } + return builderScopeResolver; + } + + /** + * Registers the set of types that will actually have builders generated in the current processing + * round. + * + *

    The builder scope resolver uses this set to trust in-compilation references without a + * type-existence search. + * + * @param elements the annotated elements selected for builder generation + */ + public void setGeneratedTypeNames(Set elements) { + generatedTypeNames.clear(); + for (Element element : elements) { + if (element instanceof TypeElement typeElement) { + generatedTypeNames.add(typeElement.getQualifiedName().toString()); + } + } + } + + /** + * Returns whether the given type is among the types that will have builders generated in the + * current processing round. + * + * @param typeElement the type element to check + * @return true if a builder will be generated for the type, false otherwise + */ + public boolean isGeneratedType(TypeElement typeElement) { + return generatedTypeNames.contains(typeElement.getQualifiedName().toString()); + } + /** * Get the TypeElement for a given qualified class name. * @@ -167,6 +218,17 @@ public List getAllMembers(TypeElement typeElement) { return elementUtils.getAllMembers(typeElement); } + /** + * Get all annotation mirrors for the given type, including inherited ones. + * + * @param typeElement the type to inspect + * @return list of all annotation mirrors + */ + public List getAllAnnotationMirrors( + TypeElement typeElement) { + return elementUtils.getAllAnnotationMirrors(typeElement); + } + /** * Get the Javadoc comment for an element. * diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationScopeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationScopeTest.java new file mode 100644 index 00000000..43776dad --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderConfigurationScopeTest.java @@ -0,0 +1,98 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link BuilderConfiguration} package scope helpers. */ +class BuilderConfigurationScopeTest { + + @Test + void emptyScope_isUnscoped() { + BuilderConfiguration config = BuilderConfiguration.DEFAULT; + + assertTrue(config.isInGenerationScope("com.example"), "Empty generation scope is unscoped"); + assertTrue(config.isInUsageScope("com.example"), "Empty usage scope is unscoped"); + assertFalse( + config.isPackageInGenerationScope("com.example"), "Empty generation set matches nothing"); + assertFalse(config.isPackageInUsageScope("com.example"), "Empty usage set matches nothing"); + } + + @Test + void packageSet_parsesCommaSeparatedListWithTrimming() { + BuilderConfiguration config = + BuilderConfiguration.builder() + .builderGenerationPackages(" a , b.c , com.example.nested ") + .builderUsagePackages("com.library, com.library.sub ") + .build(); + + assertEquals( + Set.of("a", "b.c", "com.example.nested"), config.getBuilderGenerationPackagesSet()); + assertEquals(Set.of("com.library", "com.library.sub"), config.getBuilderUsagePackagesSet()); + } + + @Test + void packageSet_treatsBlankAsEmpty() { + BuilderConfiguration config = + BuilderConfiguration.builder() + .builderGenerationPackages(" ") + .builderUsagePackages("") + .build(); + + assertTrue(config.getBuilderGenerationPackagesSet().isEmpty()); + assertTrue(config.getBuilderUsagePackagesSet().isEmpty()); + } + + @Test + void scopeMatches_exactPackageAndSubpackages() { + BuilderConfiguration config = + BuilderConfiguration.builder().builderGenerationPackages("com.example").build(); + + assertTrue(config.isInGenerationScope("com.example")); + assertTrue(config.isInGenerationScope("com.example.sub")); + assertTrue(config.isInGenerationScope("com.example.sub.deep")); + assertFalse(config.isInGenerationScope("com.exampleother")); + assertFalse(config.isInGenerationScope("com.other")); + assertFalse(config.isInGenerationScope("")); + } + + @Test + void usageScope_matchesIndependently() { + BuilderConfiguration config = + BuilderConfiguration.builder() + .builderGenerationPackages("com.example") + .builderUsagePackages("com.library") + .build(); + + assertTrue(config.isInUsageScope("com.library")); + assertTrue(config.isInUsageScope("com.library.sub")); + assertFalse(config.isInUsageScope("com.example")); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessorTest.java new file mode 100644 index 00000000..659215fa --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessorTest.java @@ -0,0 +1,395 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +/** Integration tests for builder generation and usage package scoping (issue #114). */ +class BuilderScopeProcessorTest { + + private static final String NESTED_DTO_BODY = + """ + private String value; + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + """; + + private static final String LIBRARY_DTO_BODY = + """ + private String data; + public String getData() { return data; } + public void setData(String data) { this.data = data; } + """; + + private static final String LIBRARY_BUILDER_SOURCE = + """ + package com.library; + public class LibraryDtoBuilder { + private LibraryDto instance; + public LibraryDtoBuilder() {} + public LibraryDtoBuilder(LibraryDto instance) { this.instance = instance; } + public LibraryDtoBuilder data(String data) { + if (instance == null) instance = new LibraryDto(); + instance.setData(data); + return this; + } + public LibraryDto build() { return instance; } + } + """; + + /** (a) Both options unset: nested in-compilation DTO still gets its builder consumer. */ + @Test + void bothOptionsUnset_nestedInCompilationDto_referencedAsBuilder() { + JavaFileObject nested = + ProcessorTestUtils.simpleBuilderClass("com.example", "NestedDto", NESTED_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.simpleBuilderClass( + "com.example", + "ParentDto", + """ + private NestedDto nested; + public NestedDto getNested() { return nested; } + public void setNested(NestedDto nested) { this.nested = nested; } + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(nested, parent); + + assertThat(compilation).succeededWithoutWarnings(); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, + "public ParentDtoBuilder nested(Consumer nestedBuilderConsumer)", + "new NestedDtoBuilder(this.nested.value())", + "new NestedDtoBuilder()"); + } + + /** (b) Generation scope includes the DTO package and a subpackage: generation and reference. */ + @Test + void builderGenerationPackages_includesPackageAndSubpackage_bothGeneratedAndReferenced() { + JavaFileObject nested = + ProcessorTestUtils.simpleBuilderClass("com.example.nested", "NestedDto", NESTED_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.example.nested.NestedDto nested; + public com.example.nested.NestedDto getNested() { return nested; } + public void setNested(com.example.nested.NestedDto nested) { this.nested = nested; } + } + """); + + Compilation compilation = + compilerWithOptions("-Asimplebuilder.builderGenerationPackages=com.example") + .compile(nested, parent); + + assertThat(compilation).succeededWithoutWarnings(); + // NestedDto is in a subpackage of com.example, so its builder must be generated. + String nestedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "NestedDtoBuilder"); + assertNotNull(nestedCode, "NestedDtoBuilder should be generated"); + assertTrue(nestedCode.contains("class NestedDtoBuilder")); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, + "public ParentDtoBuilder nested(Consumer nestedBuilderConsumer)", + "new NestedDtoBuilder(this.nested.value())"); + } + + /** (b) Generation scope excludes the nested DTO package: no builder and no reference. */ + @Test + void builderGenerationPackages_excludesNestedPackage_fallsBackToSetter() { + JavaFileObject nested = + ProcessorTestUtils.simpleBuilderClass("com.other", "NestedDto", NESTED_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.other.NestedDto nested; + public com.other.NestedDto getNested() { return nested; } + public void setNested(com.other.NestedDto nested) { this.nested = nested; } + } + """); + + Compilation compilation = + compilerWithOptions("-Asimplebuilder.builderGenerationPackages=com.example") + .compile(nested, parent); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "NestedDto", "NestedDtoBuilder should not be generated"); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertNotContaining( + parentCode, "Consumer", "new NestedDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, "public ParentDtoBuilder nested(NestedDto nested)"); + } + + /** (c) Usage scope includes a package whose compiled builder exists: referenced. */ + @Test + void builderUsagePackages_existingBuilder_referenced() { + JavaFileObject libraryDto = + ProcessorTestUtils.simpleBuilderClass("com.library", "LibraryDto", LIBRARY_DTO_BODY); + JavaFileObject libraryBuilder = ProcessorTestUtils.forSource(LIBRARY_BUILDER_SOURCE); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.library.LibraryDto library; + public com.library.LibraryDto getLibrary() { return library; } + public void setLibrary(com.library.LibraryDto library) { this.library = library; } + } + """); + + Compilation compilation = + compilerWithOptions( + "-Asimplebuilder.builderGenerationPackages=com.example", + "-Asimplebuilder.builderUsagePackages=com.library") + .compile(libraryDto, libraryBuilder, parent); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "LibraryDto", "LibraryDtoBuilder should not be generated by the processor"); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, + "public ParentDtoBuilder library(Consumer libraryBuilderConsumer)", + "new LibraryDtoBuilder(this.library.value())", + "new LibraryDtoBuilder()"); + } + + /** (c) Usage scope includes a package whose builder does NOT exist: no broken reference. */ + @Test + void builderUsagePackages_missingBuilder_noReference() { + JavaFileObject libraryDto = + ProcessorTestUtils.simpleBuilderClass("com.library", "LibraryDto", LIBRARY_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.library.LibraryDto library; + public com.library.LibraryDto getLibrary() { return library; } + public void setLibrary(com.library.LibraryDto library) { this.library = library; } + } + """); + + Compilation compilation = + compilerWithOptions( + "-Asimplebuilder.builderGenerationPackages=com.example", + "-Asimplebuilder.builderUsagePackages=com.library") + .compile(libraryDto, parent); + + assertThat(compilation).succeededWithoutWarnings(); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertNotContaining( + parentCode, "Consumer", "new LibraryDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, "public ParentDtoBuilder library(LibraryDto library)"); + } + + /** (d) Opt-out precedence: an @Ignore4BuilderGeneration DTO is never referenced. */ + @Test + void ignoreAnnotation_takesPrecedenceOverPackageScope() { + JavaFileObject ignoredDto = + ProcessorTestUtils.forSource( + """ + package com.library; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + + @SimpleBuilder + @Ignore4BuilderGeneration + public class IgnoredDto { + private String value; + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } + """); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.library.IgnoredDto ignored; + public com.library.IgnoredDto getIgnored() { return ignored; } + public void setIgnored(com.library.IgnoredDto ignored) { this.ignored = ignored; } + } + """); + + Compilation compilation = + compilerWithOptions( + "-Asimplebuilder.builderGenerationPackages=com.example", + "-Asimplebuilder.builderUsagePackages=com.library") + .compile(ignoredDto, parent); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "IgnoredDto", "IgnoredDtoBuilder should not be generated"); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertNotContaining( + parentCode, "Consumer", "new IgnoredDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, "public ParentDtoBuilder ignored(IgnoredDto ignored)"); + } + + /** (e) Compiler-arg and inline @SimpleBuilder.Options are both parsed. */ + @Test + void compilerArgAndInlineOptions_bothParsedAndApplied() { + JavaFileObject libraryDto = + ProcessorTestUtils.simpleBuilderClass("com.library", "LibraryDto", LIBRARY_DTO_BODY); + JavaFileObject libraryBuilder = ProcessorTestUtils.forSource(LIBRARY_BUILDER_SOURCE); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder(options = @SimpleBuilder.Options( + builderUsagePackages = "com.library" + )) + public class ParentDto { + private com.library.LibraryDto library; + public com.library.LibraryDto getLibrary() { return library; } + public void setLibrary(com.library.LibraryDto library) { this.library = library; } + } + """); + + Compilation compilation = + compilerWithOptions("-Asimplebuilder.builderGenerationPackages=com.example") + .compile(libraryDto, libraryBuilder, parent); + + assertThat(compilation).succeededWithoutWarnings(); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, + "public ParentDtoBuilder library(Consumer libraryBuilderConsumer)", + "new LibraryDtoBuilder(this.library.value())"); + } + + /** + * Empty builderGenerationPackages with a non-empty builderUsagePackages must not block references + * to local nested builders that the processor will generate in the same round. + */ + @Test + void builderUsagePackagesOnly_localNestedBuilderInOtherPackage_referenced() { + JavaFileObject nested = + ProcessorTestUtils.simpleBuilderClass("com.other", "NestedDto", NESTED_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ParentDto { + private com.other.NestedDto nested; + public com.other.NestedDto getNested() { return nested; } + public void setNested(com.other.NestedDto nested) { this.nested = nested; } + } + """); + + Compilation compilation = + compilerWithOptions("-Asimplebuilder.builderUsagePackages=com.other") + .compile(nested, parent); + + assertThat(compilation).succeededWithoutWarnings(); + String nestedCode = ProcessorTestUtils.loadGeneratedSource(compilation, "NestedDtoBuilder"); + assertNotNull(nestedCode, "NestedDtoBuilder should be generated"); + assertTrue(nestedCode.contains("class NestedDtoBuilder")); + + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, + "public ParentDtoBuilder nested(Consumer nestedBuilderConsumer)", + "new NestedDtoBuilder(this.nested.value())"); + } + + /** + * A per-class builderGenerationPackages override must not cause references to builders that are + * not actually generated because the global generation scope excludes their package. + */ + @Test + void perClassGenerationPackagesOverride_doesNotReferenceNonGeneratedBuilder() { + JavaFileObject nested = + ProcessorTestUtils.simpleBuilderClass("com.other", "NestedDto", NESTED_DTO_BODY); + JavaFileObject parent = + ProcessorTestUtils.forSource( + """ + package com.example; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder(options = @SimpleBuilder.Options( + builderGenerationPackages = "com.other" + )) + public class ParentDto { + private com.other.NestedDto nested; + public com.other.NestedDto getNested() { return nested; } + public void setNested(com.other.NestedDto nested) { this.nested = nested; } + } + """); + + Compilation compilation = + compilerWithOptions("-Asimplebuilder.builderGenerationPackages=com.example") + .compile(nested, parent); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "NestedDto", "NestedDtoBuilder should not be generated"); + String parentCode = ProcessorTestUtils.loadGeneratedSource(compilation, "ParentDtoBuilder"); + ProcessorAsserts.assertNotContaining( + parentCode, "Consumer", "new NestedDtoBuilder"); + ProcessorAsserts.assertContaining( + parentCode, "public ParentDtoBuilder nested(NestedDto nested)"); + } + + private static Compiler compilerWithOptions(String... options) { + return ProcessorTestUtils.createCompiler().withOptions(options); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java index c8b4c11f..dd059c7e 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java @@ -353,6 +353,8 @@ void readBuilderConfiguration_AllOptionsSet_ReadsCorrectly() { .put("simplebuilder.copyTypeAnnotations", "enabled") .put("simplebuilder.builderSuffix", "Factory") .put("simplebuilder.setterSuffix", "with") + .put("simplebuilder.builderGenerationPackages", "com.example.generation") + .put("simplebuilder.builderUsagePackages", "com.example.usage, com.example.usage2") .build(); CompilerArgumentsReader reader = new CompilerArgumentsReader(env); @@ -368,6 +370,8 @@ void readBuilderConfiguration_AllOptionsSet_ReadsCorrectly() { assertEquals(OptionState.ENABLED, config.copyTypeAnnotations()); assertEquals("Factory", config.getBuilderSuffix()); assertEquals("with", config.getSetterSuffix()); + assertEquals("com.example.generation", config.getBuilderGenerationPackages()); + assertEquals("com.example.usage, com.example.usage2", config.getBuilderUsagePackages()); } /** Test: readBuilderConfiguration handles mixed valid and invalid values. */ @@ -402,6 +406,8 @@ void readBuilderConfiguration_EmptyStringValues_HandlesGracefully() { .put("simplebuilder.builderAccess", "") .put("simplebuilder.builderSuffix", "") .put("simplebuilder.setterSuffix", "") + .put("simplebuilder.builderGenerationPackages", "") + .put("simplebuilder.builderUsagePackages", "") .build(); CompilerArgumentsReader reader = new CompilerArgumentsReader(env); @@ -411,5 +417,7 @@ void readBuilderConfiguration_EmptyStringValues_HandlesGracefully() { assertEquals(AccessModifier.DEFAULT, config.getBuilderAccess(), "Empty should be DEFAULT"); assertEquals("", config.getBuilderSuffix(), "Empty string should be preserved for suffix"); assertEquals("", config.getSetterSuffix(), "Empty string should be preserved for suffix"); + assertEquals(null, config.getBuilderGenerationPackages(), "Empty package list should be null"); + assertEquals(null, config.getBuilderUsagePackages(), "Empty package list should be null"); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java index 5b7d02ea..a6dbfb06 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java @@ -81,6 +81,9 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { .generateWithInterface(OptionState.ENABLED) .usingJacksonDeserializerAnnotation(OptionState.ENABLED) .generateJacksonModule(OptionState.ENABLED) + // Scoping + .builderGenerationPackages("com.example.generation") + .builderUsagePackages("com.example.usage, com.example.usage2") // Naming .builderSuffix("Builder") .setterSuffix("") @@ -112,6 +115,8 @@ void allConfigurationOptions_MustBeSettableViaBuilder() { assertEquals(OptionState.ENABLED, config.generateJacksonModule()); assertEquals("Builder", config.getBuilderSuffix()); assertEquals("", config.getSetterSuffix()); + assertEquals("com.example.generation", config.getBuilderGenerationPackages()); + assertEquals("com.example.usage, com.example.usage2", config.getBuilderUsagePackages()); } /**