hegeltest 0.3.4 → 0.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -0
  3. data/README.ja.md +408 -0
  4. data/README.md +208 -102
  5. data/Rakefile +6 -3
  6. data/docs/README.md +9 -0
  7. data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
  8. data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
  9. data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
  10. data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
  11. data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
  12. data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
  13. data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
  14. data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
  15. data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
  16. data/docs/architecture.md +16 -1
  17. data/lib/hegel/errors.rb +24 -0
  18. data/lib/hegel/generator.rb +52 -25
  19. data/lib/hegel/generators.rb +715 -45
  20. data/lib/hegel/grammar_cache.rb +36 -0
  21. data/lib/hegel/lib_hegel/real.rb +320 -17
  22. data/lib/hegel/lib_hegel.rb +31 -6
  23. data/lib/hegel/libhegel_version.rb +1 -1
  24. data/lib/hegel/report.rb +13 -10
  25. data/lib/hegel/runner.rb +61 -27
  26. data/lib/hegel/settings.rb +92 -16
  27. data/lib/hegel/state_machine.rb +17 -4
  28. data/lib/hegel/stateful/pool.rb +52 -0
  29. data/lib/hegel/stateful.rb +2 -2
  30. data/lib/hegel/syntax/methods.rb +99 -15
  31. data/lib/hegel/test_case.rb +256 -35
  32. data/lib/hegel/test_location.rb +49 -0
  33. data/lib/hegel/version.rb +1 -1
  34. data/lib/hegel.rb +21 -5
  35. data/sig/hegel.rbs +295 -42
  36. data/skills/hegel-ruby/references/ruby/reference.md +463 -41
  37. metadata +13 -1
@@ -46,6 +46,16 @@ module Hegel
46
46
  # loudly.
47
47
  DRAW_CALLER_DEPTH = 3
48
48
 
49
+ # The largest minimum length #repeat draws for its loop: 2**40, the
50
+ # bound hegel-rust's TestCase::repeat draws its own minimum under
51
+ # (max_safe_min_size).
52
+ REPEAT_MAX_MIN_SIZE = 2**40
53
+
54
+ # How many columns further #repeat indents the entries one iteration
55
+ # records: the indent hegel-rust's TestCase::repeat gives the block
56
+ # handle it runs each iteration under.
57
+ REPEAT_INDENT = 2
58
+
49
59
  # +impl+ and +ctx+ are carried alongside +handle+ so each draw call can
50
60
  # reach the same LibHegel implementation and context the run loop opened,
51
61
  # without this class knowing anything about the native binding layer or
@@ -54,17 +64,26 @@ module Hegel
54
64
  # +record+ defaults to false: recording is Hegel::Runner's decision, made
55
65
  # only for the one, already-shrunk replay that produces a failure report
56
66
  # (see #record_draw for why every other iteration skips it).
57
- def initialize(impl, ctx, handle, record: false)
67
+ #
68
+ # +grammars+ is the run's Hegel::GrammarCache, which Hegel::Runner
69
+ # passes; a test case built without one compiles a grammar for each draw.
70
+ def initialize(impl, ctx, handle, record: false, grammars: nil)
58
71
  @impl = impl
59
72
  @ctx = ctx
60
73
  @handle = handle
61
74
  @record = record
62
75
  @entries = record ? [] : nil
63
76
  @pools = []
77
+ @grammars = grammars
78
+ # Only declares the ivar: an unset one already reads as nil.
79
+ @poison = nil # mutineer:disable-line statement_removal
80
+ @indent = 0
64
81
  end
65
82
 
66
- # The [:draw, name, value] / [:note, message] entries recorded so far, in
67
- # call order, or nil when this instance was not built to record.
83
+ # The [:draw, name, value, indent] / [:note, message, indent] entries
84
+ # recorded so far, in call order, or nil when this instance was not
85
+ # built to record. +indent+ is how many columns further in the report
86
+ # puts the entry; #repeat raises it around each iteration.
68
87
  # Hegel::Runner reads this once, after the block that owns this test
69
88
  # case has run to completion or raised. One tagged list rather than a
70
89
  # draws list and a separate notes list, so a note recorded between two
@@ -151,21 +170,56 @@ module Hegel
151
170
 
