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
data/docs/architecture.md CHANGED
@@ -18,7 +18,7 @@ recorded, and prints a blob that `Hegel.test(reproduce_failure:)` replays.
18
18
  The report comes from the cases the engine stamps for capture during the
19
19
  run; see
20
20
  [ADR 0019](adr/0019-report-failures-from-the-cases-the-engine-stamps.md).
21
- The generator layer covers twenty-five generators composing through `map`
21
+ The generator layer covers thirty-four generators composing through `map`
22
22
  and `filter`, reachable bare through `Hegel::Syntax::Methods`. A test case
23
23
  can discard itself with `tc.assume` or `tc.reject`. A run can be shaped by
24
24
  `phases:`, `suppress_health_check:`, `report_multiple_failures:`, and the
@@ -121,6 +121,12 @@ test-case handle, in nested `ensure`s so a failure releasing one cannot skip
121
121
  the other. See
122
122
  [ADR 0011](adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md).
123
123
 
124
+ A compiled grammar outlives the test case that compiled it, since compiling
125
+ costs far more than drawing. `Hegel::Runner.run` keeps the run's grammars
126
+ in a `Hegel::GrammarCache` and frees them after the run and before the
127
+ context. See
128
+ [ADR 0033](adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md).
129
+
124
130
  ## A rule's own control flow
125
131
 
126
132
  Inside a stateful rule, `tc.assume(false)` means something narrower than it
@@ -135,6 +141,15 @@ exception can be raised inside an open span, which is why
135
141
  than beside the run loop: two `rescue Exception` sites need it now, and both
136
142
  have to let those four straight through before anything else.
137
143
 
144
+ ## An abandoned recursive attempt
145
+
146
+ `recursive` unwinds through open spans on purpose. When an attempt outgrows its leaf budget, `Hegel::TestCase` raises
147
+ `Hegel::LeafBudgetExceeded` and holds it until `hegel_recursion_retry`.
148
+ Meanwhile `stop_span` makes no native call, so the retry can discard the
149
+ spans the attempt opened, and every other call that takes the test-case
150
+ handle raises the held exception again instead of reaching the engine. See
151
+ [ADR 0032](adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md).
152
+
138
153
  ## Generator validation happens at draw time
139
154
 
140
155
  hegel-rust's own contributor documentation states the rule for its
data/lib/hegel/errors.rb CHANGED
@@ -31,6 +31,30 @@ module Hegel
31
31
  # Descends from Exception for the same reason as StopTest above.
32
32
  class AssumeFailed < Exception; end
33
33
 
34
+ # Raised for HEGEL_E_RETRY from hegel_recursion_leaf: the attempt at a
35
+ # recursive value outgrew its leaf budget and must unwind to the
36
+ # Generators::RecursiveGenerator that owns +recursion+, which retries it.
37
+ # Internal to this library. See docs/adr/0032.
38
+ class LeafBudgetExceeded < Exception # :nodoc:
39
+ attr_reader :recursion
40
+
41
+ def initialize(recursion)
42
+ super("hegel: a recursive draw outgrew its leaf budget; the attempt is retried")
43
+ @recursion = recursion
44
+ end
45
+ end
46
+
47
+ # Raised by Hegel::TestCase#repeat when the engine ends its loop: the
48
+ # repetition ran as long as the engine wanted it to and nothing failed,
49
+ # so Hegel::Runner.classify reports the case VALID. hegel-rust raises its
50
+ # own LoopDone at the same point, and its run lifecycle maps it to VALID
51
+ # the same way.
52
+ #
53
+ # Descends from Exception for the same reason as StopTest above: a
54
+ # `rescue => e` written after the loop must not turn the end of the loop
55
+ # into ordinary control flow. See docs/adr/0034.
56
+ class LoopDone < Exception; end
57
+
34
58
  # standard:enable Lint/InheritException
35
59
 
36
60
  # Say the process is ending, not that a property failed. Every
@@ -18,6 +18,41 @@ module Hegel
18
18
  raise NotImplementedError, "#{self.class} must implement #do_draw"
19
19
  end
20
20
 
