diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
index 5cc0758f..2e55ad4c 100644
--- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
@@ -125,11 +125,24 @@
/**
* The types for which builders are generated. Every listed type must be resolvable on the
* classpath or in the current compilation and must be constructible through accessible Java APIs
- * (e.g. an accessible constructor). At least one type is required.
+ * (e.g. an accessible constructor). At least one of {@link #value()} or {@link #sourcePackages()}
+ * must be set.
*
* @return the external types to generate builders for
*/
- Class>[] value();
+ Class>[] value() default {};
+
+ /**
+ * Packages scanned for types to generate builders for: every top-level constructible type
+ * declared in a listed package gets a builder, without listing the types one by one in {@link
+ * #value()}. Interfaces, abstract classes, enums and annotation types are skipped; nested types
+ * keep requiring an explicit {@link #value()} entry. Types a package contributes are ordered by
+ * qualified name. These are the packages the types come from — the package the builders
+ * are generated into is {@link SimpleBuilder.Options#packageName()}.
+ *
+ * @return the packages whose top-level types get builders
+ */
+ String[] sourcePackages() default {};
/**
* Configuration options for the generated builders, reusing the {@link SimpleBuilder.Options}
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index c2406c0b..d8e8cea9 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -187,6 +187,19 @@ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
The generated builders are placed in the annotated package - or in the package of the provider class when a class is annotated instead. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted.
+`sourcePackages` lists whole packages to scan instead of naming types one by one: every top-level constructible type declared in a listed package gets a builder, combined with the types of `value` (an explicit `value` entry wins over the same type found by scanning; a `sourcePackages`-only declaration without `value` is valid). It names the packages the types come *from* - not to be confused with `options.packageName`, the package builders are generated *into*:
+
+```java
+// Generates a builder for every top-level class and record in com.thirdparty.users
+@SimpleBuilderFor(
+ sourcePackages = "com.thirdparty.users",
+ options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+public class ExternalBuildersProvider {
+}
+```
+
+Interfaces, abstract classes, enums and annotation types are skipped (a debug log lists each skip), and package scanning only sees *top-level* types - nested types keep requiring an explicit `value` entry. Only packages visible to the compilation can be resolved: an unresolvable package name produces a warning and is skipped. Types a package contributes are ordered by qualified name, so the generated set is deterministic.
+
The target type must be constructible through accessible Java APIs from the builder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. The target type's own annotations are not consulted - the explicit declaration wins, so `@Ignore4BuilderGeneration` on the target does not suppress generation either. `@SimpleBuilderFor` is not `@Inherited`, and the provider class or package itself never gets a builder.
`@SimpleBuilderFor` is `@Repeatable`: several declarations may be placed on the same holder or `package-info.java` when the listed types need different options - for example a different `packageName` per declaration:
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 ecf98650..deced551 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
@@ -35,7 +35,9 @@
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.Objects;
import java.util.Optional;
import java.util.Set;
@@ -48,8 +50,12 @@
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.AnnotationValue;
import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+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.simple.builders.core.annotations.Ignore4BuilderGeneration;
import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template;
import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
@@ -338,7 +344,7 @@ private List planGenerationOfTypeByHolder(
if (targets.isEmpty()) {
context.warning(
holder,
- "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate",
+ "simple-builders: @SimpleBuilderFor on '%s' resolves no types - nothing to generate",
holder.getSimpleName());
continue;
}
@@ -392,26 +398,86 @@ private Optional planExternalTarget(
}
/**
- * Reads the {@code value} attribute of one {@code @SimpleBuilderFor} annotation instance on the
- * holder and resolves each entry to the {@link TypeElement} the builder is generated for.
+ * Resolves the types one {@code @SimpleBuilderFor} annotation instance declares builders for: the
+ * types listed in {@code value} first (a type listed there wins over the same type found by
+ * scanning), then the constructible top-level types of every {@code sourcePackages} package,
+ * ordered by qualified name for deterministic output.
*/
private List extractExternalTargetTypes(
Element holder, AnnotationMirror annotationInstance) throws BuilderException {
- Optional valueAttribute =
- JavaLangAnalyser.findAnnotationAttribute(annotationInstance, "value", context);
- List results = new ArrayList<>();
+ Map targets = new LinkedHashMap<>();
// For an array-valued attribute javac always delivers a list, even for a single entry.
- if (valueAttribute.isEmpty() || !(valueAttribute.get().getValue() instanceof List> items)) {
+ for (Object item : readArrayAttribute(annotationInstance, "value")) {
+ TypeElement target = resolveExternalTargetType(holder, item);
+ targets.put(target.getQualifiedName().toString(), target);
+ }
+ for (String packageName : readSourcePackageNames(annotationInstance)) {
+ for (TypeElement target : findPackageTargetTypes(holder, packageName)) {
+ targets.putIfAbsent(target.getQualifiedName().toString(), target);
+ }
+ }
+ 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()
+ .map(item -> item instanceof AnnotationValue value ? value.getValue() : null)
+ .filter(String.class::isInstance)
+ .map(String.class::cast)
+ .toList();
+ }
+
+ /**
+ * Finds the constructible top-level types of one {@code sourcePackages} entry: only concrete
+ * classes and records qualify; interfaces, abstract classes, enums and annotation types are
+ * skipped with a debug note.
+ */
+ private List findPackageTargetTypes(Element holder, String packageName) {
+ PackageElement packageElement = context.getPackageElement(packageName);
+ if (packageElement == null) {
context.warning(
holder,
- "simple-builders: could not read the 'value' attribute of @SimpleBuilderFor on '%s' - nothing to generate",
+ "simple-builders: package '%s' in @SimpleBuilderFor on '%s' could not be resolved - skipped",
+ packageName,
holder.getSimpleName());
- return results;
+ return List.of();
}
- for (Object item : items) {
- results.add(resolveExternalTargetType(holder, item));
+ List targets = new ArrayList<>();
+ for (TypeElement type : ElementFilter.typesIn(packageElement.getEnclosedElements())) {
+ if (isConstructibleTopLevelType(type)) {
+ targets.add(type);
+ } else {
+ context.debug(
+ "simple-builders: skipping %s '%s' in package '%s' declared by @SimpleBuilderFor on '%s' - no builder can be generated",
+ type.getKind().toString().toLowerCase(),
+ type.getQualifiedName(),
+ packageName,
+ holder.getSimpleName());
+ }
}
- return results;
+ targets.sort(Comparator.comparing(type -> type.getQualifiedName().toString()));
+ return targets;
+ }
+
+ /** Whether a package member is a type a builder can be generated for. */
+ private static boolean isConstructibleTopLevelType(TypeElement type) {
+ if (type.getKind() == ElementKind.RECORD) {
+ return true;
+ }
+ return type.getKind() == ElementKind.CLASS && !type.getModifiers().contains(Modifier.ABSTRACT);
}
/** Resolves one entry of a {@code @SimpleBuilderFor} {@code value} attribute to its type. */
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 d053a403..5af6fce0 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
@@ -273,6 +273,16 @@ public PackageElement getPackageOf(Element element) {
return elementUtils.getPackageOf(element);
}
+ /**
+ * Get the package element for a qualified package name.
+ *
+ * @param name the qualified package name
+ * @return the package element, or null if no such package is visible to the compilation
+ */
+ public PackageElement getPackageElement(String name) {
+ return elementUtils.getPackageElement(name);
+ }
+
/**
* Get the package name of an element.
*
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 bc99e235..665107c6 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
@@ -484,6 +484,178 @@ public class Builders {}
ProcessorAsserts.assertContaining(generated, "package test;");
}
+ @Test
+ void sourcePackages_GeneratesBuildersForTopLevelConstructibleTypes() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(sourcePackages = "pkg")
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .compile(pkgUser(), pkgOrder(), pkgInterface(), pkgEnum(), pkgAbstract(), holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ // Only the two concrete top-level classes get builders; interface, enum and abstract class
+ // are skipped
+ String userBuilder = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ String orderBuilder =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder");
+ ProcessorAsserts.assertContaining(userBuilder, "package test;", "public ExternalUser build()");
+ ProcessorAsserts.assertContaining(
+ orderBuilder, "package test;", "public ExternalOrder build()");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "Contract", "an interface cannot be built");
+ ProcessorAsserts.assertNoBuilderGenerated(compilation, "State", "an enum cannot be built");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "BaseDto", "an abstract class cannot be built");
+ }
+
+ @Test
+ void sourcePackages_NestedTypeKeepsRequiringExplicitEntry() {
+ JavaFileObject withNested =
+ ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public class Wrapper {
+ public Wrapper() {}
+ public static class Inner {
+ public Inner() {}
+ }
+ }
+ """);
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(sourcePackages = "pkg")
+ public class Builders {}
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(withNested, holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ // Only the top-level type is picked up; its nested class is not
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "WrapperBuilder");
+ ProcessorAsserts.assertContaining(generated, "public Wrapper build()");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "Inner", "nested types keep requiring an explicit value entry");
+ }
+
+ @Test
+ void sourcePackages_WithOptions_GeneratesIntoOptionsPackage() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(
+ sourcePackages = "pkg",
+ options = @SimpleBuilder.Options(packageName = "com.example.generated"))
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(pkgUser(), pkgOrder(), holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ // Every scanned type routes into the options package, as with value-listed types
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "generated/ExternalUserBuilder"),
+ "package com.example.generated;");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "generated/ExternalOrderBuilder"),
+ "package com.example.generated;");
+ }
+
+ @Test
+ void sourcePackages_TypeAlsoInValue_GeneratesSingleBuilder() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(value = pkg.ExternalUser.class, sourcePackages = "pkg")
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(pkgUser(), pkgOrder(), holder);
+
+ // The explicit listing and the scan overlap on ExternalUser: exactly one builder, no
+ // duplicate warning
+ assertThat(compilation).succeededWithoutWarnings();
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ ProcessorAsserts.assertContaining(generated, "package test;", "public ExternalUser build()");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder"),
+ "public ExternalOrder build()");
+ }
+
+ @Test
+ void sourcePackages_Multiple_GeneratesFromAllListedPackages() {
+ JavaFileObject otherType =
+ ProcessorTestUtils.forSource(
+ """
+ package other;
+ public class ExternalCustomer {
+ private String id;
+ public ExternalCustomer() {}
+ public String getId() { return id; }
+ public void setId(String id) { this.id = id; }
+ }
+ """);
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(sourcePackages = {"pkg", "other"})
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(pkgUser(), pkgOrder(), otherType, holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ // Every listed package contributes its top-level classes into the holder's package
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"),
+ "import pkg.ExternalUser;");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder"),
+ "import pkg.ExternalOrder;");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalCustomerBuilder"),
+ "import other.ExternalCustomer;");
+ }
+
+ @Test
+ void sourcePackages_Unresolvable_WarnsAndGeneratesFromValue() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(value = ext.ExternalUser.class, sourcePackages = "does.not.exist")
+ public class Builders {}
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder);
+
+ assertThat(compilation).succeeded();
+ assertThat(compilation).hadWarningContaining("could not be resolved");
+ // The unresolvable package is skipped; the value-listed type still generates
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ ProcessorAsserts.assertContaining(generated, "public ExternalUser build()");
+ }
+
private static JavaFileObject externalOrderDto() {
return ProcessorTestUtils.forSource(
"""
@@ -526,6 +698,62 @@ public ExternalOrder() {}
""");
}
+ private static JavaFileObject pkgUser() {
+ return ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public class ExternalUser {
+ private String name;
+ public ExternalUser() {}
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ }
+ """);
+ }
+
+ private static JavaFileObject pkgOrder() {
+ return ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public class ExternalOrder {
+ private String id;
+ public ExternalOrder() {}
+ public String getId() { return id; }
+ public void setId(String id) { this.id = id; }
+ }
+ """);
+ }
+
+ private static JavaFileObject pkgInterface() {
+ return ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public interface Contract {
+ String getName();
+ }
+ """);
+ }
+
+ private static JavaFileObject pkgEnum() {
+ return ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public enum State {
+ OPEN, CLOSED
+ }
+ """);
+ }
+
+ private static JavaFileObject pkgAbstract() {
+ return ProcessorTestUtils.forSource(
+ """
+ package pkg;
+ public abstract class BaseDto {
+ public BaseDto() {}
+ }
+ """);
+ }
+
private static JavaFileObject holder(String pkg, String className, String targetType) {
return ProcessorTestUtils.forSource(
"""