152
171
  return unless @record
153
172
 
154
- @entries << [:note, has_message ? message : yield]
173
+ @entries << [:note, has_message ? message : yield, @indent]
174
+ end
175
+
176
+ # Runs the block over and over, for as long as the engine keeps the
177
+ # loop going, and never returns normally: when the engine ends the loop
178
+ # it raises Hegel::LoopDone, which Hegel::Runner.classify reports as a
179
+ # passing case. A `break` in the block ends the loop early and returns
180
+ # from this method instead; hegel-rust's TestCase::repeat has no
181
+ # counterpart, since a Rust closure cannot break its caller's loop.
182
+ # docs/adr/0034 records both decisions.
183
+ #
184
+ # The loop is a collection whose minimum length is drawn first, from 0
185
+ # up to REPEAT_MAX_MIN_SIZE, with no maximum, the way hegel-rust draws
186
+ # it. Each iteration notes "// Repetition #N", then records what the
187
+ # block records REPEAT_INDENT columns further in. An iteration whose
188
+ # assumption failed is skipped rather than discarding the whole case;
189
+ # any other exception ends the loop (see #repeat_iteration).
190
+ #
191
+ # The minimum length is drawn with no span around it. hegel-rust's
192
+ # draw_silent opens one around every draw, and this library opens a
193
+ # span only around a generator that makes more than one native call
194
+ # (.claude/skills/new-generator), which one generate_integer is not.
195
+ def repeat(&block)
196
+ min_size = generate_integer(0, REPEAT_MAX_MIN_SIZE)
197
+ collection = new_collection(min_size, LibHegel::HEGEL_COLLECTION_MAX_SIZE_UNBOUNDED)
198
+ begin
199
+ iteration = 0
200
+ while collection_more(collection)
201
+ iteration += 1
202
+ note { "// Repetition ##{iteration}" }
203
+ repeat_iteration(&block)
204
+ end
205
+ ensure
206
+ collection_free(collection)
207
+ end
208
+ raise Hegel::LoopDone, "hegel: repeat finished its loop"
155
209
  end
156
210
 
157
211
  # hegel_generate_integer, without recording. Hegel::Generators::
158
212
  # IntegerGenerator's own primitive; #draw_integer is this plus
159
213
  # recording.
160
214
  def generate_integer(min_value, max_value)
161
- @impl.generate_integer(@ctx, @handle, min_value, max_value)
215
+ @impl.generate_integer(@ctx, live_handle, min_value, max_value)
162
216
  end
163
217
 
164
218
  # hegel_generate_integer_big, without recording. Hegel::Generators::
165
219
  # IntegerGenerator's own primitive for bounds outside int64_t's range,
166
220
  # used instead of #generate_integer only then; see IntegerGenerator#do_draw.
167
221
  def generate_integer_big(min_value, max_value)
168
- @impl.generate_integer_big(@ctx, @handle, min_value, max_value)
222
+ @impl.generate_integer_big(@ctx, live_handle, min_value, max_value)
169
223
  end
170
224
 
171
225
  # hegel_generate_boolean, without recording. Hegel::Generators::
@@ -175,14 +229,14 @@ module Hegel
175
229
  # has_forced is false, so the engine ignores the forced argument's value.
176
230
  # .mutineer.yml ignores that mutant by id, since a line marker would also
177
231
  # hide the killed mutant of has_forced.
178
- @impl.generate_boolean(@ctx, @handle, p, false, false)
232
+ @impl.generate_boolean(@ctx, live_handle, p, false, false)
179
233
  end
180
234
 
181
235
  # hegel_generate_float. Hegel::Generators::FloatGenerator's own
182
236
  # primitive.
183
237
  def generate_float(width, min_value, max_value, allow_nan:, allow_infinity:, exclude_min:, exclude_max:,
184
238
  smallest_nonzero_magnitude:)