21
+ # The span label of one attempt at a filtered draw, shared by every
22
+ # generator that runs attempts.
23
+ FILTER_ATTEMPT_LABEL = LibHegel.label_from_name("hegel-ruby.Hegel::Generator.filter_attempt")
24
+
25
+ # Draws one value that +predicate+ accepts, the draw behind #filter, which
26
+ # calls this on its source generator. The default makes up to
27
+ # Filtered::MAX_ATTEMPTS attempts, each a #do_draw in a span of its own
28
+ # that closes as discarded when the predicate rejects the value, then
29
+ # discards the test case (Hegel::AssumeFailed).
30
+ #
31
+ # A generator whose draws have effects a rejected value must not keep
32
+ # overrides this and runs the attempts itself, as
33
+ # Hegel::Stateful::Pool::ValuesConsumed does. One that only forwards
34
+ # #do_draw to another generator forwards this too, so that a filter around
35
+ # it still reaches the other generator's attempts.
36
+ def do_draw_filtered(tc, predicate)
37
+ Filtered::MAX_ATTEMPTS.times do
38
+ # Each variable is set again before it is read, or stays nil, which
39
+ # acts like false (!nil == !false).
40
+ # The initializer also shows intent: discard the span when an
41
+ # exception skips the reassignment.
42
+ accepted = false # mutineer:disable-line statement_removal
43
+ value = nil # mutineer:disable-line statement_removal, boolean_literal
44
+ tc.start_span(FILTER_ATTEMPT_LABEL)
45
+ begin
46
+ value = do_draw(tc)
47
+ accepted = predicate.call(value)
48
+ ensure
49
+ tc.stop_span(discard: !accepted)
50
+ end
51
+ return value if accepted
52
+ end
53
+ raise Hegel::AssumeFailed
54
+ end
55
+
21
56
  # The span label naming this generator's draws to the engine, which
22
57
  # treats two spans with one label as draws of one generator when it
23
58
  # shrinks. A generator built from others overrides this and combines
@@ -30,7 +65,7 @@ module Hegel
30
65
  # The class name, prefixed with this binding's name as hegel.h asks, to
31
66
  # keep clear of the engine's own hegel.<kind> labels.
32
67
  def self.label
33
- LibHegel.label_from_name("hegel-ruby.#{name}")
68
+ @label ||= LibHegel.label_from_name("hegel-ruby.#{name}")
34
69
  end
35
70
 
36
71
  # Combines this generator's class label with the labels of +parts+, the
@@ -92,13 +127,17 @@ module Hegel
92
127
  end
93
128
  end
94
129
 
95
- # Generator#filter's result. Retries the source generator, each
96
- # attempt in its own span, up to MAX_ATTEMPTS times before discarding
97
- # the test case (Hegel::AssumeFailed) -- the same budget hegel-rust's
98
- # Filtered spends before calling assume(false)
99
- # (src/generators/generators.rs), ported here rather than reinvented so
100
- # a predicate that (almost) never holds fails the same way in both
101
- # bindings.
130
+ # Generator#filter's result. Hands its predicate to the source
131
+ # generator's #do_draw_filtered, which makes up to MAX_ATTEMPTS attempts
132
+ # before discarding the test case (Hegel::AssumeFailed) -- the same
133
+ # budget hegel-rust's Filtered spends before calling assume(false)
134
+ # (frontends/rust/src/generators/generators.rs), ported here rather than
135
+ # reinvented so a predicate that (almost) never holds fails the same way
136
+ # in both bindings.
137
+ #
138
+ # A filter over a filter combines the two predicates into one draw, so
139
+ # the pair spends MAX_ATTEMPTS attempts between them, not that many per
140
+ # level.
102
141
  #
103
142
  # A predicate that never holds does not loop forever: libhegel's own
104
143
  # health check aborts a run that discards too many test cases in a
@@ -116,23 +155,11 @@ module Hegel
116
155
  attr_reader :label
117
156
 
118
157
  def do_draw(tc)
119
- MAX_ATTEMPTS.times do
120
- # Each variable is set again before it is read, or stays nil, which
121
- # acts like false (!nil == !false).
122
- # The initializer also shows intent: discard the span when an
123
- # exception skips the reassignment.
124
- accepted = false # mutineer:disable-line statement_removal
125
- value = nil # mutineer:disable-line statement_removal, boolean_literal
126
- tc.start_span(label)
127
- begin
128
- value = @source.do_draw(tc)
129
- accepted = @block.call(value)
130
- ensure
131
- tc.stop_span(discard: !accepted)
132
- end
133
- return value if accepted
134
- end
135
- raise Hegel::AssumeFailed
158
+ @source.do_draw_filtered(tc, @block)
159
+ end
160
+
161
+ def do_draw_filtered(tc, predicate)
162
+ @source.do_draw_filtered(tc, ->(value) { @block.call(value) && predicate.call(value) })
136
163
  end
137
164
  end
138
165
  end