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 28276eaa..6b671ac0 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
@@ -689,6 +689,25 @@
*/
String jacksonModulePackage() default "";
+ /**
+ * The package generated builder classes are placed in.
+ * Useful to collect builders in a central package or to generate a builder for a type whose
+ * package is not suitable, e.g. a third-party package.
+ *
+ *
Example: {@code "com.example.generated"}
+ *
+ *
Only members of the target type that are accessible from the configured package are used
+ * for builder generation - package-private and protected constructors, setters and getters are
+ * then not reachable and are treated as if absent.
+ *
+ *
Default: "" (empty - the package of the annotated type, resp. of the element carrying
+ * {@code @SimpleBuilderFor})
+ * Annotation-only: a global value would collapse all generated builders into a single package.
+ *
+ * @return the package generated builders are placed in
+ */
+ String packageName() default "";
+
/**
* Generate Javadoc comments on the generated builder class and its members.
* When disabled, no class, field, constructor or method Javadoc is emitted, producing smaller
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index e5c5a008..68a39142 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -1045,6 +1045,33 @@ public class OwnerDto {
---
+#### `packageName`
+
+**Default**: `""` (empty — the package of the annotated type, resp. of the element carrying
+`@SimpleBuilderFor`) | **Annotation only** — a global `-Asimplebuilder.packageName` is
+deliberately not supported: it would collapse all generated builders into a single package.
+
+Places generated builder classes into the given package instead of the default package.
+Useful to collect builders in a central package, or to keep a third-party target's builder
+in your own namespace when using `@SimpleBuilderFor`.
+
+Only members of the target type that are accessible from the configured package are used for
+builder generation — package-private and protected constructors, setters and getters are
+then not reachable and are treated as if absent. The value must be a valid Java package
+name; an invalid value produces a compile-time diagnostic.
+
+**Example**:
+```java
+@SimpleBuilderFor(
+ value = ExternalUser.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+public class ExternalBuilders { }
+
+// Generated class: com.example.generated.ExternalUserBuilder
+```
+
+---
+
#### `setterSuffix`
**Default**: `""` (empty) | **Compiler Option**: `-Asimplebuilder.setterSuffix=customPrefix`
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 e4bd7dc9..6fcb5bed 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
@@ -305,14 +305,15 @@ private Optional planGenerationOfAnnotatedElement(
return Optional.empty();
}
alreadyPlannedBuilders.add(
- builderTypeName(annotatedElement, context.getPackageName(annotatedElement), config));
+ builderTypeName(
+ annotatedElement, effectiveBuilderPackage(annotatedElement, config), config));
return Optional.of(new ElementToGenerate(annotatedElement, config, annotatedElement));
}
/**
* Expands a {@code @SimpleBuilderFor} holder into the external types listed in its {@code value}
- * attribute and plans a builder for each of them. The generated builder is placed in the holder's
- * package.
+ * attribute and plans a builder for each of them. The generated builder is placed in the
+ * configured {@code packageName} or the holder's package.
*/
private List planGenerationOfTypeByHolder(
Element holder,
@@ -334,7 +335,7 @@ private List planGenerationOfTypeByHolder(
BuilderConfiguration config = reader.resolveHolderConfiguration(holder);
tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION);
- String builderPackage = context.getPackageName(holder);
+ String builderPackage = effectiveBuilderPackage(holder, config);
List results = new ArrayList<>();
for (TypeElement target : targets) {
planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders)
@@ -441,9 +442,11 @@ private void registerGeneratedTypes(List elementsToGenerate)
}
scopeResolver.registerGeneratedBuilder(
new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()),
- new TypeName(
- context.getPackageName(elementToGenerate.reportingElement()),
- targetType.getSimpleName() + elementToGenerate.config().getBuilderSuffix()));
+ builderTypeName(
+ targetType,
+ effectiveBuilderPackage(
+ elementToGenerate.reportingElement(), elementToGenerate.config()),
+ elementToGenerate.config()));
}
}
@@ -545,12 +548,22 @@ private record ElementToGenerate(
Element element, BuilderConfiguration config, Element reportingElement) {}
/**
- * The package the builder is generated into: the package of the element the generation is
- * reported on - the {@code @SimpleBuilderFor} holder for external types, the annotated type
- * itself otherwise.
+ * 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 context.getPackageName(elementToGenerate.reportingElement());
+ return effectiveBuilderPackage(
+ elementToGenerate.reportingElement(), elementToGenerate.config());
+ }
+
+ /**
+ * The effective builder package: the configured {@code packageName} option when set, otherwise
+ * the package of the given element.
+ */
+ private String effectiveBuilderPackage(Element reportingElement, BuilderConfiguration config) {
+ String configured = config.getPackageName();
+ return configured == null ? context.getPackageName(reportingElement) : configured;
}
/**
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 fac8a9af..14a8f7b9 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
@@ -106,6 +106,7 @@ public record BuilderConfiguration(
OptionState generateJacksonModule,
OptionState generateJavaDoc,
String jacksonModulePackage,
+ String packageName,
PackageScopes builderGenerationPackages,
PackageScopes builderUsagePackages,
String builderSuffix,
@@ -263,6 +264,16 @@ public String getJacksonModulePackage() {
return jacksonModulePackage;
}
+ /**
+ * The package generated builders are placed in, or {@code null} when the default applies (the
+ * package of the annotated type, resp. of the {@code @SimpleBuilderFor} holder).
+ *
+ * @return the configured builder package, or {@code null} if unset
+ */
+ public String getPackageName() {
+ return packageName;
+ }
+
/**
* Returns the parsed set of builder generation package scopes.
*
@@ -373,6 +384,7 @@ public BuilderConfiguration merge(BuilderConfiguration other) {
mergeOptionState(other.generateJacksonModule, this.generateJacksonModule))
.generateJavaDoc(mergeOptionState(other.generateJavaDoc, this.generateJavaDoc))
.jacksonModulePackage(mergeString(other.jacksonModulePackage, this.jacksonModulePackage))
+ .packageName(mergeString(other.packageName, this.packageName))
.builderGenerationPackages(
overrideScopes(other.builderGenerationPackages, this.builderGenerationPackages))
.builderUsagePackages(overrideScopes(other.builderUsagePackages, this.builderUsagePackages))
@@ -462,6 +474,7 @@ public String toString() {
.appendValueIfSet("generateJacksonModule", generateJacksonModule)
.appendValueIfSet("generateJavaDoc", generateJavaDoc)
.appendIfNotEmpty("jacksonModulePackage", jacksonModulePackage)
+ .appendIfNotEmpty("packageName", packageName)
.appendIfNotEmpty("builderGenerationPackages", builderGenerationPackages.toString())
.appendIfNotEmpty("builderUsagePackages", builderUsagePackages.toString())
.appendIfNotEmpty("builderSuffix", builderSuffix)
@@ -546,6 +559,7 @@ public static class Builder {
private OptionState generateJacksonModule = OptionState.UNSET;
private OptionState generateJavaDoc = OptionState.UNSET;
private String jacksonModulePackage = null;
+ private String packageName = null;
// === Naming ===
private String builderSuffix = null;
@@ -648,6 +662,11 @@ public Builder jacksonModulePackage(String value) {
return this;
}
+ public Builder packageName(String value) {
+ this.packageName = StringUtils.trimToNull(value);
+ return this;
+ }
+
public Builder builderGenerationPackages(String value) {
this.builderGenerationPackages = PackageScopes.parse(value);
return this;
@@ -896,6 +915,7 @@ public BuilderConfiguration build() {
generateJacksonModule,
generateJavaDoc,
jacksonModulePackage,
+ packageName,
builderGenerationPackages,
builderUsagePackages,
builderSuffix,
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 d82d8b4e..f25ae6c5 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
@@ -30,6 +30,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import javax.lang.model.SourceVersion;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.AnnotationValue;
import javax.lang.model.element.Element;
@@ -137,6 +138,7 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder
// Validate access modifiers and warn about problematic configurations
validateAccessModifiers(element, result);
+ validatePackageName(element, result);
logger.debugEndOperation("Resulting configuration resolved: %s", result.toString());
return result;
@@ -170,6 +172,7 @@ public BuilderConfiguration resolveHolderConfiguration(Element element) throws B
// Validate access modifiers and warn about problematic configurations
validateAccessModifiers(element, result);
+ validatePackageName(element, result);
logger.debugEndOperation("Resulting configuration resolved: %s", result.toString());
return result;
@@ -427,4 +430,21 @@ private static void validateAccessModifiers(Element element, BuilderConfiguratio
elementName);
}
}
+
+ /**
+ * Validates the {@code packageName} option and throws an exception for values that are not a
+ * valid Java package name.
+ *
+ * @param element the element being processed
+ * @param config the resolved configuration
+ * @throws BuilderException if the configured package name is invalid
+ */
+ private static void validatePackageName(Element element, BuilderConfiguration config)
+ throws BuilderException {
+ String packageName = config.getPackageName();
+ if (packageName != null && !SourceVersion.isName(packageName)) {
+ throw new BuilderException(
+ element, "Option packageName '%s' is not a valid Java package name", packageName);
+ }
+ }
}
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 7e89eb14..41cc7277 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
@@ -144,6 +144,12 @@ public enum CompilerArgumentsEnum {
/** Option for Jackson Module package name. */
JACKSON_MODULE_PACKAGE("jacksonModulePackage", string(Builder::jacksonModulePackage)),
+ /**
+ * Option for the package generated builders are placed in. Annotation-only: a global {@code
+ * packageName} would collapse all builders into a single package.
+ */
+ PACKAGE_NAME("packageName", string(Builder::packageName), true),
+
// === Builder Scoping ===
/** Option for builder generation packages. */
BUILDER_GENERATION_PACKAGES(
@@ -214,6 +220,12 @@ public enum CompilerArgumentsEnum {
*/
private final OptionApplier builderApplier;
+ /**
+ * Whether this option may only be set per annotation and is ignored in compiler arguments ({@code
+ * -Asimplebuilder.*} / {@code -D}).
+ */
+ private final boolean annotationOnly;
+
/**
* Constructs a CompilerArgumentsEnum constant for an argument that is not a builder configuration
* option.
@@ -244,8 +256,20 @@ public enum CompilerArgumentsEnum {
* @param builderApplier applies the raw option value to the configuration builder
*/
CompilerArgumentsEnum(String optionName, OptionApplier builderApplier) {
+ this(optionName, builderApplier, false);
+ }
+
+ /**
+ * Constructs a CompilerArgumentsEnum constant for a builder configuration option.
+ *
+ * @param optionName The option name
+ * @param builderApplier applies the raw option value to the configuration builder
+ * @param annotationOnly {@code true} to restrict the option to annotation usage
+ */
+ CompilerArgumentsEnum(String optionName, OptionApplier builderApplier, boolean annotationOnly) {
this.optionName = optionName;
this.builderApplier = builderApplier;
+ this.annotationOnly = annotationOnly;
}
/**
@@ -304,6 +328,16 @@ public boolean hasValueApplier() {
return builderApplier != null;
}
+ /**
+ * Returns whether this option is only valid as an annotation member and is ignored when set
+ * through compiler arguments.
+ *
+ * @return {@code true} if the option must not be read from compiler arguments
+ */
+ public boolean isAnnotationOnly() {
+ return annotationOnly;
+ }
+
/**
* Applies a raw option value to the given configuration builder.
*
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 8ad1efbb..42ed27ab 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
@@ -117,7 +117,8 @@ public boolean readBooleanValue(CompilerArgumentsEnum argument) {
* {@code BuilderConfigurationReader}. The remaining checklist when adding a new option:
*
*
- * - {@code CompilerArgumentsEnum} — add the enum constant with its applier.
+ *
- {@code CompilerArgumentsEnum} — add the enum constant with its applier (or pass {@code
+ * annotationOnly = true} if the option must not be set globally).
*
- {@code BuilderConfiguration} — add the field, builder method, merge logic, and a typed
* accessor (e.g. {@code formattingModeEnum}) if enum conversion is needed. Set the default
* in {@code BuilderConfiguration.DEFAULT}.
@@ -133,6 +134,7 @@ public boolean readBooleanValue(CompilerArgumentsEnum argument) {
public BuilderConfiguration readBuilderConfiguration(ProcessingLogger logger) {
BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
Stream.of(CompilerArgumentsEnum.values())
+ .filter(option -> !option.isAnnotationOnly())
.forEach(option -> option.apply(builder, readValue(option), logger));
return builder.build();
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java
index 4ec0d01a..9a4c5059 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java
@@ -34,9 +34,10 @@
* 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. For {@code @SimpleBuilder}
- * targets this is the processed type's own package; for {@code @SimpleBuilderFor} targets it is
- * the holder's package. The latter cannot be derived from the processed element itself, which
- * is why it is carried explicitly here
+ * @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
*/
public record ProcessingTarget(BuilderConfiguration configuration, String builderPackage) {}
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 b3cb6aa2..707bd3e4 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
@@ -3696,4 +3696,28 @@ public class GenericSetterDto {
assertThat(compilation)
.hadWarningContaining("Field 'value' has field-specific generics, so it will be ignored");
}
+
+ @Test
+ void packageName_GeneratesBuilderIntoConfiguredPackage() {
+ JavaFileObject source =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder(options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+ public class Person {
+ private String name;
+ public Person() {}
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ }
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(source);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ String generated = loadGeneratedSource(compilation, "PersonBuilder");
+ ProcessorAsserts.assertContaining(
+ generated, "package com.example.generated;", "import test.Person;");
+ }
}
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 fa2d85af..b89982e7 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
@@ -317,6 +317,100 @@ void packageInfo_GeneratesBuildersIntoAnnotatedPackage() {
generated, "package test;", "public ExternalUserBuilder name(String name)");
}
+ @Test
+ void packageName_GeneratesBuilderIntoConfiguredPackage() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(
+ value = ext.ExternalUser.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+ public class Builders {}
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ ProcessorAsserts.assertContaining(
+ generated,
+ "package com.example.generated;",
+ "import ext.ExternalUser;",
+ "public ExternalUserBuilder name(String name)");
+ }
+
+ @Test
+ void packageName_Invalid_ProducesDiagnosticAndNoBuilder() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(
+ value = ext.ExternalUser.class,
+ options = @SimpleBuilder.Options(packageName = "com.example..broken"))
+ public class Builders {}
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder);
+
+ assertThat(compilation).succeeded();
+ assertThat(compilation).hadWarningContaining("is not a valid Java package name");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "ExternalUserBuilder", "An invalid packageName must not produce a builder");
+ }
+
+ @Test
+ void packageName_PackagePrivateConstructorIsNotReachable() {
+ // With the builder placed in another package, package-private members of the target are no
+ // longer reachable - they are treated as if absent, so no accessible constructor remains.
+ JavaFileObject packagePrivateCtor =
+ ProcessorTestUtils.forSource(
+ """
+ package ext;
+ public class ExternalWidget {
+ ExternalWidget() {}
+ }
+ """);
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(
+ value = ext.ExternalWidget.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(packagePrivateCtor, holder);
+
+ assertThat(compilation).succeeded();
+ assertThat(compilation).hadWarningContaining("No accessible constructor");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "ExternalWidgetBuilder", "No accessible constructor - no builder");
+ }
+
+ @Test
+ void packageName_GlobalCompilerArgumentIsIgnored() {
+ // packageName is annotation-only: a global -A/-D value must not collapse all builders into
+ // a single package.
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions("-Asimplebuilder.packageName=com.example.generated")
+ .compile(externalDto(), holder("test", "Builders", "ext.ExternalUser"));
+
+ assertThat(compilation).succeededWithoutWarnings();
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ ProcessorAsserts.assertContaining(generated, "package test;");
+ }
+
private static JavaFileObject externalDto() {
return ProcessorTestUtils.forSource(
"""