hegeltest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +264 -0
  6. data/Rakefile +19 -0
  7. data/docs/README.md +25 -0
  8. data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
  9. data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
  10. data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
  11. data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
  12. data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
  13. data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
  14. data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
  15. data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
  16. data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
  17. data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
  18. data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
  19. data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
  20. data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
  21. data/docs/architecture.md +182 -0
  22. data/lib/hegel/draw_name.rb +109 -0
  23. data/lib/hegel/errors.rb +47 -0
  24. data/lib/hegel/generator.rb +98 -0
  25. data/lib/hegel/generators.rb +865 -0
  26. data/lib/hegel/lib_hegel/real.rb +1149 -0
  27. data/lib/hegel/lib_hegel.rb +269 -0
  28. data/lib/hegel/libhegel_version.rb +9 -0
  29. data/lib/hegel/locate.rb +188 -0
  30. data/lib/hegel/report.rb +87 -0
  31. data/lib/hegel/runner.rb +464 -0
  32. data/lib/hegel/settings.rb +164 -0
  33. data/lib/hegel/state_machine.rb +89 -0
  34. data/lib/hegel/stateful/pool.rb +111 -0
  35. data/lib/hegel/stateful.rb +120 -0
  36. data/lib/hegel/syntax/methods.rb +173 -0
  37. data/lib/hegel/test_case.rb +523 -0
  38. data/lib/hegel/version.rb +5 -0
  39. data/lib/hegel.rb +92 -0
  40. data/lib/hegeltest.rb +7 -0
  41. data/lib/tasks/libhegel.rake +112 -0
  42. data/lib/tasks/platform_gems.rake +111 -0
  43. data/sig/hegel.rbs +563 -0
  44. data/skills/hegel-ruby/SKILL.md +30 -0
  45. data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
  46. metadata +113 -0
