Skip to content
Draft
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ Add the dependency with Maven:
<dependency>
<groupId>dev.hegel</groupId>
<artifactId>hegel</artifactId>
<version>0.1.0</version>
<version>0.4.0</version>
<scope>test</scope>
</dependency>
```

or with Gradle:

```kotlin
testImplementation("dev.hegel:hegel:0.1.0")
testImplementation("dev.hegel:hegel:0.4.0")
```

Hegel for Java requires **Java 22+** and uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html). The native engine is bundled in the jar for Linux (x86-64 and arm64) and macOS (Apple Silicon).
Expand Down
22 changes: 22 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
RELEASE_TYPE: minor

This release fixes several public-API defects found in an audit:

- `Generators.durations()` now covers the full signed nanosecond range, so negative `Duration`
values are generated by default and `min()`/`max()` accept negative bounds. Use
`durations().min(Duration.ZERO)` for the previous non-negative behavior.
- `TextGenerator` rejects combining `categories(...)` with `excludeCategories(...)` instead of
silently ignoring the exclusion (the engine honors only one of the two constraints).
- The text, binary, list, set, and map generators reject every negative `maxSize(...)` argument.
Previously `maxSize(-1)` silently meant "unbounded" (it collided with an internal sentinel).
- The default `@HegelTest` property name — which derives the example-database key — is now the
fully-qualified method name (declaring class plus method name), so same-named test methods in
different classes no longer share replay-database entries. Stored entries under the old
bare-method-name keys will no longer be found (a one-time invalidation); explicit
`name = "..."` overrides are unaffected.
- Javadoc corrections: `characters()` generates one Unicode codepoint (possibly two UTF-16
`char`s — use `codePointAt(0)`); `datetimes()` had its two zone methods swapped
(`timezones(...)` produces `ZonedDateTime`, `offsets(...)` produces `OffsetDateTime`);
`forType(Class)` documents the actual supported scalar set and that `List`/`Set`/`Optional`/
`Map` are derived only as record components; `fromRegex(...)` documents the engine's
Python-`re` pattern dialect.
43 changes: 31 additions & 12 deletions src/main/java/dev/hegel/Generators.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,15 @@ public static TextGenerator text() {
}

/**
* Generates single-character strings.
* Generates strings of exactly one Unicode <em>codepoint</em>. A codepoint outside the Basic
* Multilingual Plane occupies two UTF-16 {@code char}s, so the generated string can have {@code
* String.length() == 2}; read the value with {@link String#codePointAt(int) codePointAt(0)}
* rather than {@code charAt(0)}. (Surrogates are excluded by default, as for {@link #text()}.)
*
* @return a one-character text generator
* <p>The result is an ordinary {@link TextGenerator} with {@code minSize(1).maxSize(1)} applied,
* so calling further size methods (e.g. {@code minSize(0)}) replaces the one-codepoint contract.
*
* @return a one-codepoint text generator
*/
public static TextGenerator characters() {
return text().minSize(1).maxSize(1);
Expand Down Expand Up @@ -463,14 +469,21 @@ public static <T> Deferred<T> deferred() {
/**
* Derive a generator for {@code type} by reflection.
*
* <p>Supports scalar types ({@code int}, {@code long}, {@code boolean}, {@code float}, {@code
* double}, {@code String}, {@code byte[]}, {@link UUID}, and their wrappers), enums, records
* (recursively), and {@code List}, {@code Set}, {@code Optional} and {@code Map} of supported
* element types.
* <p>Supports the scalar types {@code int}, {@code long}, {@code boolean}, {@code float} and
* {@code double} (and their wrappers), {@code String}, {@code byte[]}, {@link UUID}, {@link
* Duration}, {@link LocalDate}, {@link LocalTime}, {@link java.time.LocalDateTime}, {@link
* java.time.OffsetDateTime}, {@link java.time.ZonedDateTime}, {@link ZoneOffset} and {@link
* ZoneId}, plus enums and records (recursively).
*
* <p>{@code List}, {@code Set}, {@code Optional} and {@code Map} of supported element types are
* derived only as record components, where the declared element types are known. This {@code
* Class}-based entry point cannot express a parameterized type, so e.g. {@code
* forType(List.class)} throws.
*
* @param type the type to derive a generator for
* @param <T> the type
* @return a generator producing instances of {@code type}
* @throws HegelException if no generator can be derived for {@code type}
*/
@SuppressWarnings("unchecked")
public static <T> Generator<T> forType(Class<T> type) {
Expand Down Expand Up @@ -546,7 +559,8 @@ public static Generator<LocalTime> times() {
/**
* Generates {@link java.time.LocalDateTime} values (the engine's offset-free {@code
* YYYY-MM-DDTHH:MM:SS[.ffffff]} output). Call {@link DateTimeGenerator#timezones} to produce
* offset-aware {@link java.time.OffsetDateTime} values instead.
* DST-aware {@link java.time.ZonedDateTime} values, or {@link DateTimeGenerator#offsets} to
* produce fixed-offset {@link java.time.OffsetDateTime} values instead.
*
* @return a datetime generator
*/
Expand Down Expand Up @@ -580,21 +594,26 @@ public static Generator<ZoneId> zoneIds() {
ZoneId.getAvailableZoneIds().stream().sorted().map(ZoneId::of).toList();

/**
* Generates {@link Duration} values across the representable nanosecond range. Configure with the
* fluent methods on {@link DurationGenerator}.
* Generates {@link Duration} values across the full signed nanosecond range — {@code
* [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds, about ±292 years — including negative
* durations. Configure with the fluent methods on {@link DurationGenerator}.
*
* @return a duration generator
*/
public static DurationGenerator durations() {
return new DurationGenerator(0, Long.MAX_VALUE);
return new DurationGenerator(Long.MIN_VALUE, Long.MAX_VALUE);
}

/**
* Generates strings matching a (Python-compatible) regular expression. By default the entire
* string matches the pattern; use {@link RegexGenerator#fullmatch(boolean) fullmatch(false)}
* to generate strings that merely contain a match.
* to generate strings that merely <em>contain</em> a match, with arbitrary padding on either
* side (the unanchored semantics of Hypothesis's {@code from_regex}).
*
* <p>The pattern uses the engine's Python {@code re} dialect, which differs from {@link
* java.util.regex.Pattern} in some constructs.
*
* @param pattern the regex pattern
* @param pattern the regex pattern (Python {@code re} dialect)
* @return a regex generator
*/
public static RegexGenerator fromRegex(String pattern) {
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/dev/hegel/HegelTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@

/**
* Name for this property, used to derive a stable example-database key. Defaults to the test
* method name.
* method's fully-qualified name (declaring class name plus method name, e.g. {@code
* com.example.FooTest.myProperty}), so same-named methods in different classes get distinct
* database keys.
*
* @return the property name, or {@code ""} to use the method name
* @return the property name, or {@code ""} to use the fully-qualified method name
*/
String name() default "";
}
8 changes: 5 additions & 3 deletions src/main/java/dev/hegel/HegelTestExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,16 @@ public void interceptTestTemplateMethod(
invocation.skip();
Method method = invocationContext.getExecutable();
HegelTest ann = method.getAnnotation(HegelTest.class);
Settings settings = settingsFrom(ann, method.getName());
Settings settings = settingsFrom(ann, method);
Object target = invocationContext.getTarget().orElse(null);
Hegel.test(tc -> ReflectionSupport.invokeMethod(method, target, tc), settings);
}
}

static Settings settingsFrom(HegelTest ann, String methodName) {
String name = ann.name().isEmpty() ? methodName : ann.name();
static Settings settingsFrom(HegelTest ann, Method method) {
// Qualify the default name with the declaring class: the name derives the example-database
// key, and a bare method name would collide across classes with same-named test methods.
String name = ann.name().isEmpty() ? method.getDeclaringClass().getName() + "." + method.getName() : ann.name();
Settings s = new Settings()
.testCases(ann.testCases())
.verbosity(ann.verbosity())
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/dev/hegel/generators/BinaryGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ public BinaryGenerator minSize(int minSize) {
}

/**
* @param maxSize the maximum length (inclusive)
* @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound)
* @return a copy with the maximum size set
*/
public BinaryGenerator maxSize(int maxSize) {
return new BinaryGenerator(minSize, maxSize);
return new BinaryGenerator(minSize, Sizes.checkedMax(maxSize, "binary"));
}

/** @hidden */
Expand Down
16 changes: 8 additions & 8 deletions src/main/java/dev/hegel/generators/DurationGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,16 @@
* Generates {@link Duration} values within an inclusive {@code [min, max]} range. Always basic (one
* engine call).
*
* <p>Durations are drawn as a nanosecond count, so the representable range is {@code [0,
* Long.MAX_VALUE]} nanoseconds (about 292 years). The default is that whole range; narrow it with
* the fluent {@link #min(Duration)} / {@link #max(Duration)} methods.
* <p>Durations are drawn as a signed nanosecond count, so the representable range is {@code
* [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds (about ±292 years), negative durations included.
* The default is that whole range; narrow it with the fluent {@link #min(Duration)} / {@link
* #max(Duration)} methods.
*/
public final class DurationGenerator implements Generator<Duration> {
private final long minNanos;
private final long maxNanos;

public DurationGenerator(long minNanos, long maxNanos) {
if (minNanos < 0) {
throw new IllegalArgumentException("durations: min must be non-negative");
}
if (minNanos > maxNanos) {
throw new IllegalArgumentException("durations: min (" + minNanos + "ns) > max (" + maxNanos + "ns)");
}
Expand All @@ -29,15 +27,17 @@ public DurationGenerator(long minNanos, long maxNanos) {
}

/**
* @param min the inclusive lower bound
* @param min the inclusive lower bound (may be negative; must fit in a {@code long} of
* nanoseconds)
* @return a copy with the lower bound set
*/
public DurationGenerator min(Duration min) {
return new DurationGenerator(min.toNanos(), maxNanos);
}

/**
* @param max the inclusive upper bound
* @param max the inclusive upper bound (may be negative; must fit in a {@code long} of
* nanoseconds)
* @return a copy with the upper bound set
*/
public DurationGenerator max(Duration max) {
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/dev/hegel/generators/ListGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ public ListGenerator<T> minSize(int minSize) {
}

/**
* @param maxSize the maximum length (inclusive)
* @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound)
* @return a copy with the maximum size set
*/
public ListGenerator<T> maxSize(int maxSize) {
return new ListGenerator<>(element, minSize, maxSize);
return new ListGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "lists"));
}

/** @hidden */
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/dev/hegel/generators/MapGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ public MapGenerator<K, V> minSize(int minSize) {
}

/**
* @param maxSize the maximum entry count (inclusive)
* @param maxSize the maximum entry count (inclusive, {@code >= 0}; omit the call for no bound)
* @return a copy with the maximum size set
*/
public MapGenerator<K, V> maxSize(int maxSize) {
return new MapGenerator<>(keys, values, minSize, maxSize);
return new MapGenerator<>(keys, values, minSize, Sizes.checkedMax(maxSize, "maps"));
}

/** @hidden */
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/dev/hegel/generators/RegexGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* Generates strings matching a (Python-compatible) regular expression. By default the entire
* string matches the pattern; use {@link #fullmatch(boolean) fullmatch(false)} to generate strings
* that merely contain a match. Always basic (one engine call).
*
* <p>The pattern uses the engine's Python {@code re} dialect, which differs from {@link
* java.util.regex.Pattern} in some constructs.
*/
public final class RegexGenerator implements Generator<String> {
private final String pattern;
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/dev/hegel/generators/SetGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ public SetGenerator<T> minSize(int minSize) {
}

/**
* @param maxSize the maximum size (inclusive)
* @param maxSize the maximum size (inclusive, {@code >= 0}; omit the call for no bound)
* @return a copy with the maximum size set
*/
public SetGenerator<T> maxSize(int maxSize) {
return new SetGenerator<>(element, minSize, maxSize);
return new SetGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "sets"));
}

/** @hidden */
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/dev/hegel/generators/Sizes.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
final class Sizes {
private Sizes() {}

/**
* Validate a user-supplied maximum size from a public fluent setter. Rejects every negative
* value so the engine's internal "unbounded" sentinel ({@code -1}) can never be smuggled in
* through the public API.
*/
static long checkedMax(long maxSize, String what) {
if (maxSize < 0) {
throw new IllegalArgumentException(
what + ": maxSize must be >= 0, got " + maxSize + "; omit the maxSize call for no upper bound");
}
return maxSize;
}

static void validate(long minSize, long maxSize, String what) {
if (minSize < 0) {
throw new IllegalArgumentException(what + ": minSize must be >= 0, got " + minSize);
Expand Down
18 changes: 16 additions & 2 deletions src/main/java/dev/hegel/generators/TextGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public TextGenerator(
String includeChars,
String excludeChars) {
Sizes.validate(minSize, maxSize, "text");
if (categories != null && excludeCategories != null) {
throw new IllegalArgumentException("text: categories() and excludeCategories() cannot be combined;"
+ " express the constraint one way, e.g. list only the allowed categories");
}
this.minSize = minSize;
this.maxSize = maxSize;
this.minCodepoint = minCodepoint;
Expand Down Expand Up @@ -61,13 +65,13 @@ public TextGenerator minSize(int minSize) {
}

/**
* @param maxSize the maximum codepoint length
* @param maxSize the maximum codepoint length (must be {@code >= 0}; omit the call for no bound)
* @return a copy with the maximum size set
*/
public TextGenerator maxSize(int maxSize) {
return new TextGenerator(
minSize,
maxSize,
Sizes.checkedMax(maxSize, "text"),
minCodepoint,
maxCodepoint,
categories,
Expand All @@ -93,8 +97,12 @@ public TextGenerator codepoints(int min, int max) {
/**
* Restrict to the listed Unicode general categories (e.g. {@code "Lu"}, {@code "Nd"}).
*
* <p>Cannot be combined with {@link #excludeCategories}: the engine honors only one of the two
* constraints, so express the restriction one way.
*
* @param cats the allowed categories
* @return a copy with the categories set
* @throws IllegalArgumentException if {@link #excludeCategories} was already set
*/
public TextGenerator categories(String... cats) {
return new TextGenerator(
Expand All @@ -109,8 +117,14 @@ public TextGenerator categories(String... cats) {
}

/**
* Exclude the listed Unicode general categories.
*
* <p>Cannot be combined with {@link #categories}: the engine honors only one of the two
* constraints, so express the restriction one way.
*
* @param cats categories to exclude
* @return a copy with excluded categories set
* @throws IllegalArgumentException if {@link #categories} was already set
*/
public TextGenerator excludeCategories(String... cats) {
return new TextGenerator(
Expand Down
19 changes: 18 additions & 1 deletion src/test/java/dev/hegel/ConformanceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ void textRespectsLengthAndCharacters() {
assertAllExamples(characters(), s -> cp(s) == 1);
}

@Test
void charactersAreSingleCodepointsNotSingleChars() {
// characters() means one Unicode CODEPOINT. A supplementary-plane codepoint occupies two
// UTF-16 chars, so String.length() == 2 there; codePointAt(0) is the safe accessor.
assertAllExamples(
characters().codepoints(0x10000, 0x10FFF),
s -> s.codePointCount(0, s.length()) == 1
&& s.length() == 2
&& Character.isSupplementaryCodePoint(s.codePointAt(0)));
}

@Test
void binaryRespectsLength() {
assertAllExamples(binary().minSize(1).maxSize(4), b -> b.length >= 1 && b.length <= 4);
Expand Down Expand Up @@ -216,10 +227,16 @@ void temporalGeneratorsProduceJavaTimeTypes() {
assertAllExamples(dates(), d -> d.getYear() >= 1 && d.getYear() <= 9999);
assertAllExamples(times(), t -> t.getHour() >= 0 && t.getHour() <= 23);
assertAllExamples(datetimes(), dt -> dt.getDayOfMonth() >= 1 && dt.getDayOfMonth() <= 31);
assertAllExamples(durations(), d -> !d.isNegative() && d.toNanos() <= Long.MAX_VALUE);
// durations() spans the full signed nanosecond range: negative values are reachable by
// default (Duration is signed, like Hypothesis's timedeltas)...
assertTrue(findAny(durations(), Duration::isNegative).isNegative());
// ...and bounds are honored, on either side of zero.
assertAllExamples(
durations().min(Duration.ofSeconds(1)).max(Duration.ofSeconds(60)),
d -> d.compareTo(Duration.ofSeconds(1)) >= 0 && d.compareTo(Duration.ofSeconds(60)) <= 0);
assertAllExamples(
durations().min(Duration.ofSeconds(-60)).max(Duration.ofSeconds(-1)),
d -> d.compareTo(Duration.ofSeconds(-60)) >= 0 && d.compareTo(Duration.ofSeconds(-1)) <= 0);
}

@Test
Expand Down
Loading
Loading