hegeltest 0.3.4-aarch64-linux → 0.4.1-aarch64-linux

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -0
  3. data/README.ja.md +408 -0
  4. data/README.md +208 -102
  5. data/Rakefile +6 -3
  6. data/docs/README.md +11 -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/adr/0036-follow-rubys-unicode-version.md +50 -0
  17. data/docs/adr/0037-send-a-maximum-at-the-engine-default-sentinel-as-no-bound.md +48 -0
  18. data/docs/architecture.md +16 -1
  19. data/lib/hegel/errors.rb +24 -0
  20. data/lib/hegel/generator.rb +52 -25
  21. data/lib/hegel/generators.rb +715 -45
  22. data/lib/hegel/grammar_cache.rb +36 -0
  23. data/lib/hegel/lib_hegel/real.rb +333 -19
  24. data/lib/hegel/lib_hegel.rb +63 -6
  25. data/lib/hegel/libhegel/libhegel-linux-arm64.so +0 -0
  26. data/lib/hegel/libhegel_version.rb +1 -1
  27. data/lib/hegel/report.rb +13 -10
  28. data/lib/hegel/runner.rb +61 -27
  29. data/lib/hegel/settings.rb +92 -16
  30. data/lib/hegel/state_machine.rb +17 -4
  31. data/lib/hegel/stateful/pool.rb +52 -0
  32. data/lib/hegel/stateful.rb +2 -2
  33. data/lib/hegel/syntax/methods.rb +99 -15
  34. data/lib/hegel/test_case.rb +259 -37
  35. data/lib/hegel/test_location.rb +49 -0
  36. data/lib/hegel/version.rb +1 -1
  37. data/lib/hegel.rb +21 -5
  38. data/sig/hegel.rbs +300 -42
  39. data/skills/hegel-ruby/references/ruby/reference.md +463 -41
  40. metadata +15 -1
@@ -0,0 +1,50 @@
1
+ # 0036: Follow Ruby's Unicode version
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ libhegel 0.48.0 adds `hegel_set_unicode_version`. It sets, for the whole
10
+ process, the Unicode version that the text, regex, and grammar generators
11
+ follow: the General Categories an alphabet is built from, and the `\w`,
12
+ `\d`, `\s`, word boundaries, and case-insensitive matching of patterns. The
13
+ engine has tables for Unicode 14.0.0 to 17.0.0. A binding that never calls it
14
+ gets the newest.
15
+
16
+ Ruby classifies characters by the Unicode version it was built with, which
17
+ `RbConfig::CONFIG["UNICODE_VERSION"]` reports: 15.0.0 on Ruby 3.4. With the
18
+ engine's 17.0.0 tables, `text(categories: ["Lu"])` can draw a character Ruby
19
+ calls unassigned, and `from_regex` can draw a string Ruby's own `Regexp` reads
20
+ differently. U+20C1 is one such character: a currency sign in 17.0.0, and
21
+ unassigned in every earlier version.
22
+
23
+ The Python frontend in hegeldev/hegel calls the function at import with
24
+ `unicodedata.unidata_version`, for the same reason. The Rust frontend declares
25
+ it in `frontends/rust/src/ffi/sys/fns.rs`; a search of `frontends/rust/src` at
26
+ the `libhegel/0.48.0` tag finds that declaration as its one occurrence, so this
27
+ gem follows the Python frontend here.
28
+
29
+ ## Decision
30
+
31
+ `LibHegel::Real#initialize` passes `RbConfig::CONFIG["UNICODE_VERSION"]` to
32
+ `hegel_set_unicode_version`, next to the engine version check, so that every
33
+ generator built afterwards follows Ruby's tables. No keyword exposes it. ADR
34
+ 0015 leaves mechanism to each binding, and which host's tables the engine
35
+ follows is mechanism: the interface stays the same, and a drawn value now
36
+ means what Ruby takes it to mean.
37
+
38
+ A version the engine has no tables for raises `Hegel::Error` when the engine
39
+ is loaded. Ruby 3.4, the oldest Ruby this gem supports, reports 15.0.0, and
40
+ the engine answers a version newer than its own with its newest tables, so
41
+ no supported Ruby reaches that error.
42
+
43
+ ## Consequences
44
+
45
+ On Ruby 3.4, characters assigned in Unicode 15.1.0 to 17.0.0 are drawn as
46
+ unassigned (`Cn`) rather than in their new categories, as Ruby classifies
47
+ them.
48
+
49
+ The setting is process-wide, so a test that changes it puts back Ruby's
50
+ version afterwards.
@@ -0,0 +1,48 @@
1
+ # 0037: Send a maximum at the engine's default sentinel as no bound
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ libhegel 0.49.0 adds `HEGEL_MAX_SIZE_DEFAULT`, which is `UINT64_MAX - 1`.
10
+ `hegel_new_collection`, `hegel_string_generator_text`, and
11
+ `hegel_generate_bytes` read that `max_size` as "the caller gave no maximum",
12
+ and apply the default of text and bytes: 100, or `min_size + 100` when
13
+ `min_size` is above 100. `UINT64_MAX` continues to mean no upper bound. The
14
+ engine changelog tells a frontend to pass `UINT64_MAX` for a user's explicit
15
+ maximum at or above `HEGEL_MAX_SIZE_DEFAULT`.
16
+
17
+ Before this change, the gem sent a caller's `max_size` to the engine as
18
+ it is. Measured on linux-amd64 with Ruby 3.4.10 against 0.49.0, without this
19
+ change, 300 cases of `binary(min_size: 200, max_size: 2**64 - 2)` had 200 to
20
+ 300 bytes: the engine applied `min_size + 100`. With this change, the same
21
+ draw had 200 to 1465 bytes. A Ruby `Integer` has no upper limit, so a caller
22
+ can also write a maximum above `UINT64_MAX`. Without this change,
23
+ `binary(max_size: 2**70)` raised `RangeError` from the `ffi` gem (measured on
24
+ the same setup).
25
+
26
+ The Rust frontend declares the constant in
27
+ `frontends/rust/src/ffi/sys/types.rs` at the `libhegel/0.49.0` tag. Its text
28
+ generator, in `frontends/rust/src/generators/strings.rs`, continues to pass
29
+ its own default of 100 (inferred from reading that file).
30
+
31
+ ## Decision
32
+
33
+ `Hegel::LibHegel.max_size_arg` changes a `max_size` at or above
34
+ `HEGEL_MAX_SIZE_DEFAULT` to `UINT64_MAX`. `Hegel::TestCase#new_collection`,
35
+ `#with_text_generator`, and `#generate_bytes` apply it, so every generator
36
+ that sizes a draw through the engine sends the caller's maximum this way.
37
+
38
+ The gem continues to compute its own default maximum of text and bytes
39
+ (`Hegel::Generators.default_max_size`) and to send it as a number. It does not
40
+ send the sentinel. The default is the same number, and a reader of the
41
+ generator then sees the default in Ruby.
42
+
43
+ ## Consequences
44
+
45
+ A maximum at or above `2**64 - 2` means no upper bound. No allocation can
46
+ reach a size of that order, so a caller who writes one gets the meaning
47
+ they wrote. A maximum above `2**64 - 1` also means no upper bound now, where it
48
+ raised `RangeError` before.
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