@@ -0,0 +1,523 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "draw_name"
4
+ require_relative "errors"
5
+ require_relative "lib_hegel"
6
+
7
+ module Hegel
8
+ # Wraps one libhegel test-case handle: the native draw surface
9
+ # (#generate_integer, #start_span, #new_collection, and so on) every
10
+ # Hegel::Generator#do_draw is built on, plus the recording entry points a
11
+ # caller (or #draw) reaches for -- #draw_integer/#draw_boolean for the two
12
+ # primitive draws, #draw for a Hegel::Generator, #note for a message, and
13
+ # #assume/#reject to discard the case outright.
14
+ #
15
+ # The native methods below intentionally do not record: a Hegel::Generator
16
+ # composing several of them (Hegel::Generators::ArrayGenerator drawing one
17
+ # element per loop iteration, say) must produce exactly one report entry
18
+ # for the whole compound value, not one per native call it happens to
19
+ # make. Only #draw_integer, #draw_boolean, #draw, and #note record, each
20
+ # exactly once per call, tagged :draw or :note so a rendered report can
21
+ # tell the two apart while keeping the call order they share (see
22
+ # #record_draw and #note, and Hegel::Report.assign_names, which numbers
23
+ # only the :draw entries).
24
+ class TestCase
25
+ # #name_for's fallback when a draw has no better name (see below).
26
+ DEFAULT_DRAW_NAME = "draw"
27
+
28
+ # Frames from #name_for's own caller_locations call up to the user's own
29
+ # source line: #record_draw's call to #name_for (1), the public
30
+ # draw_integer/draw_boolean/draw call to #record_draw (2), and the
31
+ # user's own call to that public method (3). The same depth serves
32
+ # #draw as it does #draw_integer/#draw_boolean: #draw calls
33
+ # #record_draw only after Hegel::Generator#do_draw has already
34
+ # returned, so a generator's own frames (and any native calls it made)
35
+ # are off the stack by the time #record_draw runs, leaving the same
36
+ # three frames between #name_for and the user's own call site either
37
+ # way. Verified empirically (see test/hegel/test_runner.rb), not just
38
+ # reasoned about: Ruby's caller_locations counts real stack frames, and
39
+ # a wrong guess here would misname every drawn value, not just fail
40
+ # loudly.
41
+ DRAW_CALLER_DEPTH = 3
42
+
43
+ # +impl+ and +ctx+ are carried alongside +handle+ so each draw call can
44
+ # reach the same LibHegel implementation and context the run loop opened,
45
+ # without this class knowing anything about the native binding layer or
46
+ # the Fake.
47
+ #
48
+ # +record+ defaults to false: recording is Hegel::Runner's decision, made
49
+ # only for the one, already-shrunk replay that produces a failure report
50
+ # (see #record_draw for why every other iteration skips it).
51
+ def initialize(impl, ctx, handle, record: false)
52
+ @impl = impl
53
+ @ctx = ctx
54
+ @handle = handle
55
+ @record = record
56
+ @entries = record ? [] : nil
57
+ @pools = []
58
+ end
59
+
60
+ # The [:draw, name, value] / [:note, message] entries recorded so far, in
61
+ # call order, or nil when this instance was not built to record.
62
+ # Hegel::Runner reads this once, after the block that owns this test
63
+ # case has run to completion or raised. One tagged list rather than a
64
+ # draws list and a separate notes list, so a note recorded between two
65
+ # draws stays between them in the report -- the same interleaving
66
+ # hegel-rust gets for free by sending both to one output callback.
67
+ attr_reader :entries
68
+
69
+ # hegel_generate_integer: an integer in [min_value, max_value].
70
+ def draw_integer(min_value, max_value, label: nil)
71
+ value = generate_integer(min_value, max_value)
72
+ record_draw(label, value)
73
+ value
74
+ end
75
+
76
+ # hegel_generate_boolean: true with probability +p+ (default 0.5).
77
+ def draw_boolean(p = 0.5, label: nil)
78
+ value = generate_boolean(p)
79
+ record_draw(label, value)
80
+ value
81
+ end
82
+
83
+ # Draws +generator+ (a Hegel::Generator) and records the single value it
84
+ # produced, however many native calls +generator+ made to produce it.
85
+ # +label+ is threaded through to #record_draw the same way it is for
86
+ # #draw_integer/#draw_boolean, wins over a recovered name the same way.
87
+ def draw(generator, label: nil)
88
+ value = generator.do_draw(self)
89
+ record_draw(label, value)
90
+ value
91
+ end
92
+
93
+ # Discards this test case (see #reject) unless +condition+ holds.
94
+ # +condition+ is read as Ruby truthiness, not restricted to true/false
95
+ # the way hegel-rust's TestCase::assume takes a bool: it lets a caller
96
+ # write assume(hash[:key]) directly instead of assume(!!hash[:key]).
97
+ def assume(condition)
98
+ reject unless condition
99
+ end
100
+
101
+ # Discards this test case unconditionally, with no reason attached (the
102
+ # same shape as hegel-rust's own TestCase::reject; distinct from
103
+ # #collection_reject, which rejects one drawn element rather than the
104
+ # whole case). Raises Hegel::AssumeFailed, which Hegel::Runner.classify
105
+ # already translates to HEGEL_STATUS_INVALID -- that translation is not
106
+ # this method's concern.
107
+ def reject
108
+ raise Hegel::AssumeFailed, "hegel: an assumption failed; this test case is discarded"
109
+ end
110
+
111
+ # Records +message+ (or, from the block form, its return value) for the
112
+ # eventual failure report, interleaved with draws in call order (see
113
+ # #record_draw and the :entries tag on #entries). Takes exactly one of
114
+ # +message+ or a block; hegel-rust's own TestCase::note takes only a
115
+ # message, so the block form is this binding's own addition, kept to
116
+ # the same "note" name and String content once evaluated.
117
+ #
118
+ # The block form exists to skip building the string on an iteration
119
+ # that will not record: Hegel::Runner.drive's own comment measured
120
+ # roughly 1000 iterations for a 20-test-case run that fails every time,
121
+ # and a stateful test's step loop calls #note once per step, so the
122
+ # avoided work is not incidental.
123
+ #
124
+ # Validates before the #@record check, not after: checking only on the
125
+ # recording iteration would let a caller's mistake reach only the final
126
+ # replay instead of surfacing on the very first call.
127
+ #
128
+ # +message+ (or the block's value) is kept as given, not #to_s'd here --
129
+ # the same reason #record_draw keeps a drawn value un-#inspect'd.
130
+ # Hegel::Report does that formatting once, at report assembly, not on
131
+ # every recording pass.
132
+ #
133
+ # Decided simplification, unlike hegel-rust: this does not surface a
134
+ # note on every iteration under a higher verbosity. Two reasons. First,
135
+ # this binding runs with libhegel's own output callback set to NULL
136
+ # (Hegel::LibHegel::Real#run_start passes it, and says why), so there
137
+ # is no engine-printed line for a per-iteration note to line up with.
138
+ # Second, printing on every iteration would reintroduce the per-case
139
+ # cost the block form above exists to avoid.
140
+ def note(message = nil)
141
+ has_message = !message.nil?
142
+ if has_message == block_given?
143
+ raise Hegel::Error, "hegel: note requires exactly one of a message or a block"
144
+ end
145
+
146
+ return unless @record
147
+
148
+ @entries << [:note, has_message ? message : yield]
149
+ end
150
+
151
+ # hegel_generate_integer, without recording. Hegel::Generators::
152
+ # IntegerGenerator's own primitive; #draw_integer is this plus
153
+ # recording.
154
+ def generate_integer(min_value, max_value)
155
+ @impl.generate_integer(@ctx, @handle, min_value, max_value)
156
+ end
157
+
158
+ # hegel_generate_integer_big, without recording. Hegel::Generators::
159
+ # IntegerGenerator's own primitive for bounds outside int64_t's range,
160
+ # used instead of #generate_integer only then; see IntegerGenerator#do_draw.
161
+ def generate_integer_big(min_value, max_value)
162
+ @impl.generate_integer_big(@ctx, @handle, min_value, max_value)
163
+ end
164
+
165
+ # hegel_generate_boolean, without recording. Hegel::Generators::
166
+ # BooleanGenerator's own primitive; #draw_boolean is this plus
167
+ # recording.
168
+ def generate_boolean(p = 0.5)
169
+ @impl.generate_boolean(@ctx, @handle, p, false, false)
170
+ end
171
+
172
+ # hegel_generate_float. Hegel::Generators::FloatGenerator's own
173
+ # primitive.
174
+ def generate_float(width, min_value, max_value, allow_nan:, allow_infinity:, exclude_min:, exclude_max:,
175
+ smallest_nonzero_magnitude:)
176
+ @impl.generate_float(@ctx, @handle, width, min_value, max_value, allow_nan, allow_infinity, exclude_min,
177
+ exclude_max, smallest_nonzero_magnitude)
178
+ end
179
+
180
+ # hegel_target: records +value+ as a numeric observation for libhegel's
181
+ # own hill-climbing between generation rounds, under +label+. Not
182
+ # recorded to the eventual failure report -- hegel-rust's own
183
+ # TestCase::target and hegel-java's own TestCase#target both leave an
184
+ # observation out of their report too, so this sits in the same
185
+ # "native surface that does not record" group as #generate_integer,
186
+ # #generate_boolean, and #generate_float above, rather than beside
187
+ # #draw_integer/#draw_boolean/#draw.
188
+ #
189
+ # +label+ defaults to "", matching hegel-go's own Target(value) and
190
+ # hegel-java's own target(double). hegel-rust's tc.target(expr) reaches
191
+ # a labelled call only because a macro rewrites it to
192
+ # target_labelled(expr, "expr") at compile time, using the call's own
193
+ # source text; Ruby has no such macro. Recovering a label from the call
194
+ # site the way #name_for does for an unlabelled draw was rejected: it
195
+ # would call caller_locations on every #target call, working against
196
+ # this class's own per-case cost concern (see #note's own comment on
197
+ # the same point), and it would make this default observably differ
198
+ # from hegel-go's and hegel-java's.
199
+ #
200
+ # +value+ is passed through as given, not #to_f'd: hegel_target's
201
+ # double-typed argument already accepts an Integer or a Float exactly
202
+ # as given (checked directly against libhegel, not assumed from the
203
+ # binding library's documentation alone), so converting here would only
204
+ # add a second Ruby-side step ahead of the one the binding already
205
+ # performs.
206
+ #
207
+ # No argument validation here. hegel_target's own HEGEL_E_INVALID_ARG
208
+ # messages -- a label recorded twice, a non-finite value -- are
209
+ # already specific, and Hegel::LibHegel.check! already translates them
210
+ # to Hegel::Error the same way it does for every other hegel_* call
211
+ # this class makes. Writing the same check on this side would only
212
+ # give one mistake two messages to drift apart from each other.
213
+ def target(value, label: "")
214
+ @impl.target(@ctx, @handle, value, label)
215
+ end
216
+
217
+ # hegel_start_span, labelled with one of the Hegel::LibHegel::
218
+ # HEGEL_LABEL_* constants. Every compound Hegel::Generator (map, filter,
219
+ # arrays) opens one of these around its own draw; see #stop_span.
220
+ def start_span(label)
221
+ @impl.start_span(@ctx, @handle, label)
222
+ end
223
+
224
+ # hegel_stop_span, closing the span #start_span most recently opened.
225
+ # +discard+ true marks it rejected (a filter predicate that did not
226
+ # hold), so libhegel retries from before the span opened.
227
+ def stop_span(discard: false)
228
+ @impl.stop_span(@ctx, @handle, discard)
229
+ end
230
+
231
+ # hegel_new_collection: Hegel::Generators::ArrayGenerator's own sizing
232
+ # primitive, paired with #collection_more and #collection_free.
233
+ def new_collection(min_size, max_size)
234
+ @impl.new_collection(@ctx, @handle, min_size, max_size)
235
+ end
236
+
237
+ # hegel_collection_more: whether to draw another element into
238
+ # +collection+.
239
+ def collection_more(collection)
240
+ @impl.collection_more(@ctx, @handle, collection)
241
+ end
242
+
243
+ # hegel_collection_reject: tells libhegel the element most recently
244
+ # drawn under +collection+ is invalid (a duplicate key or value, for
245
+ # Hegel::Generators::SetGenerator/HashGenerator), so the next
246
+ # #collection_more call offers another attempt at the same slot instead
247
+ # of treating the collection as one element closer to done.
248
+ def collection_reject(collection, why: nil)
249
+ @impl.collection_reject(@ctx, @handle, collection, why)
250
+ end
251
+
252
+ # hegel_collection_free.
253
+ def collection_free(collection)
254
+ @impl.collection_free(@ctx, collection)
255
+ end
256
+
257
+ # Runs the block with a text generator handle scoped to this one call,
258
+ # freeing it before returning. See Hegel::Generators::TextGenerator for
259
+ # why this is built fresh per draw rather than cached on the generator
260
+ # instance. Named for what it builds (hegel_string_generator_text), to
261
+ # read the same way as #with_regex_generator/#with_email_generator/
262
+ # #with_url_generator/#with_domain_generator below, each named for its
263
+ # own hegel_string_generator_* call.
264
+ def with_text_generator(**kwargs, &block)
265
+ with_generator_handle(@impl.string_generator_text(@ctx, **kwargs), &block)
266
+ end
267
+
268
+ # Runs the block with a regex-matching string generator handle built
269
+ # from +pattern+/+fullmatch+ (see #string_generator_regex), freeing it
270
+ # via #with_generator_handle whether the block returns or raises.
271
+ def with_regex_generator(pattern, fullmatch:, &block)
272
+ with_generator_handle(string_generator_regex(pattern, fullmatch), &block)
273
+ end
274
+
275
+ # Runs the block with an email-address string generator handle (see
276
+ # #string_generator_email), freed the same way as #with_regex_generator.
277
+ def with_email_generator(&block)
278
+ with_generator_handle(string_generator_email, &block)
279
+ end
280
+
281
+ # Runs the block with a URL string generator handle (see
282
+ # #string_generator_url), freed the same way as #with_regex_generator.
283
+ def with_url_generator(&block)
284
+ with_generator_handle(string_generator_url, &block)
285
+ end
286
+
287
+ # Runs the block with a domain-name string generator handle built from
288
+ # +max_length+ (see #string_generator_domain), freed the same way as
289
+ # #with_regex_generator.
290
+ def with_domain_generator(max_length:, &block)
291
+ with_generator_handle(string_generator_domain(max_length), &block)
292
+ end
293
+
294
+ # hegel_generate_string against +generator+ (from #with_text_generator,
295
+ # #with_regex_generator, #with_email_generator, #with_url_generator, or
296
+ # #with_domain_generator).
297
+ def generate_string(generator)
298
+ @impl.generate_string(@ctx, @handle, generator)
299
+ end
300
+
301
+ # hegel_generate_bytes. A future Hegel::Generators::BinaryGenerator's
302
+ # own primitive, the bytes counterpart to #generate_string.
303
+ def generate_bytes(min_size, max_size)
304
+ @impl.generate_bytes(@ctx, @handle, min_size, max_size)
305
+ end
306
+
307
+ # hegel_generate_ipv4, returning the address's 4 raw bytes. Converting
308
+ # to a caller-facing address type (IPAddr or similar) is left to the
309
+ # generator built on top of this call.
310
+ def generate_ipv4
311
+ @impl.generate_ipv4(@ctx, @handle)
312
+ end
313
+
314
+ # hegel_generate_ipv6, returning the address's 16 raw bytes. Same
315
+ # division of labor as #generate_ipv4.
316
+ def generate_ipv6
317
+ @impl.generate_ipv6(@ctx, @handle)
318
+ end
319
+
320
+ # hegel_generate_uuid, returning 16 raw bytes. Hegel::Generators::
321
+ # UuidsGenerator's own primitive; converting the bytes to the standard
322
+ # hex String is that generator's job, the same division of labor
323
+ # #generate_ipv4/#generate_ipv6 already follow.
324
+ def generate_uuid(version, has_version)
325
+ @impl.generate_uuid(@ctx, @handle, version, has_version)
326
+ end
327
+
328
+ # hegel_string_generator_regex. +alphabet+ is an optional string
329
+ # generator handle (from #with_text_generator), or nil (the default)
330
+ # for the header's documented "no particular alphabet" case. Public,
331
+ # like the three constructors below it, because
332
+ # test/hegel/test_lib_hegel.rb's own layer-1 conformance test already
333
+ # calls all four (and #string_generator_free) directly against the
334
+ # Fake to prove this thin delegation reaches +@impl+ correctly; a
335
+ # Hegel::Generator's do_draw should reach it only through
336
+ # #with_regex_generator instead.
337
+ def string_generator_regex(pattern, fullmatch, alphabet = nil)
338
+ @impl.string_generator_regex(@ctx, pattern, fullmatch, alphabet)
339
+ end
340
+
341
+ # hegel_string_generator_email. Public for the same reason
342
+ # #string_generator_regex is; reached only through
343
+ # #with_email_generator otherwise.
344
+ def string_generator_email
345
+ @impl.string_generator_email(@ctx)
346
+ end
347
+
348
+ # hegel_string_generator_url. Public for the same reason
349
+ # #string_generator_regex is; reached only through #with_url_generator
350
+ # otherwise.
351
+ def string_generator_url
352
+ @impl.string_generator_url(@ctx)
353
+ end
354
+
355
+ # hegel_string_generator_domain. Public for the same reason
356
+ # #string_generator_regex is; reached only through
357
+ # #with_domain_generator otherwise.
358
+ def string_generator_domain(max_length)
359
+ @impl.string_generator_domain(@ctx, max_length)
360
+ end
361
+
362
+ # hegel_string_generator_free, for a handle built by
363
+ # #string_generator_regex, #string_generator_email,
364
+ # #string_generator_url, or #string_generator_domain. Public for the
365
+ # same reason #string_generator_regex is: the layer-1 conformance test
366
+ # calls it directly to prove the delegation. #with_generator_handle
367
+ # below is what pairs it with one of the four constructors for a
368
+ # do_draw.
369
+ def string_generator_free(generator)
370
+ @impl.string_generator_free(@ctx, generator)
371
+ end
372
+
373
+ # hegel_new_pool: opens a native pool handle and records it in @pools, so
374
+ # #free_pools can release it once this test case is done. Recorded here,
375
+ # not by a second call a caller must remember to make, so a caller of
376
+ # Hegel::Stateful::Pool.new cannot forget it and leak the handle --
377
+ # docs/adr/0011 has the ownership decision behind this.
378
+ def new_pool
379
+ pool = @impl.new_pool(@ctx, @handle)
380
+ @pools << pool
381
+ pool
382
+ end
383
+
384
+ # hegel_pool_add: a fresh variable id from +pool+, for the caller to
385
+ # associate with the value it just generated. Hegel::Stateful::Pool#add's
386
+ # own primitive.
387
+ def pool_add(pool)
388
+ @impl.pool_add(@ctx, @handle, pool)
389
+ end
390
+
391
+ # hegel_pool_generate: the variable id libhegel chose from +pool+ (and
392
+ # can shrink which one it chose), +consume+ true removing it from the
393
+ # pool. Hegel::Stateful::Pool's two generators' own primitive. No
394
+ # emptiness check here: the header documents HEGEL_E_ASSUME as this
395
+ # call's own answer for an empty pool, and LibHegel.check! already
396
+ # translates that to Hegel::AssumeFailed, the same as every other
397
+ # assumption failure.
398
+ def pool_generate(pool, consume)
399
+ @impl.pool_generate(@ctx, @handle, pool, consume)
400
+ end
401
+
402
+ # hegel_pool_free. Not called directly by a caller of
403
+ # Hegel::Stateful::Pool -- only #free_pools below, which Hegel::Runner
404
+ # calls once this test case is done -- but kept its own wrapper the same
405
+ # as every other native call this class exposes.
406
+ def pool_free(pool)
407
+ @impl.pool_free(@ctx, pool)
408
+ end
409
+
410
+ # Releases every pool #new_pool opened on this test case. Hegel::Runner
411
+ # calls this exactly once, in the same place it frees the test-case
412
+ # handle itself, before that handle goes: docs/adr/0011 decides the test
413
+ # case is a pool's owner, not whichever rule happened to call
414
+ # Hegel::Stateful::Pool.new, since that code has no place of its own to
415
+ # free one.
416
+ def free_pools
417
+ @pools.each { |pool| pool_free(pool) }
418
+ end
419
+
420
+ # hegel_new_state_machine: opens the state-machine handle
421
+ # Hegel::Stateful.run drives for the rest of one stateful test. Kept
422
+ # here rather than on that module, the same reason every other native
423
+ # call is a method on this class: Hegel::Stateful never touches @impl or
424
+ # @ctx directly, only this handle-scoped surface.
425
+ def new_state_machine(rule_names, invariant_names)
426
+ @impl.new_state_machine(@ctx, @handle, rule_names, invariant_names)
427
+ end
428
+
429
+ # hegel_state_machine_next_rule: the index (into the +rule_names+
430
+ # #new_state_machine was given) of the next rule to run, or
431
+ # LibHegel::HEGEL_STATE_MACHINE_DONE once this test case's step budget
432
+ # is spent.
433
+ def state_machine_next_rule(state_machine)
434
+ @impl.state_machine_next_rule(@ctx, @handle, state_machine)
435
+ end
436
+
437
+ # hegel_state_machine_rule_rejected: tells libhegel the rule most
438
+ # recently returned by #state_machine_next_rule stopped early on a
439
+ # failed assumption, so it does not count toward the step budget.
440
+ def state_machine_rule_rejected(state_machine)
441
+ @impl.state_machine_rule_rejected(@ctx, @handle, state_machine)
442
+ end
443
+
444
+ # hegel_state_machine_free. Takes no test-case handle, unlike every
445
+ # other #state_machine_* method here: the header documents a
446
+ # state-machine handle as freeable through any handle of the same
447
+ # test-case family, and LibHegel::Real#state_machine_free's own bind
448
+ # reflects that by taking only +ctx+ and the state-machine handle.
449
+ def state_machine_free(state_machine)
450
+ @impl.state_machine_free(@ctx, state_machine)
451
+ end
452
+
453
+ # hegel_generate_date, without recording. Hegel::Generators::
454
+ # DatesGenerator's own primitive. +min_value+/+max_value+ are each a
455
+ # [year, month, day] Array; the return value is the same shape.
456
+ def generate_date(min_value, max_value)
457
+ @impl.generate_date(@ctx, @handle, min_value, max_value)
458
+ end
459
+
460
+ # hegel_generate_time, without recording. Hegel::Generators::
461
+ # TimesGenerator's own primitive. +min_value+/+max_value+ are each an
462
+ # [hour, minute, second, microsecond] Array; the return value is the
463
+ # same shape.
464
+ def generate_time(min_value, max_value)
465
+ @impl.generate_time(@ctx, @handle, min_value, max_value)
466
+ end
467
+
468
+ # hegel_generate_datetime, without recording. Hegel::Generators::
469
+ # DatetimesGenerator's own primitive. +min_date+/+max_date+ are each a
470
+ # [year, month, day] Array, +min_time+/+max_time+ each an
471
+ # [hour, minute, second, microsecond] Array; the return value is a
472
+ # [[year, month, day], [hour, minute, second, microsecond]] pair.
473
+ def generate_datetime(min_date, min_time, max_date, max_time)
474
+ @impl.generate_datetime(@ctx, @handle, min_date, min_time, max_date, max_time)
475
+ end
476
+
477
+ private
478
+
479
+ # Runs the block with +generator+, freeing it via #string_generator_free
480
+ # whether the block returns or raises. The one #ensure
481
+ # #with_regex_generator/#with_email_generator/#with_url_generator/
482
+ # #with_domain_generator above share, so each of them stays a single
483
+ # line and none writes its own begin/ensure. Private, unlike the four
484
+ # methods that call it: a public version would take an already-built
485
+ # handle as its argument, the same shape a do_draw could otherwise call
486
+ # and forget to free. Keeping it private leaves #with_regex_generator
487
+ # and friends as the sanctioned path into a do_draw, each already
488
+ # pairing its own acquire with this release in one call.
489
+ def with_generator_handle(generator)
490
+ yield generator
491
+ ensure
492
+ string_generator_free(generator)
493
+ end
494
+
495
+ # Records a :draw entry (name, value) for the eventual failure report. A
496
+ # no-op unless +record+ was true at #initialize: a run iterates the
497
+ # generation and shrink phases far more than once (Hegel::Runner.drive's
498
+ # own comment measured 1003 iterations for a 20-test-case run that
499
+ # always failed), so recording -- and later #inspect-ing -- every draw
500
+ # there would dominate a failing run's cost. Only the last,
501
+ # already-shrunk replay pays for it.
502
+ def record_draw(label, value)
503
+ return unless @record
504
+
505
+ @entries << [:draw, name_for(label, DRAW_CALLER_DEPTH), value]
506
+ end
507
+
508
+ # The single place a draw's name is decided, tried in this order: the
509
+ # caller's own label:, the name Hegel::DrawName recovers from the
510
+ # caller's own source (see docs/adr/0005), then a generic fallback.
511
+ # +depth+ is how many caller_locations frames separate this call from
512
+ # the user's own source line (see DRAW_CALLER_DEPTH); +location+ is nil
513
+ # only when the stack is shallower than +depth+, which no real draw_*
514
+ # call site produces -- defensive, not a path this binding's own calls
515
+ # reach.
516
+ def name_for(label, depth)
517
+ return label if label
518
+
519
+ location = caller_locations(depth, 1)&.first
520
+ (location && DrawName.for(location.path, location.lineno)) || DEFAULT_DRAW_NAME
521
+ end
522
+ end
523
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hegel
4
+ VERSION = "0.1.0"
5
+ end
data/lib/hegel.rb ADDED
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hegel/version"
4
+ require_relative "hegel/errors"
5
+ require_relative "hegel/runner"
6
+ require_relative "hegel/generator"
7
+ require_relative "hegel/generators"
8
+ require_relative "hegel/syntax/methods"
9
+ require_relative "hegel/state_machine"
10
+ require_relative "hegel/stateful"
11
+ require_relative "hegel/lib_hegel/real"
12
+
13
+ # Hegel is a property-based testing library for Ruby. It drives libhegel, the
14
+ # native engine that also backs the Rust, Go, TypeScript, Java, OCaml, and C++
15
+ # implementations of the Hegel protocol.
16
+ #
17
+ # The gem is named `hegeltest` while the namespace and require path are
18
+ # `hegel`, mirroring the Rust implementation: the published crate is
19
+ # `hegeltest` and callers write `use hegel::...`.
20
+ module Hegel
21
+ module_function
22
+
23
+ # Runs +block+ as a Hegel property, drawing test cases from the +tc+ it
24
+ # yields (see Hegel::TestCase). Returns nil on a passing run. On a failing
25
+ # run, writes a failure report to +output+ (see Hegel::Report) and then
26
+ # re-raises the exception the smallest failing case's body raised, class
27
+ # and backtrace intact, so a host test framework reports it as its own
28
+ # assertion failure rather than as a Hegel-specific one. Raises
29
+ # Hegel::Error for a run-level failure instead of a property failure.
30
+ #
31
+ # +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
32
+ # +suppress_health_check+ all default to nil, which means the same thing
33
+ # for each of them: do not call the matching libhegel setter, and let the
34
+ # engine's own default apply instead. See Hegel::Settings for the
35
+ # keyword-to-setter mapping, the verbosity Symbols it accepts, and the
36
+ # +phases+/+suppress_health_check+ Symbols each of those two accepts (as
37
+ # an Array; an empty Array raises Hegel::Error rather than silently
38
+ # meaning "none"). +verbosity: :quiet+ also silences the failure report
39
+ # itself, not just libhegel's own progress output.
40
+ #
41
+ # +database+ and +database_key+ opt a run into libhegel's example
42
+ # database: +database_key+ is the switch, +database+ only means something
43
+ # alongside it. Left at their shared default (nil, nil), a run stores
44
+ # nothing, matching every Hegel.test call before these two keywords
45
+ # existed. Given a String, +database_key+ scopes what a run stores and
46
+ # replays -- make it unique to the property, since two properties sharing
47
+ # a key share one replay scope. +database+ then chooses the directory
48
+ # (libhegel's own default, ./.hegel/examples/ outside CI, if left nil
49
+ # alongside a key). Passing +database+ without +database_key+ raises
50
+ # Hegel::Error. See docs/adr/0009 for the decision and the measurements
51
+ # behind it.
52
+ #
53
+ # +report_multiple_failures+ defaults to false, not nil, unlike every
54
+ # keyword above: see Hegel::Runner.run's own comment for why departing
55
+ # from libhegel's own default (true) is itself the decision here.
56
+ #
57
+ # +stateful_step_count+ bounds how many rules a Hegel::Stateful.run call
58
+ # applies per test case; left nil (the default) leaves libhegel's own
59
+ # default of 50 in place. hegel.h documents it as needing to be at least
60
+ # 1; like +tc.target+'s label, that requirement is left to the engine
61
+ # rather than re-checked here.
62
+ #
63
+ # +output+ (default $stderr) is where a failure report is written; a
64
+ # caller passes its own IO to capture that report instead (tests do).
65
+ #
66
+ # +reproduce_failure+, when given, replays the single case that blob
67
+ # (printed at the end of an earlier failure report) encodes, instead of
68
+ # starting a run: +test_cases+ and the other run-shaping keywords above
69
+ # have nothing to bound in that case. See Hegel::Runner.reproduce.
70
+ #
71
+ # +impl+ exists for tests: it lets Hegel::LibHegel::Fake stand in for the
72
+ # real engine. Ordinary callers never pass it. Its default expression
73
+ # (#default_impl) only runs when +impl+ is not given, so a test that does
74
+ # pass one never opens the native library at all.
75
+ def test(test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
76
+ phases: nil, suppress_health_check: nil, report_multiple_failures: false, stateful_step_count: nil,
77
+ output: $stderr, reproduce_failure: nil, impl: default_impl, &block)
78
+ Runner.run(impl: impl, test_cases: test_cases, seed: seed, derandomize: derandomize, verbosity: verbosity,
79
+ database: database, database_key: database_key, phases: phases, suppress_health_check: suppress_health_check,
80
+ report_multiple_failures: report_multiple_failures, stateful_step_count: stateful_step_count, output: output,
81
+ reproduce_failure: reproduce_failure, &block)
82
+ end
83
+
84
+ # The Hegel::LibHegel::Real instance #test uses by default, built once and
85
+ # reused: Hegel::LibHegel::Real#initialize opens the native library, and
86
+ # that should not happen again on every #test call in a process that calls
87
+ # it more than once. Building it here rather than at load time also means a
88
+ # caller who always passes their own +impl:+ never opens the library.
89
+ def default_impl
90
+ @default_impl ||= LibHegel::Real.new
91
+ end
92
+ end
data/lib/hegeltest.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler derives its automatic require from the gem name, so a gem published
4
+ # as `hegeltest` must answer to `require "hegeltest"`. The library itself lives
5
+ # in `hegel.rb` and the documented form is `require "hegel"`; this file exists
6
+ # so that `gem "hegeltest"` works without a `require:` option.
7
+ require_relative "hegel"