185
- @impl.generate_float(@ctx, @handle, width, min_value, max_value, allow_nan, allow_infinity, exclude_min,
239
+ @impl.generate_float(@ctx, live_handle, width, min_value, max_value, allow_nan, allow_infinity, exclude_min,
186
240
  exclude_max, smallest_nonzero_magnitude)
187
241
  end
188
242
 
@@ -220,33 +274,49 @@ module Hegel
220
274
  # this class makes. Writing the same check on this side would only
221
275
  # give one mistake two messages to drift apart from each other.
222
276
  def target(value, label: "")
223
- @impl.target(@ctx, @handle, value, label)
277
+ @impl.target(@ctx, live_handle, value, label)
278
+ end
279
+
280
+ def event(label)
281
+ @impl.event(@ctx, live_handle, label)
282
+ end
283
+
284
+ # No finite check here: hegel_event_value's HEGEL_E_INVALID_ARG reaches
285
+ # the caller through LibHegel.check!, as it does for #target.
286
+ def event_value(label, value)
287
+ @impl.event_value(@ctx, live_handle, label, value)
224
288
  end
225
289
 
226
290
  # hegel_start_span, labelled with a generator's label (see
227
291
  # Hegel::Generator#label). Every compound Hegel::Generator (map, filter,
228
292
  # arrays) opens one of these around its own draw; see #stop_span.
229
293
  def start_span(label)
230
- @impl.start_span(@ctx, @handle, label)
294
+ @impl.start_span(@ctx, live_handle, label)
231
295
  end
232
296
 
233
297
  # hegel_stop_span, closing the span #start_span most recently opened.
234
298
  # +discard+ true marks it rejected (a filter predicate that did not
235
299
  # hold), so libhegel retries from before the span opened.
300
+ #
301
+ # Makes no native call while an abandoned recursive attempt unwinds:
302
+ # hegel_recursion_retry discards the spans that attempt left open, and
303
+ # closing them here first would keep them. See docs/adr/0032.
236
304
  def stop_span(discard: false)
305
+ return if @poison
306
+
237
307
  @impl.stop_span(@ctx, @handle, discard)
238
308
  end
239
309
 
240
310
  # hegel_new_collection: Hegel::Generators::ArrayGenerator's own sizing
241
311
  # primitive, paired with #collection_more and #collection_free.
242
312
  def new_collection(min_size, max_size)
243
- @impl.new_collection(@ctx, @handle, min_size, max_size)
313
+ @impl.new_collection(@ctx, live_handle, min_size, max_size)
244
314
  end
245
315
 
246
316
  # hegel_collection_more: whether to draw another element into
247
317
  # +collection+.
248
318
  def collection_more(collection)
249
- @impl.collection_more(@ctx, @handle, collection)
319
+ @impl.collection_more(@ctx, live_handle, collection)
250
320
  end
251
321
 
252
322
  # hegel_collection_reject: tells libhegel the element most recently
@@ -255,7 +325,7 @@ module Hegel
255
325
  # #collection_more call offers another attempt at the same slot instead
256
326
  # of treating the collection as one element closer to done.
257
327
  def collection_reject(collection, why: nil)
258
- @impl.collection_reject(@ctx, @handle, collection, why)
328
+ @impl.collection_reject(@ctx, live_handle, collection, why)
259
329
  end
260
330
 
261
331
  # hegel_collection_free.
@@ -275,10 +345,11 @@ module Hegel
275
345
  end
276
346
 
277
347
  # Runs the block with a regex-matching string generator handle built
278
- # from +pattern+/+fullmatch+ (see #string_generator_regex), freeing it
279
- # via #with_generator_handle whether the block returns or raises.
280
- def with_regex_generator(pattern, fullmatch:, &block)
281
- with_generator_handle(string_generator_regex(pattern, fullmatch), &block)
348
+ # from +pattern+/+fullmatch+/+alphabet+ (see #string_generator_regex),
349
+ # freeing it via #with_generator_handle whether the block returns or
350
+ # raises. +alphabet+ stays the caller's to free.
351
+ def with_regex_generator(pattern, fullmatch:, alphabet: nil, &block)
352
+ with_generator_handle(string_generator_regex(pattern, fullmatch, alphabet), &block)
282
353
  end
283
354
 
284
355
  # Runs the block with an email-address string generator handle (see
@@ -304,26 +375,26 @@ module Hegel
304
375
  # #with_regex_generator, #with_email_generator, #with_url_generator, or
305
376
  # #with_domain_generator).
306
377
  def generate_string(generator)
307
- @impl.generate_string(@ctx, @handle, generator)
378
+ @impl.generate_string(@ctx, live_handle, generator)
308
379
  end
309
380
 
310
381
  # hegel_generate_bytes. A future Hegel::Generators::BinaryGenerator's
311
382
  # own primitive, the bytes counterpart to #generate_string.
312
383
  def generate_bytes(min_size, max_size)
313
- @impl.generate_bytes(@ctx, @handle, min_size, max_size)
384
+ @impl.generate_bytes(@ctx, live_handle, min_size, max_size)
314
385
  end
315
386
 
316
387
  # hegel_generate_ipv4, returning the address's 4 raw bytes. Converting
317
388
  # to a caller-facing address type (IPAddr or similar) is left to the
318
389
  # generator built on top of this call.
319
390
  def generate_ipv4
320
- @impl.generate_ipv4(@ctx, @handle)
391
+ @impl.generate_ipv4(@ctx, live_handle)
321
392
  end
322
393
 
323
394
  # hegel_generate_ipv6, returning the address's 16 raw bytes. Same
324
395
  # division of labor as #generate_ipv4.
325
396
  def generate_ipv6
326
- @impl.generate_ipv6(@ctx, @handle)
397
+ @impl.generate_ipv6(@ctx, live_handle)
327
398
  end
328
399
 
329
400
  # hegel_generate_uuid, returning 16 raw bytes. Hegel::Generators::
@@ -331,7 +402,7 @@ module Hegel
331
402
  # hex String is that generator's job, the same division of labor
332
403
  # #generate_ipv4/#generate_ipv6 already follow.
333
404
  def generate_uuid(version, has_version)
334
- @impl.generate_uuid(@ctx, @handle, version, has_version)
405
+ @impl.generate_uuid(@ctx, live_handle, version, has_version)
335
406
  end
336
407
 
337
408
  # hegel_string_generator_regex. +alphabet+ is an optional string
@@ -385,7 +456,7 @@ module Hegel
385
456
  # Hegel::Stateful::Pool.new cannot forget it and leak the handle --
386
457
  # docs/adr/0011 has the ownership decision behind this.
387
458
  def new_pool
388
- pool = @impl.new_pool(@ctx, @handle)
459
+ pool = @impl.new_pool(@ctx, live_handle)
389
460
  @pools << pool
390
461
  pool
391
462
  end
@@ -394,7 +465,7 @@ module Hegel
394
465
  # associate with the value it just generated. Hegel::Stateful::Pool#add's
395
466
  # own primitive.
396
467
  def pool_add(pool)
397
- @impl.pool_add(@ctx, @handle, pool)
468
+ @impl.pool_add(@ctx, live_handle, pool)
398
469
  end
399
470
 
400
471
  # hegel_pool_generate: the variable id libhegel chose from +pool+ (and
@@ -405,7 +476,13 @@ module Hegel
405
476
  # translates that to Hegel::AssumeFailed, the same as every other
406
477
  # assumption failure.
407
478
  def pool_generate(pool, consume)
408
- @impl.pool_generate(@ctx, @handle, pool, consume)
479
+ @impl.pool_generate(@ctx, live_handle, pool, consume)
480
+ end
481
+
482
+ # hegel_pool_restore: puts back +variable_id+, a consuming
483
+ # #pool_generate's answer that the caller is throwing away unused.
484
+ def pool_restore(pool, variable_id)
485
+ @impl.pool_restore(@ctx, live_handle, pool, variable_id)
409
486
  end
410
487
 
411
488
  # hegel_pool_free. Not called directly by a caller of
@@ -431,34 +508,34 @@ module Hegel
431
508
  # here rather than on that module, the same reason every other native
432
509
  # call is a method on this class: Hegel::Stateful never touches @impl or
433
510
  # @ctx directly, only this handle-scoped surface.
434
- def new_state_machine(rule_names, invariant_names, always_check, step_count)
435
- @impl.new_state_machine(@ctx, @handle, rule_names, invariant_names, always_check, step_count)
511
+ def new_state_machine(rule_names, rule_weights, invariant_names, always_check, step_count)
512
+ @impl.new_state_machine(@ctx, live_handle, rule_names, rule_weights, invariant_names, always_check, step_count)
436
513
  end
437
514
 
438
515
  # hegel_state_machine_next_group: starts the next round, or returns
439
516
  # LibHegel::HEGEL_STATE_MACHINE_DONE once the machine is done.
440
517
  def state_machine_next_group(state_machine)
441
- @impl.state_machine_next_group(@ctx, @handle, state_machine)
518
+ @impl.state_machine_next_group(@ctx, live_handle, state_machine)
442
519
  end
443
520
 
444
521
  # hegel_state_machine_next_rule: the index (into the +rule_names+
445
522
  # #new_state_machine was given) of the next rule to run this round, or
446
523
  # LibHegel::HEGEL_STATE_MACHINE_DONE once the round is over.
447
524
  def state_machine_next_rule(state_machine)
448
- @impl.state_machine_next_rule(@ctx, @handle, state_machine)
525
+ @impl.state_machine_next_rule(@ctx, live_handle, state_machine)
449
526
  end
450
527
 
451
528
  # hegel_state_machine_rule_rejected: tells libhegel the rule most
452
529
  # recently returned by #state_machine_next_rule stopped early on a
453
530
  # failed assumption, so its round does not count toward the step budget.
454
531
  def state_machine_rule_rejected(state_machine)
455
- @impl.state_machine_rule_rejected(@ctx, @handle, state_machine)
532
+ @impl.state_machine_rule_rejected(@ctx, live_handle, state_machine)
456
533
  end
457
534
 
458
535
  # hegel_state_machine_should_check_invariant: whether to run the
459
536
  # invariant at +index+ at this join point.
460
537
  def state_machine_should_check_invariant(state_machine, index)
461
- @impl.state_machine_should_check_invariant(@ctx, @handle, state_machine, index)
538
+ @impl.state_machine_should_check_invariant(@ctx, live_handle, state_machine, index)
462
539
  end
463
540
 
464
541
  # hegel_state_machine_free. Takes no test-case handle, unlike every
@@ -470,11 +547,69 @@ module Hegel
470
547
  @impl.state_machine_free(@ctx, state_machine)
471
548
  end
472
549
 
550
+ # A hegel_new_recursion handle, and whether #recursion_free has
551
+ # released it.
552
+ Recursion = Struct.new(:handle, :freed)
553
+
554
+ # hegel_new_recursion. Hegel::Generators::RecursiveGenerator's own
555
+ # scope, freed by #recursion_free in that generator's `ensure`.
556
+ def new_recursion(max_depth, max_leaves)
557
+ Recursion.new(@impl.new_recursion(@ctx, live_handle, max_depth, max_leaves), false)
558
+ end
559
+
560
+ # hegel_recursion_branch: whether the sub-value at +depth+ is a branch.
561
+ def recursion_branch(recursion, depth)
562
+ @impl.recursion_branch(@ctx, live_handle, recursion.handle, depth)
563
+ end
564
+
565
+ # hegel_recursion_leaf. Raises Hegel::LeafBudgetExceeded when the
566
+ # attempt outgrew its leaf budget, and keeps it until #recursion_retry:
567
+ # every call that takes the test-case handle raises it again instead of
568
+ # reaching the engine, so a body that rescues it cannot draw on into an
569
+ # attempt the engine is about to discard.
570
+ def recursion_leaf(recursion)
571
+ return if @impl.recursion_leaf(@ctx, live_handle, recursion.handle)
572
+
573
+ @poison = LeafBudgetExceeded.new(recursion)
574
+ raise @poison
575
+ end
576
+
577
+ # True while the attempt +recursion+ abandoned through #recursion_leaf
578
+ # still waits for #recursion_retry.
579
+ def abandoned_by?(recursion)
580
+ @poison.is_a?(LeafBudgetExceeded) && @poison.recursion.equal?(recursion)
581
+ end
582
+
583
+ # hegel_recursion_retry, which reaches the engine with the raw handle
584
+ # while an attempt is abandoned. HEGEL_E_ASSUME or HEGEL_E_STOP_TEST ends the
585
+ # test case, so its exception takes the abandoned attempt's place and
586
+ # later calls raise it without reaching the engine.
587
+ def recursion_retry(recursion)
588
+ @impl.recursion_retry(@ctx, @handle, recursion.handle)
589
+ @poison = nil
590
+ rescue AssumeFailed, StopTest => e
591
+ @poison = e
592
+ raise
593
+ end
594
+
595
+ # hegel_recursion_finish: false when the engine discarded the finished
596
+ # attempt, which the caller then draws again from the root.
597
+ def recursion_finish(recursion)
598
+ @impl.recursion_finish(@ctx, live_handle, recursion.handle)
599
+ end
600
+
601
+ # hegel_recursion_free. Takes no test-case handle, so it runs while an
602
+ # attempt is abandoned.
603
+ def recursion_free(recursion)
604
+ recursion.freed = true
605
+ @impl.recursion_free(@ctx, recursion.handle)
606
+ end
607
+
473
608
  # hegel_generate_date, without recording. Hegel::Generators::
474
609
  # DatesGenerator's own primitive. +min_value+/+max_value+ are each a
475
610
  # [year, month, day] Array; the return value is the same shape.
476
611
  def generate_date(min_value, max_value)
477
- @impl.generate_date(@ctx, @handle, min_value, max_value)
612
+ @impl.generate_date(@ctx, live_handle, min_value, max_value)
478
613
  end
479
614
 
480
615
  # hegel_generate_time, without recording. Hegel::Generators::
@@ -482,7 +617,7 @@ module Hegel
482
617
  # [hour, minute, second, microsecond] Array; the return value is the
483
618
  # same shape.
484
619
  def generate_time(min_value, max_value)
485
- @impl.generate_time(@ctx, @handle, min_value, max_value)
620
+ @impl.generate_time(@ctx, live_handle, min_value, max_value)
486
621
  end
487
622
 
488
623
  # hegel_generate_datetime, without recording. Hegel::Generators::
@@ -491,11 +626,97 @@ module Hegel
491
626
  # [hour, minute, second, microsecond] Array; the return value is a
492
627
  # [[year, month, day], [hour, minute, second, microsecond]] pair.
493
628
  def generate_datetime(min_date, min_time, max_date, max_time)
494
- @impl.generate_datetime(@ctx, @handle, min_date, min_time, max_date, max_time)
629
+ @impl.generate_datetime(@ctx, live_handle, min_date, min_time, max_date, max_time)
630
+ end
631
+
632
+ # Yields the Hegel::GrammarCache::Compiled under +key+ in the run's
633
+ # cache, calling +build+ to compile it on the first draw. Without a
634
+ # cache, it compiles one for this draw alone and frees it afterwards.
635
+ def with_grammar(key, build)
636
+ return yield @grammars.fetch(key, &build) if @grammars
637
+
638
+ compiled = build.call
639
+ begin
640
+ yield compiled
641
+ ensure
642
+ grammar_free(compiled.grammar)
643
+ end
644
+ end
645
+
646
+ def grammar_builder_new(text)
647
+ @impl.grammar_builder_new(@ctx, text)
648
+ end
649
+
650
+ def grammar_builder_file(builder, index)
651
+ @impl.grammar_builder_file(@ctx, builder, index)
652
+ end
653
+
654
+ def grammar_builder_provide(builder, index, text)
655
+ @impl.grammar_builder_provide(@ctx, builder, index, text)
656
+ end
657
+
658
+ def grammar_builder_free(builder)
659
+ @impl.grammar_builder_free(@ctx, builder)
660
+ end
661
+
662
+ def grammar_new(builder, start, max_size, max_depth, alphabet, names)
663
+ @impl.grammar_new(@ctx, builder, start, max_size, max_depth, alphabet, names)
664
+ end
665
+
666
+ def grammar_placeholder(grammar, name)
667
+ @impl.grammar_placeholder(@ctx, grammar, name)
668
+ end
669
+
670
+ def grammar_free(grammar)
671
+ @impl.grammar_free(@ctx, grammar)
672
+ end
673
+
674
+ # hegel_generate_grammar, without recording.
675
+ def generate_grammar(grammar, max_size)
676
+ @impl.generate_grammar(@ctx, live_handle, grammar, max_size)
495
677
  end
496
678
 
497
679
  private
498
680
 
681
+ def live_handle
682
+ raise @poison if @poison
683
+
684
+ @handle
685
+ end
686
+
687
+ # Runs one #repeat iteration with the indent raised by REPEAT_INDENT.
688
+ # hegel-rust indents an iteration's output through hegel_test_case_block,
689
+ # which indents only what the engine printer prints. This library
690
+ # renders its report itself (Hegel::Report, docs/adr/0005), so the
691
+ # indent is kept on this side and recorded with each entry.
692
+ #
693
+ # The rescue order follows hegel-rust's TestCase::repeat, after the
694
+ # fatal exceptions every `rescue Exception` here lets through first. The
695
+ # library's own control exceptions, a nested #repeat's Hegel::LoopDone
696
+ # among them, and Hegel::Error, which stands where hegel-rust's
697
+ # InvalidArgument and InternalError stand, re-raise unchanged. A failed
698
+ # assumption skips to the next iteration. Anything else is a failure:
699
+ # one boolean is drawn before it re-raises, as hegel-rust draws one.
700
+ #
701
+ # standard:disable Lint/RescueException -- the same reason as
702
+ # Hegel::Runner.classify: a failing assertion descends from Exception.
703
+ def repeat_iteration
704
+ @indent += REPEAT_INDENT
705
+ begin
706
+ yield
707
+ ensure
708
+ @indent -= REPEAT_INDENT
709
+ end
710
+ rescue *Hegel::FATAL_EXCEPTIONS, Hegel::StopTest, Hegel::LoopDone, Hegel::Error
711
+ raise
712
+ rescue Hegel::AssumeFailed
713
+ # Skips this iteration; #repeat goes on to the next one.
714
+ rescue Exception
715
+ generate_boolean
716
+ raise
717
+ end
718
+ # standard:enable Lint/RescueException
719
+
499
720
  # Runs the block with +generator+, freeing it via #string_generator_free
500
721
  # whether the block returns or raises. The one #ensure
501
722
  # #with_regex_generator/#with_email_generator/#with_url_generator/
@@ -522,7 +743,7 @@ module Hegel
522
743
  def record_draw(label, value)
523
744
  return unless @record
524
745
 
525
- @entries << [:draw, name_for(label, DRAW_CALLER_DEPTH), value]
746
+ @entries << [:draw, name_for(label, DRAW_CALLER_DEPTH), value, @indent]
526
747
  end
527
748
 
528
749
  # The single place a draw's name is decided, tried in this order: the
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runner"
4
+
5
+ module Hegel
6
+ # Where a Hegel.test call lives, in the four pieces
7
+ # hegel_settings_set_test_location takes. Under Antithesis the engine
8
+ # reports each run's verdict as the assertion
9
+ # "<class_name>::<function> passes properties" at file:begin_line, so two
10
+ # calls that share a class_name and a function share one assertion.
11
+ TestLocation = Data.define(:file, :begin_line, :class_name, :function)
12
+
13
+ class TestLocation
14
+ # Matches the label prefix Ruby 3.4 puts on a frame inside a block, so
15
+ # that the method underneath names the test.
16
+ BLOCK_PREFIX = /\A(?:block(?: \(\d+ levels\))? in )+/
17
+
18
+ # Matches "Class#method" and "Class.method". Greedy, so a namespaced
19
+ # class keeps its "::" and a "#<Class:0x...>" label splits at its last
20
+ # separator.
21
+ METHOD_LABEL = /\A(.+)[#.]([^#.]+)\z/
22
+
23
+ # The first of +locations+ that is the caller's own, by the same rule
24
+ # Runner.origin_for uses to name a failure (see ADR 0012), so a wrapper in
25
+ # this gem, a gem, or the standard library is not named as the test.
26
+ # +locations+ is never empty, because the frame of Hegel.test's own caller
27
+ # exists even at the top of a script.
28
+ def self.from_locations(locations)
29
+ location = locations.find { |candidate| !Runner.infrastructure?(candidate.path) } || locations.first
30
+ path = location.absolute_path || location.path
31
+ root = File.join(Dir.pwd, "")
32
+ file = path.start_with?(root) ? path.delete_prefix(root) : path
33
+ class_name, function = names(location.label, file, location.lineno)
34
+ new(file: file, begin_line: location.lineno, class_name: class_name, function: function)
35
+ end
36
+
37
+ # [class_name, function] for a frame +label+. A frame with no enclosing
38
+ # method (a script body, or an RSpec example, whose block sits at the top
39
+ # of its file) has no name of its own, so it takes its file and line:
40
+ # that keeps two examples in one file from sharing one assertion.
41
+ def self.names(label, file, lineno)
42
+ match = METHOD_LABEL.match(label.sub(BLOCK_PREFIX, ""))
43
+ return match.captures if match
44
+
45
+ [file.delete_suffix(".rb"), "line #{lineno}"]
46
+ end
47
+ private_class_method :names
48
+ end
49
+ end
data/lib/hegel/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hegel
4
- VERSION = "0.3.4"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/hegel.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "hegel/version"
4
4
  require_relative "hegel/errors"
5
5
  require_relative "hegel/runner"
6
+ require_relative "hegel/test_location"
6
7
  require_relative "hegel/generator"
7
8
  require_relative "hegel/generators"
8
9
  require_relative "hegel/syntax/methods"
@@ -28,6 +29,13 @@ module Hegel
28
29
  # assertion failure rather than as a Hegel-specific one. Raises
29
30
  # Hegel::Error for a run-level failure instead of a property failure.
30
31
  #
32
+ # +profile+ names the settings profile the run starts from: one of the
33
+ # engine's shipped profiles, one in hegel.toml, or one registered with
34
+ # Hegel::Settings.register_profile. Left nil, the run starts from the
35
+ # default profile. A keyword passed alongside still beats the profile, and
36
+ # report_multiple_failures is always passed, so a profile's value for it
37
+ # never applies (see docs/adr/0028). An unknown name raises Hegel::Error.
38
+ #
31
39
  # +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
32
40
  # +suppress_health_check+ all default to nil, which means the same thing
33
41
  # for each of them: do not call the matching libhegel setter, and let the
@@ -54,6 +62,11 @@ module Hegel
54
62
  # keyword above: see Hegel::Runner.run's own comment for why it is always
55
63
  # passed.
56
64
  #
65
+ # +stop_on_first_failure+, +print_blob+, +show_statistics+,
66
+ # +nondeterminism_strictness+, and +backend+ default to nil, so the engine's
67
+ # profile decides as it does for +test_cases+; they follow hegel-rust's
68
+ # Settings.
69
+ #
57
70
  # +output+ (default $stderr) is where a failure report is written; a
58
71
  # caller passes its own IO to capture that report instead (tests do).
59
72
  #
@@ -66,13 +79,16 @@ module Hegel
66
79
  # real engine. Ordinary callers never pass it. Its default expression
67
80
  # (#default_impl) only runs when +impl+ is not given, so a test that does
68
81
  # pass one never opens the native library at all.
69
- def test(test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
70
- phases: nil, suppress_health_check: nil, report_multiple_failures: false, output: $stderr,
82
+ def test(profile: nil, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
83
+ phases: nil, suppress_health_check: nil, report_multiple_failures: false, stop_on_first_failure: nil,
84
+ print_blob: nil, show_statistics: nil, nondeterminism_strictness: nil, backend: nil, output: $stderr,
71
85
  reproduce_failure: nil, impl: default_impl, &block)
72
- Runner.run(impl: impl, test_cases: test_cases, seed: seed, derandomize: derandomize, verbosity: verbosity,
86
+ Runner.run(impl: impl, profile: profile, test_cases: test_cases, seed: seed, derandomize: derandomize, verbosity: verbosity,
73
87
  database: database, database_key: database_key, phases: phases, suppress_health_check: suppress_health_check,
74
- report_multiple_failures: report_multiple_failures, output: output, reproduce_failure: reproduce_failure,
75
- &block)
88
+ report_multiple_failures: report_multiple_failures, stop_on_first_failure: stop_on_first_failure,
89
+ print_blob: print_blob, show_statistics: show_statistics, nondeterminism_strictness: nondeterminism_strictness,
90
+ backend: backend, output: output, reproduce_failure: reproduce_failure,
91
+ test_location: TestLocation.from_locations(caller_locations(1)), &block)
76
92
  end
77
93
 
78
94
  # The Hegel::LibHegel::Real instance #test uses by default, built once and